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';
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
const CATEGORIES = ['kundencenter', 'stoerungsticket', 'rechnungen'] as const;
const settingsView = (s: any) => ({
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,
});
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. */
const SAMPLE: Record<string, string> = {
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 { 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');
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 } });
return { 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;
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 ----
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/:key', async (req) => {
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 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 status = await sendMail(to, `${key}:test`, renderTemplateText(t.subject, sample), renderTemplateText(t.body, sample));
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 } });
return { status };
const status = await sendMail(to, `${key}:test`, renderTemplateText(t.subject, sample), renderTemplateText(t.body, sample), t.category);
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;
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) --------------------------