diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index e1f43e0..1bc006c 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -10,10 +10,12 @@ import { renderTemplateText, sendMail } from '../../core/mail.js'; import type { KcModule } from '../../core/module.js'; const email = z.string().email().max(254).transform((s) => s.toLowerCase()); +const CATEGORIES = ['kundencenter', 'stoerungsticket', 'rechnungen'] as const; const settingsView = (s: any) => ({ host: s.host, port: s.port, secureMode: s.secure_mode, username: s.username, hasPassword: !!s.secrets_enc, fromName: s.from_name, fromEmail: s.from_email, configured: !!s.host && !!s.from_email, updatedAt: s.updated_at, }); +const senderView = (p: any) => ({ category: p.category, name: p.name, fromName: p.from_name, fromEmail: p.from_email, updatedAt: p.updated_at }); /** Beispielwerte für die Testvorschau, je Platzhaltername – fällt auf den Platzhalternamen selbst zurück. */ const SAMPLE: Record = { name: 'Max Mustermann', inviteLink: 'https://kundencenter.example/einladung?token=…', validDays: '7', @@ -52,12 +54,25 @@ export const mailModule: KcModule = { const a = requirePermission(req, 'email.write'); const { to } = z.object({ to: email }).parse(req.body); const status = await sendMail(to, 'test', 'Testmail vom Kundencenter', 'Diese Testmail bestätigt, dass die SMTP-Einstellungen des Kundencenters funktionieren.\n'); - await audit({ actorType: 'user', actorId: a.user.id, action: 'email.settings.test', resourceType: 'smtp_settings', resourceId: '1', result: status === 'sent' ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { to, status } }); - return { status }; + const error = status === 'failed' ? (await one('SELECT error FROM mail_log WHERE to_email = ? AND template = ? ORDER BY id DESC LIMIT 1', [to, 'test']))?.error ?? null : null; + await audit({ actorType: 'user', actorId: a.user.id, action: 'email.settings.test', resourceType: 'smtp_settings', resourceId: '1', result: status === 'sent' ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { to, status, error } }); + return { status, error }; + }); + + // ---- Absenderprofile: eigener Absendername/-adresse je Bereich, leer = globaler SMTP-Absender ---------- + app.get('/admin/email/senders', async (req) => { requirePermission(req, 'email.read'); return (await query('SELECT * FROM mail_sender_profiles ORDER BY category')).map(senderView); }); + app.put('/admin/email/senders/:category', async (req) => { + const a = requirePermission(req, 'email.write'); + const { category } = z.object({ category: z.enum(CATEGORIES) }).parse(req.params); + const b = z.object({ fromName: z.string().trim().max(150).nullable(), fromEmail: z.string().trim().max(254).nullable() }).parse(req.body); + if (b.fromEmail && !z.string().email().safeParse(b.fromEmail).success) throw badRequest('Absenderadresse ist keine gültige E-Mail-Adresse', 'BAD_FROM_EMAIL'); + await run('UPDATE mail_sender_profiles SET from_name = ?, from_email = ?, updated_by = ? WHERE category = ?', [b.fromName, b.fromEmail, a.user.id, category]); + await audit({ actorType: 'user', actorId: a.user.id, action: 'email.sender.update', resourceType: 'mail_sender_profile', resourceId: category, correlationId: req.correlationId, ip: clientIp(req), after: b }); + return { ok: true }; }); // ---- Mailvorlagen: Definition (Auslöser + verfügbare Daten) ist fest, Betreff/Text/aktiv sind editierbar ---- - const templateView = (t: any) => ({ key: t.key, name: t.name, trigger: t.trigger_text, variables: typeof t.variables_json === 'string' ? JSON.parse(t.variables_json) : t.variables_json, subject: t.subject, body: t.body, enabled: !!t.enabled, updatedAt: t.updated_at }); + const templateView = (t: any) => ({ key: t.key, name: t.name, trigger: t.trigger_text, category: t.category, variables: typeof t.variables_json === 'string' ? JSON.parse(t.variables_json) : t.variables_json, subject: t.subject, body: t.body, enabled: !!t.enabled, updatedAt: t.updated_at }); app.get('/admin/email/templates', async (req) => { requirePermission(req, 'email.read'); return (await query('SELECT * FROM mail_templates ORDER BY name')).map(templateView); }); app.get('/admin/email/templates/:key', async (req) => { requirePermission(req, 'email.read'); const { key } = z.object({ key: z.string().max(60) }).parse(req.params); @@ -82,9 +97,10 @@ export const mailModule: KcModule = { const t = await one('SELECT * FROM mail_templates WHERE `key` = ?', [key]); if (!t) throw notFound(); const vars: string[] = typeof t.variables_json === 'string' ? JSON.parse(t.variables_json) : t.variables_json; const sample = Object.fromEntries(vars.map((v) => [v, SAMPLE[v] ?? `[${v}]`])); - const status = await sendMail(to, `${key}:test`, renderTemplateText(t.subject, sample), renderTemplateText(t.body, sample)); - await audit({ actorType: 'user', actorId: a.user.id, action: 'email.template.test', resourceType: 'mail_template', resourceId: key, result: status === 'sent' ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { to, status } }); - return { status }; + const status = await sendMail(to, `${key}:test`, renderTemplateText(t.subject, sample), renderTemplateText(t.body, sample), t.category); + const error = status === 'failed' ? (await one('SELECT error FROM mail_log WHERE to_email = ? AND template = ? ORDER BY id DESC LIMIT 1', [to, `${key}:test`]))?.error ?? null : null; + await audit({ actorType: 'user', actorId: a.user.id, action: 'email.template.test', resourceType: 'mail_template', resourceId: key, result: status === 'sent' ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { to, status, error } }); + return { status, error }; }); // ---- Protokoll der letzten Sendeversuche (kein Inhalt, nur Metadaten) -------------------------- diff --git a/apps/web/src/app/(app)/einstellungen/email/page.tsx b/apps/web/src/app/(app)/einstellungen/email/page.tsx index 65517cf..12f94fd 100644 --- a/apps/web/src/app/(app)/einstellungen/email/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/email/page.tsx @@ -5,9 +5,12 @@ import { useSession } from '@/lib/session'; import { Alert, Field, fmt } from '@/components/ui'; interface Settings { host: string | null; port: number; secureMode: 'none' | 'starttls' | 'tls'; username: string | null; hasPassword: boolean; fromName: string | null; fromEmail: string | null; configured: boolean; updatedAt: string } -interface Template { key: string; name: string; trigger: string; variables: string[]; subject: string; body: string; enabled: boolean; updatedAt: string } +type Category = 'kundencenter' | 'stoerungsticket' | 'rechnungen'; +interface Template { key: string; name: string; trigger: string; category: Category; variables: string[]; subject: string; body: string; enabled: boolean; updatedAt: string } +interface Sender { category: Category; name: string; fromName: string | null; fromEmail: string | null; updatedAt: string } interface LogRow { id: number; to: string; template: string; status: string; error: string | null; createdAt: string } const SECURE_LABEL: Record = { none: 'Keine Verschlüsselung', starttls: 'STARTTLS (empfohlen, meist Port 587)', tls: 'TLS/SSL direkt (meist Port 465)' }; +const CATEGORY_LABEL: Record = { kundencenter: 'Kundencenter', stoerungsticket: 'Störungsticket', rechnungen: 'Rechnungen' }; const STATUS: Record = { sent: { cls: 'ok', icon: '✓', label: 'Gesendet' }, failed: { cls: 'err', icon: '✕', label: 'Fehlgeschlagen' }, not_configured: { cls: 'warn', icon: '▲', label: 'SMTP nicht eingerichtet' }, disabled: { cls: 'lock', icon: '⊘', label: 'Vorlage deaktiviert' }, no_template: { cls: 'err', icon: '✕', label: 'Vorlage fehlt' }, @@ -30,7 +33,7 @@ function SmtpForm({ s, w, onSaved }: { s: Settings; w: boolean; onSaved: () => v } async function sendTest() { setTestBusy(true); setMsg(null); - try { const r = await api<{ status: string }>('POST', '/admin/email/settings/test', { to: testTo }); setMsg({ k: r.status === 'sent' ? 'ok' : 'err', t: r.status === 'sent' ? `Testmail an ${testTo} gesendet.` : `Status: ${STATUS[r.status]?.label ?? r.status}` }); } + try { const r = await api<{ status: string; error: string | null }>('POST', '/admin/email/settings/test', { to: testTo }); setMsg({ k: r.status === 'sent' ? 'ok' : 'err', t: r.status === 'sent' ? `Testmail an ${testTo} gesendet.` : `Status: ${STATUS[r.status]?.label ?? r.status}${r.error ? ` – ${r.error}` : ''}` }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); } } return (
@@ -57,6 +60,31 @@ function SmtpForm({ s, w, onSaved }: { s: Settings; w: boolean; onSaved: () => v
); } +/** Absenderprofile je Bereich: eigener Name/Adresse statt des globalen SMTP-Absenders, leer = globaler Absender wird verwendet. */ +function SenderRow({ p, w, fallbackName, fallbackEmail, onSaved }: { p: Sender; w: boolean; fallbackName: string; fallbackEmail: string; onSaved: () => void }) { + const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); + async function save(e: FormEvent) { + e.preventDefault(); if (!w) return; setBusy(true); setMsg(null); + const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || null); + try { await api('PUT', `/admin/email/senders/${p.category}`, { fromName: v('fromName'), fromEmail: v('fromEmail') }); setMsg({ k: 'ok', t: 'Gespeichert.' }); onSaved(); } + catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); } + } + return (
+
{CATEGORY_LABEL[p.category]}

{p.name}

{msg &&

{msg.t}

}
+ + + {w && } +
); +} +function SenderProfiles({ senders, s, w, onSaved }: { senders: Sender[]; s: Settings; w: boolean; onSaved: () => void }) { + const fallbackName = s.fromName || 'globaler Absender oben'; const fallbackEmail = s.fromEmail || 'globaler Absender oben'; + return (
+

Absenderprofile

+

Für Kundencenter-Zugang, Störungstickets und Rechnungen kann jeweils ein eigener Absender hinterlegt werden (z. B. support@… für Tickets, rechnung@… für Rechnungen). Leer gelassene Felder verwenden den globalen SMTP-Absender oben.

+ {senders.map((p) => )} +
); +} + /** Eine Vorlage: feste Definition (Auslöser, verfügbare Daten) + editierbarer Betreff/Text. */ function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: () => void }) { const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); @@ -69,12 +97,12 @@ function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: () } async function sendTest() { setTestBusy(true); setMsg(null); - try { const r = await api<{ status: string }>('POST', `/admin/email/templates/${t.key}/test`, { to: testTo }); setMsg({ k: r.status === 'sent' ? 'ok' : 'err', t: r.status === 'sent' ? `Testmail mit Beispieldaten an ${testTo} gesendet.` : `Status: ${STATUS[r.status]?.label ?? r.status}` }); } + try { const r = await api<{ status: string; error: string | null }>('POST', `/admin/email/templates/${t.key}/test`, { to: testTo }); setMsg({ k: r.status === 'sent' ? 'ok' : 'err', t: r.status === 'sent' ? `Testmail mit Beispieldaten an ${testTo} gesendet.` : `Status: ${STATUS[r.status]?.label ?? r.status}${r.error ? ` – ${r.error}` : ''}` }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); } } return (
-

{t.name}

{t.trigger}

+

{t.name}

{t.trigger}

{CATEGORY_LABEL[t.category]}
{!t.enabled && Deaktiviert}
{open &&
@@ -96,9 +124,10 @@ function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: () export default function EmailSettings() { const { can } = useSession(); const w = can('email.write'); - const [s, setS] = useState(null); const [tpls, setTpls] = useState(null); const [log, setLog] = useState(null); const [err, setErr] = useState(''); + const [s, setS] = useState(null); const [senders, setSenders] = useState(null); const [tpls, setTpls] = useState(null); const [log, setLog] = useState(null); const [err, setErr] = useState(''); const load = useCallback(() => { api('GET', '/admin/email/settings').then(setS).catch((e) => setErr(errMsg(e))); + api('GET', '/admin/email/senders').then(setSenders).catch((e) => setErr(errMsg(e))); api('GET', '/admin/email/templates').then(setTpls).catch((e) => setErr(errMsg(e))); api('GET', '/admin/email/log').then(setLog).catch(() => undefined); }, []); @@ -108,6 +137,7 @@ export default function EmailSettings() { {err && {err}} {!w && Nur Superadministratoren können die SMTP-Verbindung und Mailvorlagen ändern. Sie können hier den aktuellen Stand und das Protokoll einsehen.} {s ? :

Wird geladen …

} + {s && senders && }

Mailvorlagen

Jede Vorlage wird bei einem festen Ereignis im Kundencenter verwendet. Betreff und Text können angepasst werden; Platzhalter wie {'{{name}}'} werden beim Versand durch die tatsächlichen Daten ersetzt. Welche Platzhalter je Vorlage zur Verfügung stehen, steht direkt unter „Bearbeiten“.

{tpls === null ?

Wird geladen …

: tpls.map((t) => )} diff --git a/migrations/023_mail_sender_profiles.sql b/migrations/023_mail_sender_profiles.sql new file mode 100644 index 0000000..5293ec7 --- /dev/null +++ b/migrations/023_mail_sender_profiles.sql @@ -0,0 +1,19 @@ +-- Getrennte Absender-Identitäten je Bereich (Kundencenter, Störungsticket, Rechnungen), optional abweichend +-- vom globalen SMTP-Absender (smtp_settings.from_name/from_email). Leer = globaler Absender wird verwendet. +CREATE TABLE mail_sender_profiles ( + category ENUM('kundencenter','stoerungsticket','rechnungen') PRIMARY KEY, + name VARCHAR(150) NOT NULL, + from_name VARCHAR(150) NULL, + from_email VARCHAR(254) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by CHAR(36) NULL REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +INSERT INTO mail_sender_profiles (category, name) VALUES + ('kundencenter', 'Kundencenter (Zugang, Passwort, Zugangsdaten)'), + ('stoerungsticket', 'Störungsticket (Support)'), + ('rechnungen', 'Rechnungen'); + +ALTER TABLE mail_templates ADD COLUMN category ENUM('kundencenter','stoerungsticket','rechnungen') NOT NULL DEFAULT 'kundencenter'; +UPDATE mail_templates SET category = 'kundencenter' WHERE `key` IN ('invite','password_reset','panel_credentials'); +UPDATE mail_templates SET category = 'stoerungsticket' WHERE `key` IN ('ticket_created','ticket_message'); +UPDATE mail_templates SET category = 'rechnungen' WHERE `key` IN ('invoice_issued'); diff --git a/packages/platform/src/mail.ts b/packages/platform/src/mail.ts index 6c0ab55..31721a2 100644 --- a/packages/platform/src/mail.ts +++ b/packages/platform/src/mail.ts @@ -3,6 +3,7 @@ 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 } +export type MailCategory = 'kundencenter' | 'stoerungsticket' | 'rechnungen'; /** Lädt die SMTP-Verbindung aus der Datenbank (Einstellungen > E-Mail). Kein Host/Absender gesetzt = nicht eingerichtet. */ export async function loadSmtpConfig(): Promise { @@ -10,6 +11,12 @@ export async function loadSmtpConfig(): Promise { 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 }; } +/** Absenderprofil je Bereich (Kundencenter/Störungsticket/Rechnungen); leere Felder fallen auf den globalen SMTP-Absender zurück. */ +async function fromFor(cfg: SmtpConfig, category?: MailCategory): Promise<{ name: string | null; email: string }> { + if (!category) return { name: cfg.fromName, email: cfg.fromEmail }; + const p = await one('SELECT from_name, from_email FROM mail_sender_profiles WHERE category = ?', [category]); + return { name: p?.from_name ?? cfg.fromName, email: p?.from_email ?? cfg.fromEmail }; +} async function smtpPassword(): Promise { 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; @@ -22,7 +29,7 @@ async function transportFor(cfg: SmtpConfig) { } /** 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'> { +export async function sendMail(to: string, template: string, subject: string, text: string, category?: MailCategory): 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']); @@ -30,7 +37,8 @@ export async function sendMail(to: string, template: string, subject: string, te } try { const transport = await transportFor(cfg); - await transport.sendMail({ from: cfg.fromName ? `"${cfg.fromName.replace(/"/g, "'")}" <${cfg.fromEmail}>` : cfg.fromEmail, to, subject, text }); + const from = await fromFor(cfg, category); + await transport.sendMail({ from: from.name ? `"${from.name.replace(/"/g, "'")}" <${from.email}>` : from.email, to, subject, text }); await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']); return 'sent'; } catch (e) { @@ -44,10 +52,10 @@ export function renderTemplateText(tpl: string, vars: Record): s 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). */ +/** Sendet anhand einer in der Datenbank hinterlegten Vorlage (Einstellungen > E-Mail > Vorlagen). Der Absender richtet sich nach dem Bereich der Vorlage (Kundencenter/Störungsticket/Rechnungen). */ export async function sendTemplateMail(to: string, key: string, vars: Record): 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)); + return sendMail(to, key, renderTemplateText(t.subject, vars), renderTemplateText(t.body, vars), t.category as MailCategory); }