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:
parent
b112647ff1
commit
a68b4cf6ec
18 changed files with 385 additions and 42 deletions
|
|
@ -15,10 +15,12 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "^3.24.4"
|
||||
"mysql2": "^3.24.4",
|
||||
"nodemailer": "^10.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
53
packages/platform/src/mail.ts
Normal file
53
packages/platform/src/mail.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue