feat(mail): HTML-Mails mit Link zum Ticket/zur Rechnung als Schaltfläche

Mails wurden bisher nur als Klartext verschickt. sendMail/sendTemplateMail
verschicken jetzt zusätzlich eine einfache, mandantenfarbige HTML-Version
(renderTemplateHtml in @kc/platform/mail): ein Platzhalter, dessen Name auf
"Link" endet, wird darin zu einer Schaltfläche statt einer nackten URL; der
Klartext-Teil behält den Link weiterhin sichtbar (Fallback für Mailprogramme
ohne HTML-Ansicht).

ticket_created/ticket_message bekommen dafür eine neue Variable
{{ticketLink}}, invoice_issued {{invoiceLink}} (Migration 025 – nur
Vorlagen mit noch unverändertem Text werden angepasst, damit von Personal
bereits bearbeitete Texte nicht überschrieben werden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 23:00:31 +02:00
parent c0289317bf
commit 8616e51adc
6 changed files with 59 additions and 10 deletions

View file

@ -1,6 +1,7 @@
import nodemailer from 'nodemailer';
import { one, run } from './db.js';
import { decrypt } from './crypto.js';
import { config } from './config.js';
export interface SmtpConfig { host: string; port: number; secureMode: 'none' | 'starttls' | 'tls'; username: string | null; fromName: string | null; fromEmail: string }
export type MailCategory = 'kundencenter' | 'stoerungsticket' | 'rechnungen';
@ -29,7 +30,7 @@ async function transportFor(cfg: SmtpConfig) {
}
/** Versand mit Protokoll. Ohne SMTP-Konfiguration wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */
export async function sendMail(to: string, template: string, subject: string, text: string, category?: MailCategory): Promise<'sent' | 'failed' | 'not_configured'> {
export async function sendMail(to: string, template: string, subject: string, text: string, opts: { category?: MailCategory; html?: string } = {}): Promise<'sent' | 'failed' | 'not_configured'> {
const cfg = await loadSmtpConfig();
if (!cfg) {
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']);
@ -37,8 +38,8 @@ export async function sendMail(to: string, template: string, subject: string, te
}
try {
const transport = await transportFor(cfg);
const from = await fromFor(cfg, category);
await transport.sendMail({ from: from.name ? `"${from.name.replace(/"/g, "'")}" <${from.email}>` : from.email, to, subject, text });
const from = await fromFor(cfg, opts.category);
await transport.sendMail({ from: from.name ? `"${from.name.replace(/"/g, "'")}" <${from.email}>` : from.email, to, subject, text, html: opts.html });
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']);
return 'sent';
} catch (e) {
@ -52,10 +53,36 @@ export function renderTemplateText(tpl: string, vars: Record<string, string>): s
return tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in vars ? vars[k]! : m));
}
const escapeHtml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** Beschriftung des Schaltflächen-Buttons, je nach Variablenname (endet auf "Link"). */
const LINK_LABEL: Record<string, string> = { inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen' };
/**
* Baut aus dem (Klartext-)Vorlagentext eine einfache, mandantenfarbige HTML-Mail. Eine Variable, deren Name auf
* "Link" endet, wird nicht als nackte URL im Text belassen, sondern als Schaltfläche ans Ende gesetzt (der
* Klartext-Teil der Mail behält den Link weiterhin als sichtbare URL, für Mailprogramme ohne HTML-Ansicht).
*/
export function renderTemplateHtml(tpl: string, vars: Record<string, string>): string {
const linkKey = Object.keys(vars).find((k) => /Link$/.test(k) && vars[k]);
const bodyVars = linkKey ? { ...vars, [linkKey]: '' } : vars;
const rendered = tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in bodyVars ? escapeHtml(bodyVars[k]!) : m));
const paragraphs = rendered.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p style="margin:0 0 16px;">${p.replace(/\n/g, '<br>')}</p>`).join('');
const button = linkKey ? `<p style="margin:24px 0 0;"><a href="${escapeHtml(vars[linkKey]!)}" style="display:inline-block;background:#3b6fd8;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;">${LINK_LABEL[linkKey] ?? 'Öffnen'}</a></p>` : '';
return `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#f4f6fa;font-family:-apple-system,Segoe UI,Roboto,sans-serif;">
<table role="presentation" width="100%" style="max-width:520px;margin:0 auto;background:#ffffff;border:1px solid #e3e8f0;border-radius:12px;">
<tr><td style="padding:28px 32px;">
<div style="font-weight:700;font-size:18px;color:#173568;margin-bottom:20px;">Kundencenter</div>
<div style="color:#172033;font-size:15px;line-height:1.55;">${paragraphs}${button}</div>
</td></tr></table>
</body></html>`;
}
/** Sendet anhand einer in der Datenbank hinterlegten Vorlage (Einstellungen > E-Mail > Vorlagen). Der Absender richtet sich nach dem Bereich der Vorlage (Kundencenter/Störungsticket/Rechnungen). */
export async function sendTemplateMail(to: string, key: string, vars: Record<string, string>): Promise<'sent' | 'failed' | 'not_configured' | 'disabled' | 'no_template'> {
const t = await one('SELECT * FROM mail_templates WHERE `key` = ?', [key]);
if (!t) return 'no_template';
if (!t.enabled) return 'disabled';
return sendMail(to, key, renderTemplateText(t.subject, vars), renderTemplateText(t.body, vars), t.category as MailCategory);
return sendMail(to, key, renderTemplateText(t.subject, vars), renderTemplateText(t.body, vars), { category: t.category as MailCategory, html: renderTemplateHtml(t.body, vars) });
}
export const baseUrl = config.baseUrl;