Add contract renewal reminder emails (keep/cancel decision links)

Sends a one-time email ~30 days before any contract's term_end
(whether renewal='auto' or 'none'), with "Behalten" and "Kündigen"
action links. "Behalten" extends the contract by one renewal term;
"Kündigen" sets the same cancellation effective-date logic as the
customer-facing cancel flow and opens a staff ticket so the external
deregistration (e.g. with a domain registrar) actually gets done.

Uses a dedicated single-use token table (contract_renewal_tokens)
since contracts, unlike user_tokens, are not scoped to a single user.
The public confirmation page lives at /vertrag-entscheidung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-10-01 11:51:19 +02:00
parent 29c7273f4d
commit dad9528295
6 changed files with 233 additions and 8 deletions

View file

@ -5,7 +5,9 @@ import type { PoolConnection } from 'mysql2/promise';
import { calculatePrice } from '@kc/platform/pricing'; import { calculatePrice } from '@kc/platform/pricing';
import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine'; import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine';
import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms'; import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms';
import { peekRenewalToken, applyRenewalDecision, RenewalTokenError } from '@kc/platform/contractRenewal';
import { one, query, run, tx } from '../../core/db.js'; import { one, query, run, tx } from '../../core/db.js';
import { rl } from '../../core/config.js';
import { audit } from '../../core/audit.js'; import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js'; import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js'; import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
@ -217,5 +219,18 @@ export const ordersModule: KcModule = {
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) }); await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' }; return { status: 'ok' };
}); });
// ---- Vertrags-Erinnerung per Mail: "Behalten"/"Kündigen" ohne Login, über einen Einmal-Link -------------
app.get('/contracts/renewal-decision', async (req) => {
const { token } = z.object({ token: z.string().min(20).max(100) }).parse(req.query);
const info = await peekRenewalToken(token);
if (!info) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN');
return info;
});
app.post('/contracts/renewal-decision', { config: rl(10, '10 minutes') }, async (req) => {
const b = z.object({ token: z.string().min(20).max(100), action: z.enum(['keep', 'cancel']) }).parse(req.body);
try { return await applyRenewalDecision(b.token, b.action); }
catch (e: unknown) { if (e instanceof RenewalTokenError) throw badRequest(e.message, 'INVALID_TOKEN'); throw e; }
});
}, },
}; };

View file

@ -0,0 +1,49 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { Alert } from '@/components/ui';
interface Info { contractNumber: string; orgName: string; productName: string; termEnd: string }
function Decision() {
const sp = useSearchParams();
const token = sp.get('token') ?? '';
const preselect = sp.get('action') === 'cancel' ? 'cancel' : sp.get('action') === 'keep' ? 'keep' : null;
const [info, setInfo] = useState<Info | null>(null);
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const [done, setDone] = useState<'keep' | 'cancel' | null>(null);
useEffect(() => {
if (!token) { setErr('Der Link ist unvollständig.'); return; }
api<Info>('GET', `/contracts/renewal-decision?token=${encodeURIComponent(token)}`).then(setInfo).catch((e) => setErr(errMsg(e)));
}, [token]);
async function decide(action: 'keep' | 'cancel') {
setBusy(true); setErr('');
try { await api('POST', '/contracts/renewal-decision', { token, action }); setDone(action); }
catch (e) { setErr(errMsg(e)); } finally { setBusy(false); }
}
return (<main id="main" className="center"><div className="auth card" style={{ maxWidth: 480 }}>
<h1>Vertragsentscheidung</h1>
{err && <Alert kind="err">{err}</Alert>}
{done ? (
<Alert kind="ok">
{done === 'keep' ? 'Vielen Dank! Der Vertrag wird weitergeführt.' : 'Die Kündigung wurde erfasst. Unser Team kümmert sich um die Abmeldung und meldet sich bei Rückfragen.'}
</Alert>
) : info ? (<>
<p className="muted">
Vertrag <strong>{info.contractNumber}</strong> ({info.productName}) für <strong>{info.orgName}</strong> läuft am{' '}
<strong>{new Date(info.termEnd).toLocaleDateString('de-DE')}</strong> aus. Möchten Sie ihn behalten oder kündigen?
</p>
<div className="row" style={{ marginTop: 16 }}>
<button className="btn primary" disabled={busy} autoFocus={preselect === 'keep'} onClick={() => decide('keep')}>{busy ? '…' : 'Vertrag behalten'}</button>
<button className="btn danger" disabled={busy} autoFocus={preselect === 'cancel'} onClick={() => decide('cancel')}>{busy ? '…' : 'Vertrag kündigen'}</button>
</div>
<p className="muted small" style={{ marginTop: 16 }}>„Behalten" verlängert den Vertrag automatisch um eine weitere Laufzeit. „Kündigen" leitet die Abmeldung ein.</p>
</>) : !err && <p className="muted" role="status">Wird geladen …</p>}
</div></main>);
}
export default function Page() { return <Suspense><Decision /></Suspense>; }

View file

@ -53,6 +53,16 @@ const checkLicense = async () => {
}; };
setInterval(() => void checkLicense(), 12 * 3600_000); void checkLicense(); setInterval(() => void checkLicense(), 12 * 3600_000); void checkLicense();
// Vertrags-Erinnerung: ~30 Tage vor Laufzeitende Mail mit "Behalten"/"Kündigen"-Link (einmal je Vertrag,
// markiert über renewal_reminder_sent_at). Unabhängig von processContractLifecycle oben, die nur zum
// tatsächlichen Laufzeitende selbst verlängert/beendet.
import { sendRenewalReminders } from '@kc/platform/contractRenewal';
const checkRenewals = async () => {
try { const r = await sendRenewalReminders(); if (r.sent || r.skipped) log(`Vertrags-Erinnerungen: ${r.sent} gesendet, ${r.skipped} übersprungen`); }
catch (e) { log(`Vertrags-Erinnerung fehlgeschlagen: ${(e as Error).message}`); }
};
setInterval(() => void checkRenewals(), 24 * 3600_000); void checkRenewals();
// Audit-Aufbewahrung: Einträge jenseits der konfigurierten Frist löschen (Einstellungen > Firma/Audit; Default // Audit-Aufbewahrung: Einträge jenseits der konfigurierten Frist löschen (Einstellungen > Firma/Audit; Default
// spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch, // spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch,
// mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig. // mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig.

View file

@ -0,0 +1,27 @@
-- Erinnerung vor Vertragsende: ~30 Tage vorher eine Mail mit "Behalten"/"Kündigen"-Entscheidung, egal ob der
-- Vertrag automatisch verlängert (renewal='auto') oder ausläuft (renewal='none'). "Behalten" verlängert
-- bewusst um eine Laufzeit (auch bei renewal='none', ohne die Einstellung selbst zu ändern - nächstes Mal wird
-- wieder gefragt). "Kündigen" setzt dieselbe Kündigungslogik wie die eingeloggte Kunden-Kündigung UND legt ein
-- Ticket an, damit Personal die externe Abmeldung (z. B. beim Domain-Registrar) tatsächlich vornehmen kann.
ALTER TABLE contracts ADD COLUMN renewal_reminder_sent_at DATETIME(3) NULL;
-- Eigene Token-Tabelle statt user_tokens: eine Entscheidung bezieht sich auf einen VERTRAG, nicht auf den
-- einloggenden Nutzer selbst (ein Kunde kann mehrere Verträge haben) - user_tokens hat dafür keine Spalte.
-- Gleiche Hash-/Ablauf-/Einmal-Mechanik wie user_tokens.
CREATE TABLE contract_renewal_tokens (
id CHAR(36) PRIMARY KEY,
contract_id CHAR(36) NOT NULL REFERENCES contracts(id),
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at DATETIME(3) NOT NULL,
used_at DATETIME(3) NULL,
used_action ENUM('keep','cancel') NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX (contract_id)
);
INSERT INTO mail_templates (`key`, name, trigger_text, category, variables_json, subject, body) VALUES
('contract_renewal_reminder', 'Erinnerung vor Vertragsende', 'Automatisch, ca. 30 Tage vor Laufzeitende eines Vertrags (einmalig je Vertrag).',
'kundencenter',
JSON_ARRAY('name', 'contractNumber', 'productName', 'termEnd', 'keepLink', 'cancelLink'),
'Ihr Vertrag {{contractNumber}} läuft bald ab',
'Hallo {{name}},\n\nIhr Vertrag {{contractNumber}} ({{productName}}) läuft am {{termEnd}} aus. Über die Schaltflächen unten können Sie ihn behalten oder kündigen.\n\nBei „Behalten" verlängern wir den Vertrag automatisch um eine weitere Laufzeit. Bei „Kündigen" kümmern wir uns um die Abmeldung. Ohne Rückmeldung läuft der Vertrag nach unseren üblichen Bedingungen weiter bzw. aus.\n\n{{keepLink}}\n{{cancelLink}}\n\nBei Fragen antworten Sie gerne auf diese E-Mail.\n');

View file

@ -0,0 +1,111 @@
import { randomUUID } from 'node:crypto';
import { one, query, run, tx } from './db.js';
import { randomToken, sha256 } from './crypto.js';
import { sendTemplateMail } from './mail.js';
import { audit } from './audit.js';
import { addMonths, effectiveCancelDate } from './contractterms.js';
import { config } from './config.js';
/** Erinnerung vor Vertragsende ("Behalten"/"Kündigen"), unabhängig davon, ob der Vertrag automatisch
* verlängert (renewal='auto') oder sonst ausläuft (renewal='none') - siehe docs/Diskussion zu #35-Folgeticket.
* Nicht zu verwechseln mit processContractLifecycle() (@kc/connectors), die die eigentliche Verlängerung/
* Beendigung zum Laufzeitende durchführt; diese Erinnerung läuft zeitlich VOR dieser Entscheidung und gibt
* dem Kunden die Möglichkeit, aktiv "Kündigen" zu wählen (inkl. Ticket für die externe Abmeldung, z. B. beim
* Domain-Registrar) oder "Behalten" zu wählen (verlängert bewusst um eine Laufzeit, auch bei renewal='none'). */
const REMINDER_DAYS_BEFORE = 30;
const TOKEN_TTL_DAYS = 45; // deckt die Frist vor Laufzeitende plus Puffer danach ab
export interface ReminderRunResult { sent: number; skipped: number }
/** Findet fällige Verträge und sendet je eine Erinnerungsmail mit Entscheidungslinks; markiert
* renewal_reminder_sent_at, damit das nur einmal passiert (idempotent, täglich vom Worker aufgerufen). */
export async function sendRenewalReminders(now: Date = new Date()): Promise<ReminderRunResult> {
const rows = await query(
`SELECT c.id, c.number, c.org_id, c.term_end, c.price_snapshot_json, g.name AS org_name, b.billing_email, b.contact_name
FROM contracts c JOIN organizations g ON g.id = c.org_id LEFT JOIN billing_profiles b ON b.org_id = g.id
WHERE c.status IN ('active','suspended') AND c.term_end IS NOT NULL AND c.cancel_requested_at IS NULL
AND c.renewal_reminder_sent_at IS NULL AND c.term_end <= DATE_ADD(?, INTERVAL ? DAY)`,
[now, REMINDER_DAYS_BEFORE],
);
let sent = 0; let skipped = 0;
for (const c of rows) {
if (!c.billing_email) { skipped++; continue; }
const token = randomToken(32);
await run('INSERT INTO contract_renewal_tokens (id, contract_id, token_hash, expires_at) VALUES (?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? DAY))', [randomUUID(), c.id, sha256(token), TOKEN_TTL_DAYS]);
const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json;
const link = (action: 'keep' | 'cancel') => `${config.baseUrl}/vertrag-entscheidung?token=${encodeURIComponent(token)}&action=${action}`;
const status = await sendTemplateMail(c.billing_email, 'contract_renewal_reminder', {
name: c.contact_name || c.org_name, contractNumber: c.number, productName: snap?.name ?? 'Ihr Vertrag',
termEnd: new Date(c.term_end).toLocaleDateString('de-DE'), keepLink: link('keep'), cancelLink: link('cancel'),
});
await run('UPDATE contracts SET renewal_reminder_sent_at = ? WHERE id = ?', [now, c.id]);
if (status === 'sent') sent++; else skipped++;
}
return { sent, skipped };
}
export interface RenewalDecisionInfo { contractId: string; contractNumber: string; orgName: string; productName: string; termEnd: string }
/** Nur lesen (für die Bestätigungsseite), verbraucht den Token NICHT - das macht erst applyRenewalDecision(). */
export async function peekRenewalToken(token: string): Promise<RenewalDecisionInfo | null> {
const t = await one('SELECT contract_id FROM contract_renewal_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3)', [sha256(token)]);
if (!t) return null;
const c = await one('SELECT c.id, c.number, c.term_end, c.price_snapshot_json, g.name AS org_name FROM contracts c JOIN organizations g ON g.id = c.org_id WHERE c.id = ?', [t.contract_id]);
if (!c) return null;
const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json;
return { contractId: c.id, contractNumber: c.number, orgName: c.org_name, productName: snap?.name ?? 'Vertrag', termEnd: new Date(c.term_end).toISOString() };
}
export class RenewalTokenError extends Error {}
/** Verbraucht den Token race-sicher (wie consumeToken bei Einladung/Passwort-Reset) und führt die Entscheidung aus:
* "keep" verlängert um eine Laufzeit (bzw. nimmt eine bereits gestellte Kündigung zurück, falls vorhanden);
* "cancel" setzt dieselbe Kündigungslogik wie die eingeloggte Kunden-Kündigung und legt zusätzlich ein internes
* Ticket an, damit Personal die externe Abmeldung (z. B. Domain-Registrar) tatsächlich vornimmt. */
export async function applyRenewalDecision(token: string, action: 'keep' | 'cancel'): Promise<{ contractNumber: string }> {
return tx(async (c) => {
const hash = sha256(token);
const t = await one('SELECT id, contract_id FROM contract_renewal_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3) FOR UPDATE', [hash], c);
if (!t) throw new RenewalTokenError('Link ungültig oder abgelaufen');
const upd = await run('UPDATE contract_renewal_tokens SET used_at = UTC_TIMESTAMP(3), used_action = ? WHERE id = ? AND used_at IS NULL', [action, t.id], c);
if (upd.affectedRows !== 1) throw new RenewalTokenError('Link wurde bereits verwendet');
const contract = await one('SELECT * FROM contracts WHERE id = ? FOR UPDATE', [t.contract_id], c);
if (!contract) throw new RenewalTokenError('Vertrag nicht gefunden');
if (!['active', 'suspended'].includes(contract.status)) throw new RenewalTokenError('Dieser Vertrag ist nicht mehr aktiv');
if (action === 'keep') {
if (contract.cancel_requested_at) {
await run('UPDATE contracts SET cancel_requested_at = NULL, cancel_effective_at = NULL WHERE id = ?', [contract.id], c);
await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: contract.id, after: { via: 'renewal_reminder' } }, c);
} else {
// Immer um mindestens eine Laufzeit verlängern (anders als renewedTermEnd/processContractLifecycle, die
// nur ein bereits VERGANGENES Laufzeitende aufholen - hier liegt term_end zum Zeitpunkt der Erinnerung
// noch in der Zukunft, genau das soll die Entscheidung "Behalten" ja bewirken).
const months = Number(contract.renewal_term_months) > 0 ? Number(contract.renewal_term_months) : 12;
let end = addMonths(new Date(contract.term_end ?? new Date()), months);
let guard = 0; while (end <= new Date() && guard++ < 1200) end = addMonths(end, months); // sehr späte Entscheidung: weiter aufholen
await run('UPDATE contracts SET term_end = ? WHERE id = ?', [end, contract.id], c);
await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.renew', resourceType: 'contract', resourceId: contract.id, after: { termEnd: end.toISOString(), via: 'renewal_reminder' } }, c);
}
} else {
if (!contract.cancel_requested_at) {
const now = new Date();
const effective = effectiveCancelDate(now, { termEnd: contract.term_end ? new Date(contract.term_end) : null, renewal: contract.renewal, renewalTermMonths: Number(contract.renewal_term_months), noticeDays: Number(contract.notice_days) });
await run('UPDATE contracts SET cancel_requested_at = ?, cancel_effective_at = ? WHERE id = ?', [now, effective, contract.id], c);
await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.cancel.request', resourceType: 'contract', resourceId: contract.id, after: { effectiveAt: effective.toISOString(), via: 'renewal_reminder' } }, c);
}
const owner = await one("SELECT user_id FROM memberships WHERE org_id = ? ORDER BY (role = 'owner') DESC LIMIT 1", [contract.org_id], c);
if (owner) {
await run("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'ticket'", [], c);
const num = await one('SELECT LAST_INSERT_ID() AS n', [], c);
const ticketId = randomUUID(); const ticketNumber = `T-${num!.n}`;
await run('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)',
[ticketId, ticketNumber, contract.org_id, `Kündigung über Erinnerungsmail: Vertrag ${contract.number}`, 'pending_staff', 'normal', owner.user_id], c);
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body, internal_note) VALUES (?,?,?,?,?,1)',
[randomUUID(), ticketId, owner.user_id, 'customer', `Der Kunde hat über die Erinnerungsmail zum Vertrag ${contract.number} "Kündigen" gewählt. Bitte die externe Abmeldung vornehmen (z. B. Domain beim Registrar kündigen) und das Ticket danach schließen.`], c);
}
}
return { contractNumber: contract.number as string };
});
}

View file

@ -55,25 +55,38 @@ export function renderTemplateText(tpl: string, vars: Record<string, string>): s
const escapeHtml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 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"). */ /** 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' }; const LINK_LABEL: Record<string, string> = {
inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen',
keepLink: 'Vertrag behalten', cancelLink: 'Vertrag kündigen',
};
/** Zweitrangige Schaltfläche (zurückhaltender gestaltet) statt der primären Akzentfarbe - z. B. für "Kündigen" neben "Behalten". */
const LINK_SECONDARY = new Set(['cancelLink']);
/** /**
* Baut aus dem (Klartext-)Vorlagentext eine einfache, mandantenfarbige HTML-Mail. Eine Variable, deren Name auf * Baut aus dem (Klartext-)Vorlagentext eine einfache, mandantenfarbige HTML-Mail. Jede Variable, deren Name auf
* "Link" endet, wird nicht als nackte URL im Text belassen, sondern als Schaltfläche ans Ende gesetzt (der * "Link" endet, wird nicht als nackte URL im Text belassen, sondern als eigene Schaltfläche ans Ende gesetzt
* Klartext-Teil der Mail behält den Link weiterhin als sichtbare URL, für Mailprogramme ohne HTML-Ansicht). * (mehrere "*Link"-Variablen ergeben mehrere Schaltflächen, z. B. "Behalten"/"Kündigen" nebeneinander). Der
* Klartext-Teil der Mail behält jeden Link weiterhin als sichtbare URL, für Mailprogramme ohne HTML-Ansicht.
*/ */
export function renderTemplateHtml(tpl: string, vars: Record<string, string>): string { export function renderTemplateHtml(tpl: string, vars: Record<string, string>): string {
const linkKey = Object.keys(vars).find((k) => /Link$/.test(k) && vars[k]); const linkKeys = Object.keys(vars).filter((k) => /Link$/.test(k) && vars[k]);
const bodyVars = linkKey ? { ...vars, [linkKey]: '' } : vars; const bodyVars = linkKeys.length ? { ...vars, ...Object.fromEntries(linkKeys.map((k) => [k, ''])) } : vars;
const rendered = tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in bodyVars ? escapeHtml(bodyVars[k]!) : m)) const rendered = tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in bodyVars ? escapeHtml(bodyVars[k]!) : m))
.replace(/https?:\/\/[^\s<]+/g, (url) => `<a href="${url}" style="color:#3b6fd8;">${url}</a>`); // weitere Links im Text bleiben klickbar, auch ohne eigenen Button .replace(/https?:\/\/[^\s<]+/g, (url) => `<a href="${url}" style="color:#3b6fd8;">${url}</a>`); // weitere Links im Text bleiben klickbar, auch ohne eigenen Button
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 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>` : ''; const buttons = linkKeys.map((k) => {
const secondary = LINK_SECONDARY.has(k);
const style = secondary
? 'display:inline-block;background:#ffffff;color:#3b6fd8;text-decoration:none;padding:11px 23px;border-radius:8px;font-weight:600;border:1px solid #3b6fd8;'
: 'display:inline-block;background:#3b6fd8;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;';
return `<a href="${escapeHtml(vars[k]!)}" style="${style}margin-right:12px;">${LINK_LABEL[k] ?? 'Öffnen'}</a>`;
}).join('');
const buttonRow = buttons ? `<p style="margin:24px 0 0;">${buttons}</p>` : '';
return `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#f4f6fa;font-family:-apple-system,Segoe UI,Roboto,sans-serif;"> 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;"> <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;"> <tr><td style="padding:28px 32px;">
<div style="font-weight:700;font-size:18px;color:#173568;margin-bottom:20px;">Kundencenter</div> <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> <div style="color:#172033;font-size:15px;line-height:1.55;">${paragraphs}${buttonRow}</div>
</td></tr></table> </td></tr></table>
</body></html>`; </body></html>`;
} }