Files
craftvia/src/lib/email-brand.ts
T
msolarczekandClaude Opus 5 9ec7fa5356 Fundament: Branding Craftvia
- Farb-Tokens laut Brandbook §11.4 (Lotsenblau, Signalorange, Graphit, Hafengrau,
  Stahlgrau, Zink, Funktionsfarben) in globals.css + src/lib/brand.ts; helles Theme
- Inline-SVG-Logo src/components/brand/craftvia-logo.tsx (horizontal/signet,
  color/mono/reversed, App-Icon-Kachel, optionale Tagline); Certvia-/GEFIM-Logos entfernt
- Favicon/PWA-Icons aus dem Signet erzeugt (scripts/generate-brand-icons.ts),
  site.webmanifest (Craftvia, theme_color #082E5B)
- Inter selbst gehostet (next/font/local, lokale OFL-Datei), Open Sans/Poppins entfernt
- Metadaten, WebAuthn-RP-Name, Mail-/Dokument-CD auf Craftvia umgestellt
- docs/craftvia/BRANDING.md ersetzt docs/BRANDING-CERTVIA.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:39:52 +02:00

135 lines
5.5 KiB
TypeScript

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 das Craftvia-Gerüst, damit die Templates 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 ?? "http://localhost:3000").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. */
export function emailSignature(): string {
return `${BRAND.name} — ${BRAND.tagline}`;
}
/**
* Rendert eine Mail im Craftvia-CD (Hafengrau-Grund, Lotsenblau, Signalorange-CTA).
* 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.signalorange};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:${BRAND_COLORS.hafengrau};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${BRAND_COLORS.hafengrau};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}/favicon/pwa-192.png" alt="${BRAND.name}" height="34" width="34" style="height:34px;width:34px;display:inline-block;vertical-align:middle;border:0;border-radius:8px;">
<span style="display:inline-block;vertical-align:middle;margin-left:10px;font-family:${t.headingFont};font-size:22px;font-weight:800;color:${BRAND_COLORS.lotsenblau};">Craft<span style="color:${BRAND_COLORS.signalorange};">via</span></span>
</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");
}