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:
parent
c0289317bf
commit
8616e51adc
6 changed files with 59 additions and 10 deletions
|
|
@ -10,6 +10,7 @@ import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
|||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { can, canInOrg } from '../../core/policy.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
import { config } from '../../core/config.js';
|
||||
import { renderInvoicePdf, type CompanySettings, type InvoiceForPdf } from './pdf.js';
|
||||
|
||||
/** Kaufmännisches Runden (halb auf), wie in @kc/platform/pricing – hier lokal, weil Rechnungspositionen
|
||||
|
|
@ -223,7 +224,7 @@ export const invoicesModule: KcModule = {
|
|||
});
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.issue', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number } });
|
||||
await notify('invoice.issued', { number, gross: v.total_gross_cents }, `invoice.issued:${id}`, req.correlationId);
|
||||
if (org?.billing_email) await mailTo(org.billing_email, 'invoice_issued', { name: org.contact_name || org.company || org.name, number, amount: deMoney(v.total_gross_cents), dueDate: deDate(due) }, `mail:invoice.issued:${id}`, req.correlationId);
|
||||
if (org?.billing_email) await mailTo(org.billing_email, 'invoice_issued', { name: org.contact_name || org.company || org.name, number, amount: deMoney(v.total_gross_cents), dueDate: deDate(due), invoiceLink: `${config.baseUrl}/rechnungen/${id}` }, `mail:invoice.issued:${id}`, req.correlationId);
|
||||
return { ok: true, number };
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { encrypt, decrypt } from '../../core/crypto.js';
|
|||
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest, notFound } from '../../core/errors.js';
|
||||
import { rl } from '../../core/config.js';
|
||||
import { renderTemplateText, sendMail } from '../../core/mail.js';
|
||||
import { renderTemplateText, renderTemplateHtml, sendMail } from '../../core/mail.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
|
||||
|
|
@ -22,7 +22,7 @@ const SAMPLE: Record<string, string> = {
|
|||
name: 'Max Mustermann', inviteLink: 'https://kundencenter.example/einladung?token=…', validDays: '7',
|
||||
resetLink: 'https://kundencenter.example/passwort-reset?token=…', resourceName: 'kd100356644 · webhosting.example',
|
||||
username: 'kd100356644', password: 'BeispielPasswort123', number: 'T-42', subject: 'Beispiel-Betreff',
|
||||
amount: '19,90 €', dueDate: '15.10.2026',
|
||||
amount: '19,90 €', dueDate: '15.10.2026', ticketLink: 'https://kundencenter.example/tickets/…', invoiceLink: 'https://kundencenter.example/rechnungen/…',
|
||||
};
|
||||
|
||||
export const mailModule: KcModule = {
|
||||
|
|
@ -98,7 +98,7 @@ 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), t.category);
|
||||
const status = await sendMail(to, `${key}:test`, renderTemplateText(t.subject, sample), renderTemplateText(t.body, sample), { category: t.category, html: renderTemplateHtml(t.body, sample) });
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
|||
import { AppError, badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { can, canInOrg } from '../../core/policy.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
import { config } from '../../core/config.js';
|
||||
import { MAX_FILES_PER_MESSAGE, MAX_FILE_BYTES, pathFor, storeUpload } from './files.js';
|
||||
|
||||
const ticketLink = (id: string) => `${config.baseUrl}/tickets/${id}`;
|
||||
|
||||
const OPEN = new Set(['open', 'pending_customer', 'pending_staff']);
|
||||
async function nextNumber(c: PoolConnection): Promise<string> {
|
||||
await run("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'ticket'", [], c);
|
||||
|
|
@ -69,7 +72,7 @@ export const ticketsModule: KcModule = {
|
|||
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'ticket.create', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number, subject: b.subject } });
|
||||
if (!staff) {
|
||||
await notify('ticket.created', { number, subject: b.subject.slice(0, 100) }, `ticket.created:${id}`, req.correlationId);
|
||||
await mailTo(a.user.email, 'ticket_created', { name: a.user.name, number, subject: b.subject }, `mail:ticket.created:${id}`, req.correlationId);
|
||||
await mailTo(a.user.email, 'ticket_created', { name: a.user.name, number, subject: b.subject, ticketLink: ticketLink(id) }, `mail:ticket.created:${id}`, req.correlationId);
|
||||
}
|
||||
return { id, number, messageId };
|
||||
});
|
||||
|
|
@ -118,7 +121,7 @@ export const ticketsModule: KcModule = {
|
|||
let emailed = false;
|
||||
if (!b.internalNote && staff && b.notifyEmail) {
|
||||
const creator = await one('SELECT email, name FROM users WHERE id = ?', [t.created_by]);
|
||||
if (creator) { await mailTo(creator.email, 'ticket_message', { name: creator.name, number: t.number, subject: t.subject }, `mail:ticket.message:${id}:${Date.now()}`, req.correlationId); emailed = true; }
|
||||
if (creator) { await mailTo(creator.email, 'ticket_message', { name: creator.name, number: t.number, subject: t.subject, ticketLink: ticketLink(id) }, `mail:ticket.message:${id}:${Date.now()}`, req.correlationId); emailed = true; }
|
||||
}
|
||||
return { ok: true, messageId, emailed };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: ()
|
|||
{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>
|
||||
{t.variables.some((v) => /Link$/.test(v)) && <p className="small muted">Die Mail wird zusätzlich als hübsch formatiertes HTML verschickt; ein Platzhalter, der auf „Link“ endet, wird darin automatisch zu einer Schaltfläche (im Klartext bleibt er als normale URL sichtbar).</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>
|
||||
|
|
|
|||
17
migrations/025_mail_link_buttons.sql
Normal file
17
migrations/025_mail_link_buttons.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Ticket- und Rechnungsmails bekommen einen direkten Link zum jeweiligen Datensatz (wird beim Versand als
|
||||
-- Schaltfläche dargestellt, siehe renderTemplateHtml in @kc/platform/mail). Nur Vorlagen mit noch unverändertem
|
||||
-- Text werden angepasst, damit bereits von Personal angepasste Texte nicht überschrieben werden.
|
||||
UPDATE mail_templates SET
|
||||
variables_json = JSON_ARRAY('name', 'number', 'subject', 'ticketLink'),
|
||||
body = 'Hallo {{name}},\n\nIhr Ticket „{{subject}}“ ({{number}}) wurde bei uns erstellt. Wir melden uns, sobald es eine Antwort gibt.\n\n{{ticketLink}}\n'
|
||||
WHERE `key` = 'ticket_created' AND body = 'Hallo {{name}},\n\nIhr Ticket „{{subject}}“ ({{number}}) wurde bei uns erstellt. Wir melden uns, sobald es eine Antwort gibt.\n';
|
||||
|
||||
UPDATE mail_templates SET
|
||||
variables_json = JSON_ARRAY('name', 'number', 'subject', 'ticketLink'),
|
||||
body = 'Hallo {{name}},\n\nes gibt eine neue Antwort zu Ihrem Ticket „{{subject}}“ ({{number}}).\n\n{{ticketLink}}\n'
|
||||
WHERE `key` = 'ticket_message' AND body = 'Hallo {{name}},\n\nes gibt eine neue Antwort zu Ihrem Ticket „{{subject}}“ ({{number}}). Bitte im Kundencenter ansehen und bei Bedarf antworten.\n';
|
||||
|
||||
UPDATE mail_templates SET
|
||||
variables_json = JSON_ARRAY('name', 'number', 'amount', 'dueDate', 'invoiceLink'),
|
||||
body = 'Hallo {{name}},\n\nIhre Rechnung {{number}} über {{amount}} wurde erstellt, fällig am {{dueDate}}.\n\n{{invoiceLink}}\n'
|
||||
WHERE `key` = 'invoice_issued' AND body = 'Hallo {{name}},\n\nIhre Rechnung {{number}} über {{amount}} wurde erstellt, fällig am {{dueDate}}. Sie können sie im Kundencenter einsehen und als PDF herunterladen.\n';
|
||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
/** 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue