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

@ -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<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 }> = {
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 (<div className="card">
@ -57,6 +60,31 @@ function SmtpForm({ s, w, onSaved }: { s: Settings; w: boolean; onSaved: () => v
</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. */
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 (<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><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>
{open && <div style={{ marginTop: 16 }}>
@ -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<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(() => {
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<LogRow[]>('GET', '/admin/email/log').then(setLog).catch(() => undefined);
}, []);
@ -108,6 +137,7 @@ export default function EmailSettings() {
{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>}
{s && senders && <SenderProfiles senders={senders} s={s} w={w} onSaved={load} />}
<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} />)}