Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
import { BRAND, BRAND_COLORS } from "@/lib/brand";
import { DOCUMENT_THEME } from "@/lib/document-brand";
/**
* Brandfähige E-Mail-Template-Basis (Story S6).
*
* SMTP und der Einladungs-/Reset-Flow kommen erst mit Paket 4 — dieses Modul
* liefert vorab das Certvia-Gerüst, damit die Templates dann nur noch Inhalt
* einsetzen müssen und das CD nicht neu erfunden wird.
*
* Bewusste Einschränkungen für E-Mail-Clients:
* - keine CSS-Variablen, keine externen Stylesheets → alles inline
* - Tabellen-Layout statt Flex/Grid (Outlook)
* - Logo als absolute URL, kein Inline-SVG (viele Clients rendern es nicht)
* - immer eine Plaintext-Alternative mitschicken (`renderTextEmail`)
*/
export type EmailContent = {
/** Betreff — wird nicht in den Body gerendert, nur durchgereicht */
subject: string;
/** Überschrift im Body */
heading: string;
/** Absätze; jeder Eintrag wird ein <p> */
paragraphs: string[];
/** Optionaler Call-to-Action */
action?: { label: string; url: string };
/** Kleingedrucktes über der Fußzeile (z. B. Gültigkeitsdauer eines Links) */
note?: string;
/**
* Zusatzzeile in der Fußzeile — für den Abmelde-/Präferenzhinweis bei
* Benachrichtigungsmails. Sicherheitsrelevante Transaktionsmails (Reset,
* Passwortwechsel) setzen ihn bewusst NICHT: sie sind nicht abbestellbar.
*/
footerNote?: string;
};
/**
* Basis-URL für absolute Links/Bilder in Mails. Relative Pfade funktionieren in
* E-Mail-Clients nicht.
*/
function baseUrl(): string {
return (process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "https://app.certvia.de").replace(
/\/+$/,
""
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Absenderzeile für den `From`-Header. */
export function emailSender(address: string): string {
return `${BRAND.name} <${address}>`;
}
/** Signatur-/Fußzeilentext — enthält immer den Dachmarken-Hinweis. */
export function emailSignature(): string {
return `${BRAND.name} — ${BRAND.byline}`;
}
/**
* Rendert eine Mail im Certvia-CD (heller Grund, Marken-Violett, Poppins-Kopf).
* Rückgabe ist ein vollständiges HTML-Dokument.
*/
export function renderHtmlEmail(content: EmailContent): string {
const url = baseUrl();
const t = DOCUMENT_THEME;
const paragraphs = content.paragraphs
.map(
(p) =>
`<p style="margin:0 0 14px;font-family:${t.bodyFont};font-size:15px;line-height:1.6;color:${t.text};">${escapeHtml(p)}</p>`
)
.join("");
const action = content.action
? `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0;">
<tr><td style="background:${BRAND_COLORS.violet};border-radius:8px;">
<a href="${escapeHtml(content.action.url)}" style="display:inline-block;padding:12px 22px;font-family:${t.headingFont};font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">${escapeHtml(content.action.label)}</a>
</td></tr>
</table>`
: "";
const note = content.note
? `<p style="margin:0;font-family:${t.bodyFont};font-size:12px;line-height:1.5;color:${t.textMuted};">${escapeHtml(content.note)}</p>`
: "";
return `<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(content.subject)}</title></head>
<body style="margin:0;padding:0;background:#f4f2fa;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f2fa;padding:28px 12px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:${t.pageBackground};border-radius:14px;overflow:hidden;border:1px solid ${t.rule};">
<tr><td style="padding:26px 30px 0;">
<img src="${url}/assets/logo/certvia-lockup-positiv.svg" alt="${BRAND.name}" height="34" style="height:34px;width:auto;display:block;border:0;">
</td></tr>
<tr><td style="padding:22px 30px 4px;">
<h1 style="margin:0 0 14px;font-family:${t.headingFont};font-size:20px;font-weight:600;color:${t.text};">${escapeHtml(content.heading)}</h1>
${paragraphs}
${action}
${note}
</td></tr>
<tr><td style="padding:22px 30px 26px;">
<div style="border-top:1px solid ${t.rule};padding-top:14px;font-family:${t.bodyFont};font-size:12px;color:${t.textMuted};">
${escapeHtml(emailSignature())}${
content.footerNote
? `<br><span style="font-size:11px;">${escapeHtml(content.footerNote)}</span>`
: ""
}
</div>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
/** Plaintext-Alternative zur HTML-Mail (immer als `text` mitschicken). */
export function renderTextEmail(content: EmailContent): string {
const lines = [content.heading, "", ...content.paragraphs];
if (content.action) lines.push("", `${content.action.label}: ${content.action.url}`);
if (content.note) lines.push("", content.note);
lines.push("", "—", emailSignature());
if (content.footerNote) lines.push(content.footerNote);
return lines.join("\n");
}