feat: SMTP-Stack mit konfigurierbaren Mailvorlagen

Bisher gab es nur zwei fest im Code verdrahtete E-Mails (Einladung,
Passwort-Reset) und die SMTP-Verbindung kam ausschließlich aus
Umgebungsvariablen. Jetzt:

- SMTP-Verbindung unter Einstellungen > E-Mail konfigurierbar (Host,
  Port, Verschlüsselung, Zugangsdaten verschlüsselt gespeichert,
  Absender), mit Testmail-Versand. Die Mail-Logik wandert dafür nach
  @kc/platform/mail, damit API und Worker sie gemeinsam nutzen.
- Mailvorlagen-Verwaltung: jedes Ereignis hat eine feste "Definition"
  (Name, Auslöser, verfügbare Platzhalter/Daten), Betreff/Text sind
  editierbar, je Vorlage einzeln aktivierbar, mit Testversand anhand
  von Beispieldaten. Unbekannte Platzhalter werden beim Speichern
  abgelehnt.
- Neue Ereignisse verdrahtet: Ticket erstellt (Bestätigung an Kunde),
  Personal antwortet auf Ticket (Benachrichtigung an Kunde), Rechnung
  ausgestellt, sowie die zuvor zurückgestellte Funktion "Panel-
  Zugangsdaten per E-Mail senden" nach einem Passwort-Reset. Diese
  laufen über die Jobqueue (mail.template), analog zu discord.notify.
- Versandprotokoll (letzte 100 Versuche, ohne Inhalte) einsehbar unter
  Einstellungen > E-Mail.
- Migration 022, Rechte email.read (Admin+) / email.write (Superadmin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 12:53:29 +02:00
parent b112647ff1
commit a68b4cf6ec
18 changed files with 385 additions and 42 deletions

View file

@ -34,8 +34,5 @@ export const config = {
user: req('DB_USER'),
password: req('DB_PASSWORD'),
},
smtp: process.env.SMTP_HOST
? { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT ?? 587), user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD, from: process.env.SMTP_FROM ?? 'kundencenter@localhost' }
: null,
};
if (config.secretKey.length !== 32) throw new Error('KC_SECRET_KEY muss 32 Byte (base64) sein');

View file

@ -0,0 +1,53 @@
import nodemailer from 'nodemailer';
import { one, run } from './db.js';
import { decrypt } from './crypto.js';
export interface SmtpConfig { host: string; port: number; secureMode: 'none' | 'starttls' | 'tls'; username: string | null; fromName: string | null; fromEmail: string }
/** Lädt die SMTP-Verbindung aus der Datenbank (Einstellungen > E-Mail). Kein Host/Absender gesetzt = nicht eingerichtet. */
export async function loadSmtpConfig(): Promise<SmtpConfig | null> {
const s = await one('SELECT * FROM smtp_settings WHERE id = 1');
if (!s?.host || !s?.from_email) return null;
return { host: s.host, port: s.port, secureMode: s.secure_mode, username: s.username, fromName: s.from_name, fromEmail: s.from_email };
}
async function smtpPassword(): Promise<string | null> {
const s = await one('SELECT secrets_enc FROM smtp_settings WHERE id = 1');
return s?.secrets_enc ? (JSON.parse(decrypt(s.secrets_enc)).password ?? null) : null;
}
async function transportFor(cfg: SmtpConfig) {
return nodemailer.createTransport({
host: cfg.host, port: cfg.port, secure: cfg.secureMode === 'tls', requireTLS: cfg.secureMode === 'starttls',
auth: cfg.username ? { user: cfg.username, pass: (await smtpPassword()) ?? '' } : undefined,
});
}
/** Versand mit Protokoll. Ohne SMTP-Konfiguration wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */
export async function sendMail(to: string, template: string, subject: string, text: string): Promise<'sent' | 'failed' | 'not_configured'> {
const cfg = await loadSmtpConfig();
if (!cfg) {
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']);
return 'not_configured';
}
try {
const transport = await transportFor(cfg);
await transport.sendMail({ from: cfg.fromName ? `"${cfg.fromName.replace(/"/g, "'")}" <${cfg.fromEmail}>` : cfg.fromEmail, to, subject, text });
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']);
return 'sent';
} catch (e) {
await run('INSERT INTO mail_log (to_email, template, status, error) VALUES (?,?,?,?)', [to, template, 'failed', String((e as Error).message).slice(0, 300)]);
return 'failed';
}
}
/** Ersetzt {{platzhalter}} durch die übergebenen Werte; unbekannte Platzhalter bleiben unverändert stehen (fällt in der Vorschau auf). */
export function renderTemplateText(tpl: string, vars: Record<string, string>): string {
return tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in vars ? vars[k]! : m));
}
/** Sendet anhand einer in der Datenbank hinterlegten Vorlage (Einstellungen > E-Mail > Vorlagen). */
export async function sendTemplateMail(to: string, key: string, vars: Record<string, string>): Promise<'sent' | 'failed' | 'not_configured' | 'disabled' | 'no_template'> {
const t = await one('SELECT * FROM mail_templates WHERE `key` = ?', [key]);
if (!t) return 'no_template';
if (!t.enabled) return 'disabled';
return sendMail(to, key, renderTemplateText(t.subject, vars), renderTemplateText(t.body, vars));
}