Basis: Certvia dev@a48c5fb als Fundament für Craftvia
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user