From 8616e51adc6bcca1a8f4becf45fc85b3abf7ffb7 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Mon, 28 Sep 2026 23:00:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(mail):=20HTML-Mails=20mit=20Link=20zum=20T?= =?UTF-8?q?icket/zur=20Rechnung=20als=20Schaltfl=C3=A4che?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/invoices/index.ts | 3 +- apps/api/src/modules/mail/index.ts | 6 ++-- apps/api/src/modules/tickets/index.ts | 7 ++-- .../app/(app)/einstellungen/email/page.tsx | 1 + migrations/025_mail_link_buttons.sql | 17 +++++++++ packages/platform/src/mail.ts | 35 ++++++++++++++++--- 6 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 migrations/025_mail_link_buttons.sql diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 01db998..3195c26 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -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 }; }); diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index d44a4cf..0dacce1 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -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 = { 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 }; diff --git a/apps/api/src/modules/tickets/index.ts b/apps/api/src/modules/tickets/index.ts index de69672..76c6c85 100644 --- a/apps/api/src/modules/tickets/index.ts +++ b/apps/api/src/modules/tickets/index.ts @@ -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 { 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 }; }); diff --git a/apps/web/src/app/(app)/einstellungen/email/page.tsx b/apps/web/src/app/(app)/einstellungen/email/page.tsx index 908a620..ac969a3 100644 --- a/apps/web/src/app/(app)/einstellungen/email/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/email/page.tsx @@ -152,6 +152,7 @@ function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: () {open &&
{msg && {msg.t}}

Verfügbare Daten für diese Vorlage: {t.variables.map((v) => {`{{${v}}}`})}

+ {t.variables.some((v) => /Link$/.test(v)) &&

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).

}