- 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>
135 lines
5.5 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
/** 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");
|
|
}
|