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:
parent
bbece6ca5f
commit
7d2b2947f4
4 changed files with 88 additions and 15 deletions
|
|
@ -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} />)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue