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
120
apps/web/src/app/(app)/einstellungen/email/page.tsx
Normal file
120
apps/web/src/app/(app)/einstellungen/email/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
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 }
|
||||
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 STATUS: Record<string, { cls: string; icon: string; label: string }> = {
|
||||
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' },
|
||||
};
|
||||
const StatusBadge = ({ v }: { v: string }) => { const m = STATUS[v] ?? { cls: 'info', icon: '○', label: v }; return <span className={`badge ${m.cls}`}><span aria-hidden="true">{m.icon}</span>{m.label}</span>; };
|
||||
|
||||
/** SMTP-Verbindung: Formular + Testmail. Leer gelassenes Passwortfeld lässt ein bereits gespeichertes Passwort unverändert. */
|
||||
function SmtpForm({ s, w, onSaved }: { s: Settings; w: boolean; onSaved: () => void }) {
|
||||
const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
|
||||
const [testTo, setTestTo] = useState(''); const [testBusy, setTestBusy] = useState(false);
|
||||
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);
|
||||
const pw = String(f.get('password') ?? '');
|
||||
try {
|
||||
await api('PUT', '/admin/email/settings', { host: v('host'), port: Number(f.get('port')) || 587, secureMode: f.get('secureMode'), username: v('username'), fromName: v('fromName'), fromEmail: v('fromEmail'), ...(pw ? { password: pw } : {}) });
|
||||
setMsg({ k: 'ok', t: 'Gespeichert.' }); (e.currentTarget.elements.namedItem('password') as HTMLInputElement).value = ''; onSaved();
|
||||
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
|
||||
}
|
||||
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}` }); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); }
|
||||
}
|
||||
return (<div className="card">
|
||||
<h2>SMTP-Verbindung</h2>
|
||||
{!s.configured && <Alert kind="warn">Noch nicht eingerichtet. Solange Host und Absenderadresse fehlen, werden E-Mails nicht versendet, aber im Protokoll unten als „nicht eingerichtet“ vermerkt.</Alert>}
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
<form onSubmit={save}>
|
||||
<div className="cols">
|
||||
<Field id="host" label="SMTP-Server (Host)"><input id="host" name="host" defaultValue={s.host ?? ''} placeholder="smtp.example.de" disabled={!w} /></Field>
|
||||
<Field id="port" label="Port"><input id="port" name="port" type="number" min={1} max={65535} defaultValue={s.port} disabled={!w} /></Field>
|
||||
<Field id="secureMode" label="Verschlüsselung"><select id="secureMode" name="secureMode" defaultValue={s.secureMode} disabled={!w}>{Object.entries(SECURE_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)}</select></Field>
|
||||
<Field id="username" label="Benutzername (leer = keine Anmeldung)"><input id="username" name="username" defaultValue={s.username ?? ''} disabled={!w} /></Field>
|
||||
<Field id="password" label="Passwort" hint={s.hasPassword ? 'Ein Passwort ist gespeichert. Leer lassen, um es unverändert zu lassen.' : 'Noch kein Passwort gespeichert.'}><input id="password" name="password" type="password" autoComplete="new-password" disabled={!w} /></Field>
|
||||
<Field id="fromName" label="Absendername"><input id="fromName" name="fromName" defaultValue={s.fromName ?? ''} placeholder="Kundencenter" disabled={!w} /></Field>
|
||||
<Field id="fromEmail" label="Absenderadresse"><input id="fromEmail" name="fromEmail" type="email" defaultValue={s.fromEmail ?? ''} placeholder="kundencenter@example.de" disabled={!w} /></Field>
|
||||
</div>
|
||||
{w && <div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Speichern'}</button></div>}
|
||||
</form>
|
||||
{w && <div className="row" style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||||
<input aria-label="Test-Empfänger" type="email" placeholder="test@example.de" value={testTo} onChange={(e) => setTestTo(e.target.value)} style={{ maxWidth: 260 }} />
|
||||
<button className="btn" disabled={!testTo || testBusy} onClick={sendTest}>{testBusy ? 'Bitte warten …' : 'Testmail senden'}</button>
|
||||
</div>}
|
||||
<p className="small muted" style={{ marginTop: 8 }}>Zuletzt geändert: {fmt(s.updatedAt)}</p>
|
||||
</div>);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const [testTo, setTestTo] = useState(''); const [testBusy, setTestBusy] = useState(false);
|
||||
async function save(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); if (!w) return; setBusy(true); setMsg(null);
|
||||
const f = new FormData(e.currentTarget);
|
||||
try { await api('PUT', `/admin/email/templates/${t.key}`, { subject: f.get('subject'), body: f.get('body'), enabled: f.get('enabled') === 'on' }); setMsg({ k: 'ok', t: 'Gespeichert.' }); onSaved(); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
|
||||
}
|
||||
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}` }); }
|
||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); }
|
||||
}
|
||||
return (<div className="card">
|
||||
<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 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>
|
||||
{open && <div style={{ marginTop: 16 }}>
|
||||
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||
<p className="small">Verfügbare Daten für diese Vorlage: {t.variables.map((v) => <code key={v} className="mono" style={{ marginRight: 8 }}>{`{{${v}}}`}</code>)}</p>
|
||||
<form onSubmit={save}>
|
||||
<Field id={`subj-${t.key}`} label="Betreff"><input id={`subj-${t.key}`} name="subject" defaultValue={t.subject} disabled={!w} required /></Field>
|
||||
<Field id={`body-${t.key}`} label="Text"><textarea id={`body-${t.key}`} name="body" defaultValue={t.body} disabled={!w} required rows={7} style={{ width: '100%', font: 'inherit', padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', background: 'var(--surface)', color: 'var(--text)' }} /></Field>
|
||||
<label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="enabled" defaultChecked={t.enabled} disabled={!w} style={{ width: 20, minHeight: 20 }} /> Vorlage aktiv (sonst wird bei diesem Ereignis keine E-Mail versendet)</label>
|
||||
{w && <div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Speichern'}</button></div>}
|
||||
</form>
|
||||
{w && <div className="row" style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<input aria-label="Test-Empfänger" type="email" placeholder="test@example.de" value={testTo} onChange={(e) => setTestTo(e.target.value)} style={{ maxWidth: 260 }} />
|
||||
<button className="btn small" disabled={!testTo || testBusy} onClick={sendTest}>{testBusy ? 'Bitte warten …' : 'Testmail mit Beispieldaten senden'}</button>
|
||||
</div>}
|
||||
</div>}
|
||||
</div>);
|
||||
}
|
||||
|
||||
export default function EmailSettings() {
|
||||
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 load = useCallback(() => {
|
||||
api<Settings>('GET', '/admin/email/settings').then(setS).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);
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
return (<>
|
||||
<h2>E-Mail</h2>
|
||||
{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>}
|
||||
{s ? <SmtpForm s={s} w={w} onSaved={load} /> : <p className="muted" role="status">Wird geladen …</p>}
|
||||
<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>
|
||||
{tpls === null ? <p className="muted" role="status">Wird geladen …</p> : tpls.map((t) => <TemplateCard key={t.key} t={t} w={w} onSaved={load} />)}
|
||||
<h2 style={{ marginTop: 32 }}>Protokoll</h2>
|
||||
<p className="muted small" style={{ marginTop: -8 }}>Die letzten 100 Sendeversuche. Der Inhalt der E-Mails wird nicht protokolliert.</p>
|
||||
<div className="card"><div className="tablewrap"><table><thead><tr><th>Zeitpunkt</th><th>Empfänger</th><th>Vorlage</th><th>Status</th></tr></thead><tbody>
|
||||
{log === null ? <tr><td colSpan={4} className="muted">Wird geladen …</td></tr> : log.length === 0 ? <tr><td colSpan={4} className="muted">Noch keine Einträge.</td></tr> :
|
||||
log.map((l) => <tr key={l.id}><td className="small">{fmt(l.createdAt)}</td><td className="small">{l.to}</td><td className="small">{l.template}</td><td><StatusBadge v={l.status} />{l.error && <div className="small muted">{l.error}</div>}</td></tr>)}
|
||||
</tbody></table></div></div>
|
||||
</>);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue