feat: separate Absenderprofile für Kundencenter/Störungsticket/Rechnungen

Bisher gab es nur einen globalen SMTP-Absender für alle Mailvorlagen.
Jetzt kann je Bereich ein eigener Absendername/-adresse hinterlegt
werden (z. B. support@… für Tickets, rechnung@… für Rechnungen);
leer gelassene Felder fallen weiter auf den globalen SMTP-Absender
zurück. Jede Vorlage ist fest einem Bereich zugeordnet (Migration
023: mail_templates.category, neue Tabelle mail_sender_profiles mit
den drei Zeilen kundencenter/stoerungsticket/rechnungen).

Außerdem: der Testmail-Versand (SMTP-Verbindung wie auch je Vorlage)
zeigt jetzt den konkreten Fehler aus dem Mail-Protokoll an (z. B.
"535 5.7.8 authentication failed") statt nur "Fehlgeschlagen".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 13:16:42 +02:00
parent bbece6ca5f
commit 7d2b2947f4
4 changed files with 88 additions and 15 deletions

View file

@ -10,10 +10,12 @@ import { renderTemplateText, sendMail } from '../../core/mail.js';
import type { KcModule } from '../../core/module.js'; import type { KcModule } from '../../core/module.js';
const email = z.string().email().max(254).transform((s) => s.toLowerCase()); const email = z.string().email().max(254).transform((s) => s.toLowerCase());
const CATEGORIES = ['kundencenter', 'stoerungsticket', 'rechnungen'] as const;
const settingsView = (s: any) => ({ const settingsView = (s: any) => ({
host: s.host, port: s.port, secureMode: s.secure_mode, username: s.username, hasPassword: !!s.secrets_enc, 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, 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. */ /** Beispielwerte für die Testvorschau, je Platzhaltername – fällt auf den Platzhalternamen selbst zurück. */
const SAMPLE: Record<string, string> = { const SAMPLE: Record<string, string> = {
name: 'Max Mustermann', inviteLink: 'https://kundencenter.example/einladung?token=…', validDays: '7', 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 a = requirePermission(req, 'email.write');
const { to } = z.object({ to: email }).parse(req.body); 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'); 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 } }); 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;
return { status }; 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 ---- // ---- 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', 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) => { app.get('/admin/email/templates/:key', async (req) => {
requirePermission(req, 'email.read'); const { key } = z.object({ key: z.string().max(60) }).parse(req.params); 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 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 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 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)); const status = await sendMail(to, `${key}:test`, renderTemplateText(t.subject, sample), renderTemplateText(t.body, sample), t.category);
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 } }); 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;
return { status }; 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) -------------------------- // ---- Protokoll der letzten Sendeversuche (kein Inhalt, nur Metadaten) --------------------------

View file

@ -5,9 +5,12 @@ import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui'; 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 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 } interface LogRow { id: number; to: string; template: string; status: string; error: string | null; createdAt: string }
const SECURE_LABEL: Record<string, string> = { none: 'Keine Verschlüsselung', starttls: 'STARTTLS (empfohlen, meist Port 587)', tls: 'TLS/SSL direkt (meist Port 465)' }; const SECURE_LABEL: Record<string, string> = { none: 'Keine Verschlüsselung', starttls: 'STARTTLS (empfohlen, meist Port 587)', tls: 'TLS/SSL direkt (meist Port 465)' };
const CATEGORY_LABEL: Record<Category, string> = { kundencenter: 'Kundencenter', stoerungsticket: 'Störungsticket', rechnungen: 'Rechnungen' };
const STATUS: Record<string, { cls: string; icon: string; label: string }> = { const STATUS: Record<string, { cls: string; icon: string; label: string }> = {
sent: { cls: 'ok', icon: '✓', label: 'Gesendet' }, failed: { cls: 'err', icon: '✕', label: 'Fehlgeschlagen' }, 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' }, 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() { async function sendTest() {
setTestBusy(true); setMsg(null); 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); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); }
} }
return (<div className="card"> return (<div className="card">
@ -57,6 +60,31 @@ function SmtpForm({ s, w, onSaved }: { s: Settings; w: boolean; onSaved: () => v
</div>); </div>);
} }
/** 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<HTMLFormElement>) {
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 (<form onSubmit={save} className="cols" style={{ alignItems: 'end', marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid var(--border)' }}>
<div><strong>{CATEGORY_LABEL[p.category]}</strong><p className="small muted" style={{ margin: '2px 0 0' }}>{p.name}</p>{msg && <p className={`small ${msg.k === 'ok' ? '' : 'muted'}`} style={{ color: msg.k === 'ok' ? 'var(--ok)' : 'var(--err)', margin: '4px 0 0' }}>{msg.t}</p>}</div>
<Field id={`sn-${p.category}`} label="Absendername"><input id={`sn-${p.category}`} name="fromName" defaultValue={p.fromName ?? ''} placeholder={fallbackName} disabled={!w} /></Field>
<Field id={`se-${p.category}`} label="Absenderadresse"><input id={`se-${p.category}`} name="fromEmail" type="email" defaultValue={p.fromEmail ?? ''} placeholder={fallbackEmail} disabled={!w} /></Field>
{w && <button className="btn small" type="submit" disabled={busy} style={{ marginBottom: 16 }}>{busy ? '…' : 'Speichern'}</button>}
</form>);
}
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 (<div className="card">
<h2>Absenderprofile</h2>
<p className="muted small">Für Kundencenter-Zugang, Störungstickets und Rechnungen kann jeweils ein eigener Absender hinterlegt werden (z. B. <code className="mono">support@…</code> für Tickets, <code className="mono">rechnung@…</code> für Rechnungen). Leer gelassene Felder verwenden den globalen SMTP-Absender oben.</p>
{senders.map((p) => <SenderRow key={p.category} p={p} w={w} fallbackName={fallbackName} fallbackEmail={fallbackEmail} onSaved={onSaved} />)}
</div>);
}
/** Eine Vorlage: feste Definition (Auslöser, verfügbare Daten) + editierbarer Betreff/Text. */ /** Eine Vorlage: feste Definition (Auslöser, verfügbare Daten) + editierbarer Betreff/Text. */
function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: () => void }) { 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); 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() { async function sendTest() {
setTestBusy(true); setMsg(null); 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); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); }
} }
return (<div className="card"> return (<div className="card">
<div className="row between"> <div className="row between">
<div><h3 style={{ margin: 0 }}>{t.name}</h3><p className="small muted" style={{ margin: '4px 0 0' }}>{t.trigger}</p></div> <div><h3 style={{ margin: 0 }}>{t.name}</h3><p className="small muted" style={{ margin: '4px 0 0' }}>{t.trigger}</p><span className="badge info" style={{ marginTop: 6 }}>{CATEGORY_LABEL[t.category]}</span></div>
<div className="row">{!t.enabled && <span className="badge lock"><span aria-hidden="true">⊘</span>Deaktiviert</span>}<button className="btn small" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? 'Schließen' : 'Bearbeiten'}</button></div> <div className="row">{!t.enabled && <span className="badge lock"><span aria-hidden="true">⊘</span>Deaktiviert</span>}<button className="btn small" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? 'Schließen' : 'Bearbeiten'}</button></div>
</div> </div>
{open && <div style={{ marginTop: 16 }}> {open && <div style={{ marginTop: 16 }}>
@ -96,9 +124,10 @@ function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: ()
export default function EmailSettings() { export default function EmailSettings() {
const { can } = useSession(); const w = can('email.write'); const { can } = useSession(); const w = can('email.write');
const [s, setS] = useState<Settings | null>(null); const [tpls, setTpls] = useState<Template[] | null>(null); const [log, setLog] = useState<LogRow[] | null>(null); const [err, setErr] = useState(''); const [s, setS] = useState<Settings | null>(null); const [senders, setSenders] = useState<Sender[] | null>(null); const [tpls, setTpls] = useState<Template[] | null>(null); const [log, setLog] = useState<LogRow[] | null>(null); const [err, setErr] = useState('');
const load = useCallback(() => { const load = useCallback(() => {
api<Settings>('GET', '/admin/email/settings').then(setS).catch((e) => setErr(errMsg(e))); api<Settings>('GET', '/admin/email/settings').then(setS).catch((e) => setErr(errMsg(e)));
api<Sender[]>('GET', '/admin/email/senders').then(setSenders).catch((e) => setErr(errMsg(e)));
api<Template[]>('GET', '/admin/email/templates').then(setTpls).catch((e) => setErr(errMsg(e))); api<Template[]>('GET', '/admin/email/templates').then(setTpls).catch((e) => setErr(errMsg(e)));
api<LogRow[]>('GET', '/admin/email/log').then(setLog).catch(() => undefined); api<LogRow[]>('GET', '/admin/email/log').then(setLog).catch(() => undefined);
}, []); }, []);
@ -108,6 +137,7 @@ export default function EmailSettings() {
{err && <Alert kind="err">{err}</Alert>} {err && <Alert kind="err">{err}</Alert>}
{!w && <Alert kind="info">Nur Superadministratoren können die SMTP-Verbindung und Mailvorlagen ändern. Sie können hier den aktuellen Stand und das Protokoll einsehen.</Alert>} {!w && <Alert kind="info">Nur Superadministratoren können die SMTP-Verbindung und Mailvorlagen ändern. Sie können hier den aktuellen Stand und das Protokoll einsehen.</Alert>}
{s ? <SmtpForm s={s} w={w} onSaved={load} /> : <p className="muted" role="status">Wird geladen …</p>} {s ? <SmtpForm s={s} w={w} onSaved={load} /> : <p className="muted" role="status">Wird geladen …</p>}
{s && senders && <SenderProfiles senders={senders} s={s} w={w} onSaved={load} />}
<h2 style={{ marginTop: 32 }}>Mailvorlagen</h2> <h2 style={{ marginTop: 32 }}>Mailvorlagen</h2>
<p className="muted small" style={{ marginTop: -8 }}>Jede Vorlage wird bei einem festen Ereignis im Kundencenter verwendet. Betreff und Text können angepasst werden; Platzhalter wie <code className="mono">{'{{name}}'}</code> werden beim Versand durch die tatsächlichen Daten ersetzt. Welche Platzhalter je Vorlage zur Verfügung stehen, steht direkt unter „Bearbeiten“.</p> <p className="muted small" style={{ marginTop: -8 }}>Jede Vorlage wird bei einem festen Ereignis im Kundencenter verwendet. Betreff und Text können angepasst werden; Platzhalter wie <code className="mono">{'{{name}}'}</code> werden beim Versand durch die tatsächlichen Daten ersetzt. Welche Platzhalter je Vorlage zur Verfügung stehen, steht direkt unter „Bearbeiten“.</p>
{tpls === null ? <p className="muted" role="status">Wird geladen …</p> : tpls.map((t) => <TemplateCard key={t.key} t={t} w={w} onSaved={load} />)} {tpls === null ? <p className="muted" role="status">Wird geladen …</p> : tpls.map((t) => <TemplateCard key={t.key} t={t} w={w} onSaved={load} />)}

View file

@ -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');

View file

@ -3,6 +3,7 @@ import { one, run } from './db.js';
import { decrypt } from './crypto.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 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. */ /** Lädt die SMTP-Verbindung aus der Datenbank (Einstellungen > E-Mail). Kein Host/Absender gesetzt = nicht eingerichtet. */
export async function loadSmtpConfig(): Promise<SmtpConfig | null> { export async function loadSmtpConfig(): Promise<SmtpConfig | null> {
@ -10,6 +11,12 @@ export async function loadSmtpConfig(): Promise<SmtpConfig | null> {
if (!s?.host || !s?.from_email) return null; 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 }; 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<string | null> { async function smtpPassword(): Promise<string | null> {
const s = await one('SELECT secrets_enc FROM smtp_settings WHERE id = 1'); 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; 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). */ /** 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(); const cfg = await loadSmtpConfig();
if (!cfg) { if (!cfg) {
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']); 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 { try {
const transport = await transportFor(cfg); 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']); await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']);
return 'sent'; return 'sent';
} catch (e) { } catch (e) {
@ -44,10 +52,10 @@ export function renderTemplateText(tpl: string, vars: Record<string, string>): s
return tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in vars ? vars[k]! : m)); 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<string, string>): Promise<'sent' | 'failed' | 'not_configured' | 'disabled' | 'no_template'> { 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]); const t = await one('SELECT * FROM mail_templates WHERE `key` = ?', [key]);
if (!t) return 'no_template'; if (!t) return 'no_template';
if (!t.enabled) return 'disabled'; 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);
} }