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
+276
View File
@@ -0,0 +1,276 @@
import { renderHtmlEmail, renderTextEmail, type EmailContent } from "@/lib/email-brand";
import { BRAND } from "@/lib/brand";
/**
* SEC1 — Template-Katalog (de/en, HTML + Text).
*
* Warum ein eigener Katalog statt next-intl:
* Die Mails werden im **Worker** gerendert — außerhalb eines Requests. Die
* next-intl-Server-APIs (`getTranslations`) setzen einen Request-Scope voraus
* und stehen dort nicht zur Verfügung. Der Katalog hier ist bewusst schlank
* und worker-tauglich; die UI-Kataloge in `messages/*.json` bleiben unberührt.
*
* Layout, Farben und die Dachmarken-Fußzeile kommen aus `src/lib/email-brand.ts`
* (Certvia-CD, Inline-Styles, Tabellenlayout für Outlook).
*
* WICHTIG: Templates erhalten fertige `actionUrl`s. Tokens werden von SEC2/SEC3/
* SEC4 erzeugt und tauchen weder im MailLog noch in Logs auf.
*/
export const LOCALES = ["de", "en"] as const;
export type Locale = (typeof LOCALES)[number];
export function normalizeLocale(input?: string | null): Locale {
return input === "en" ? "en" : "de";
}
/** Variablen je Template — bewusst eng typisiert, damit Aufrufer nichts vergessen. */
export type TemplateVars = {
invitation: { name: string; tenantName: string; actionUrl: string; expires: string };
password_reset: { name: string; actionUrl: string; expires: string };
password_changed: { name: string; when: string; ip?: string };
email_change_verify: { name: string; actionUrl: string; expires: string; newEmail: string };
email_changed_notice: { name: string; newEmail: string; when: string };
mfa_changed: { name: string; change: string; when: string };
notification: {
name: string;
subject: string;
body: string;
actionUrl?: string;
taskType: string;
};
incident_notification: {
name: string;
subject: string;
body: string;
actionUrl?: string;
refNo: string;
};
test: { name: string; when: string };
};
export type TemplateKey = keyof TemplateVars;
export const TEMPLATE_KEYS = [
"invitation",
"password_reset",
"password_changed",
"email_change_verify",
"email_changed_notice",
"mfa_changed",
"notification",
"incident_notification",
"test",
] as const satisfies readonly TemplateKey[];
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
const FOOTER_NOTE: Record<Locale, string> = {
de: "Sie erhalten diese Benachrichtigung, weil Ihnen eine Aufgabe zugewiesen ist. Die Einstellungen dazu finden Sie in Ihrem Profil.",
en: "You are receiving this notification because a task is assigned to you. You can change this in your profile.",
};
/** Fußnote für Vorfall-Benachrichtigungen (§7). */
const INCIDENT_FOOTER_NOTE: Record<Locale, string> = {
de: "Sie erhalten diese Benachrichtigung, weil Sie am Vorfallmanagement beteiligt sind. Die Einstellungen dazu finden Sie in Ihrem Profil.",
en: "You are receiving this notification because you are involved in incident management. You can change this in your profile.",
};
type Builder<K extends TemplateKey> = (vars: TemplateVars[K]) => EmailContent;
const de: { [K in TemplateKey]: Builder<K> } = {
invitation: (v) => ({
subject: `Ihr Zugang zu ${BRAND.name}`,
heading: `Willkommen bei ${BRAND.name}`,
paragraphs: [
`Hallo ${v.name},`,
`für Sie wurde ein Zugang zu ${BRAND.name} für „${v.tenantName}" eingerichtet. Über den folgenden Link vergeben Sie Ihr Passwort und schließen die Einrichtung ab.`,
],
action: { label: "Zugang einrichten", url: v.actionUrl },
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
}),
password_reset: (v) => ({
subject: `${BRAND.name}: Passwort zurücksetzen`,
heading: "Passwort zurücksetzen",
paragraphs: [
`Hallo ${v.name},`,
"für Ihr Konto wurde ein Zurücksetzen des Passworts angefordert. Über den folgenden Link vergeben Sie ein neues Passwort.",
"Haben Sie das nicht angefordert, können Sie diese E-Mail ignorieren — Ihr Passwort bleibt dann unverändert.",
],
action: { label: "Neues Passwort vergeben", url: v.actionUrl },
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
}),
password_changed: (v) => ({
subject: `${BRAND.name}: Ihr Passwort wurde geändert`,
heading: "Passwort geändert",
paragraphs: [
`Hallo ${v.name},`,
`das Passwort Ihres Kontos wurde am ${v.when} geändert${v.ip ? ` (IP ${v.ip})` : ""}.`,
"Waren Sie das nicht, wenden Sie sich bitte umgehend an Ihre Administration.",
],
}),
email_change_verify: (v) => ({
subject: `${BRAND.name}: Neue E-Mail-Adresse bestätigen`,
heading: "E-Mail-Adresse bestätigen",
paragraphs: [
`Hallo ${v.name},`,
`Sie möchten die E-Mail-Adresse Ihres Kontos auf ${v.newEmail} ändern. Bitte bestätigen Sie die neue Adresse über den folgenden Link.`,
"Die Änderung wird erst nach dieser Bestätigung wirksam.",
],
action: { label: "Neue Adresse bestätigen", url: v.actionUrl },
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
}),
email_changed_notice: (v) => ({
subject: `${BRAND.name}: Ihre E-Mail-Adresse wurde geändert`,
heading: "E-Mail-Adresse geändert",
paragraphs: [
`Hallo ${v.name},`,
`die E-Mail-Adresse Ihres Kontos wurde am ${v.when} auf ${v.newEmail} geändert. Künftige Anmeldungen erfolgen mit der neuen Adresse.`,
"Waren Sie das nicht, wenden Sie sich bitte umgehend an Ihre Administration.",
],
}),
mfa_changed: (v) => ({
subject: `${BRAND.name}: Zwei-Faktor-Authentifizierung geändert`,
heading: "Zwei-Faktor-Authentifizierung geändert",
paragraphs: [
`Hallo ${v.name},`,
`an der Zwei-Faktor-Authentifizierung Ihres Kontos wurde am ${v.when} eine Änderung vorgenommen: ${v.change}.`,
"Waren Sie das nicht, wenden Sie sich bitte umgehend an Ihre Administration.",
],
}),
notification: (v) => ({
subject: `${BRAND.name}: ${v.subject}`,
heading: v.subject,
paragraphs: [`Hallo ${v.name},`, v.body],
action: v.actionUrl ? { label: "In Certvia öffnen", url: v.actionUrl } : undefined,
footerNote: FOOTER_NOTE.de,
}),
incident_notification: (v) => ({
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
heading: v.subject,
paragraphs: [`Hallo ${v.name},`, v.body],
action: v.actionUrl ? { label: "Vorfall öffnen", url: v.actionUrl } : undefined,
footerNote: INCIDENT_FOOTER_NOTE.de,
}),
test: (v) => ({
subject: `${BRAND.name}: Test-Mail`,
heading: "Test-Mail",
paragraphs: [
`Hallo ${v.name},`,
`diese Nachricht wurde am ${v.when} als Zustelltest aus der ${BRAND.name}-Administration versendet.`,
"Erreicht sie Sie, sind SMTP-Konfiguration und Versandweg in Ordnung.",
],
}),
};
const en: { [K in TemplateKey]: Builder<K> } = {
invitation: (v) => ({
subject: `Your ${BRAND.name} account`,
heading: `Welcome to ${BRAND.name}`,
paragraphs: [
`Hello ${v.name},`,
`an account has been created for you on ${BRAND.name} for "${v.tenantName}". Use the link below to set your password and finish the setup.`,
],
action: { label: "Set up account", url: v.actionUrl },
note: `The link is valid until ${v.expires} and can only be used once.`,
}),
password_reset: (v) => ({
subject: `${BRAND.name}: reset your password`,
heading: "Reset your password",
paragraphs: [
`Hello ${v.name},`,
"a password reset was requested for your account. Use the link below to choose a new password.",
"If you did not request this, you can ignore this e-mail — your password stays unchanged.",
],
action: { label: "Choose a new password", url: v.actionUrl },
note: `The link is valid until ${v.expires} and can only be used once.`,
}),
password_changed: (v) => ({
subject: `${BRAND.name}: your password was changed`,
heading: "Password changed",
paragraphs: [
`Hello ${v.name},`,
`the password of your account was changed on ${v.when}${v.ip ? ` (IP ${v.ip})` : ""}.`,
"If this was not you, please contact your administrator immediately.",
],
}),
email_change_verify: (v) => ({
subject: `${BRAND.name}: confirm your new e-mail address`,
heading: "Confirm your e-mail address",
paragraphs: [
`Hello ${v.name},`,
`you requested to change your account e-mail address to ${v.newEmail}. Please confirm the new address using the link below.`,
"The change only takes effect after this confirmation.",
],
action: { label: "Confirm new address", url: v.actionUrl },
note: `The link is valid until ${v.expires} and can only be used once.`,
}),
email_changed_notice: (v) => ({
subject: `${BRAND.name}: your e-mail address was changed`,
heading: "E-mail address changed",
paragraphs: [
`Hello ${v.name},`,
`the e-mail address of your account was changed to ${v.newEmail} on ${v.when}. Future sign-ins use the new address.`,
"If this was not you, please contact your administrator immediately.",
],
}),
mfa_changed: (v) => ({
subject: `${BRAND.name}: two-factor authentication changed`,
heading: "Two-factor authentication changed",
paragraphs: [
`Hello ${v.name},`,
`two-factor authentication for your account was changed on ${v.when}: ${v.change}.`,
"If this was not you, please contact your administrator immediately.",
],
}),
notification: (v) => ({
subject: `${BRAND.name}: ${v.subject}`,
heading: v.subject,
paragraphs: [`Hello ${v.name},`, v.body],
action: v.actionUrl ? { label: `Open in ${BRAND.name}`, url: v.actionUrl } : undefined,
footerNote: FOOTER_NOTE.en,
}),
incident_notification: (v) => ({
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
heading: v.subject,
paragraphs: [`Hello ${v.name},`, v.body],
action: v.actionUrl ? { label: "Open incident", url: v.actionUrl } : undefined,
footerNote: INCIDENT_FOOTER_NOTE.en,
}),
test: (v) => ({
subject: `${BRAND.name}: test message`,
heading: "Test message",
paragraphs: [
`Hello ${v.name},`,
`this message was sent on ${v.when} as a delivery test from the ${BRAND.name} administration.`,
"If it reaches you, SMTP configuration and delivery path are working.",
],
}),
};
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
export type RenderedMail = { subject: string; html: string; text: string };
/** Rendert ein Template in der gewünschten Sprache zu HTML + Text. */
export function renderTemplate<K extends TemplateKey>(
template: K,
locale: Locale,
vars: TemplateVars[K],
): RenderedMail {
const build = CATALOG[locale][template] as Builder<K>;
const content = build(vars);
return {
subject: content.subject,
html: renderHtmlEmail(content),
text: renderTextEmail(content),
};
}
/** Datum/Zeit für Mail-Texte — bewusst hier, damit Worker und App identisch formatieren. */
export function formatWhen(date: Date, locale: Locale): string {
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Berlin",
}).format(date);
}