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>
2026-09-28 12:53:29 +02:00
'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 ) ;
2026-09-28 13:05:11 +02:00
const form = e . currentTarget ;
const f = new FormData ( form ) ; const v = ( k : string ) = > ( String ( f . get ( k ) ? ? '' ) . trim ( ) || null ) ;
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>
2026-09-28 12:53:29 +02:00
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 } : { } ) } ) ;
2026-09-28 13:05:11 +02:00
setMsg ( { k : 'ok' , t : 'Gespeichert.' } ) ; ( form . elements . namedItem ( 'password' ) as HTMLInputElement ) . value = '' ; onSaved ( ) ;
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>
2026-09-28 12:53:29 +02:00
} 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 >
< / > ) ;
}