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`
* (Craftvia-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;
eventType: string;
};
test: { name: string; when: string };
// ---- Craftvia domain notifications (lane L6). `name` may be "" for configured external recipients.
craftvia_team_assigned: {
name: string; number: string; title: string; customer: string;
site?: string; plannedStart?: string; team?: string; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_report_review: {
name: string; number: string; title: string; customer: string;
submittedBy?: string; reportType?: "daily" | "completion"; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_billing_release: {
name: string; number: string; title: string; customer: string; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_emergency: {
name: string; phase: "created" | "completed"; number: string; technician: string; customer: string;
start: string; end?: string; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_document_failed: {
name: string; fileName: string; error?: string; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_notification: {
name: string; subject: string; body: string; actionUrl?: string; footer?: CraftviaFooter;
};
// ---- Craftvia customer mail (lane L11). No app link: the customer has no account.
craftvia_report_customer: {
customerName: string; tenantName: string; reportTitle: string; reportDate: string; message?: string;
};
// ---- L15 Testphase (Transaktions-/Pflichtmails, nicht abbestellbar)
trial_confirm: { name: string; companyName: string; trialEnd: string; actionUrl: string; expires: string };
trial_existing_account: { name: string; loginUrl: string; resetUrl: string };
trial_reminder: { name: string; tenantName: string; daysLeft: number; endDate: string; actionUrl: string; contact?: string };
trial_expired: { name: string; tenantName: string; endDate: string; deletionDate?: string; exportUrl: string; contact?: string };
trial_deletion_notice: { name: string; tenantName: string; deletionDate: string; exportUrl: string; contact?: string };
};
/** Why the recipient gets a Craftvia notification — controls the footer line. */
export type CraftviaFooter = "user" | "mandatory" | "configured";
export type TemplateKey = keyof TemplateVars;
export const TEMPLATE_KEYS = [
"invitation",
"password_reset",
"password_changed",
"email_change_verify",
"email_changed_notice",
"mfa_changed",
"notification",
"test",
] as const satisfies readonly TemplateKey[];
/** Craftvia domain templates (lane L6) — kept separate so SEC1 fixtures stay unchanged. */
export const CRAFTVIA_TEMPLATE_KEYS = [
"craftvia_team_assigned",
"craftvia_report_review",
"craftvia_billing_release",
"craftvia_emergency",
"craftvia_document_failed",
"craftvia_notification",
] as const satisfies readonly TemplateKey[];
/** Customer-facing Craftvia mails (lane L11) — separate list, recipients are external customers. */
export const CUSTOMER_TEMPLATE_KEYS = ["craftvia_report_customer"] as const satisfies readonly TemplateKey[];
/** L15 Testphase: Anmeldung (Double-Opt-in), Hinweis bei bestehendem Konto, Erinnerungen, Ablauf, Löschung. */
export const TRIAL_TEMPLATE_KEYS = ["trial_confirm", "trial_existing_account", "trial_reminder", "trial_expired", "trial_deletion_notice"] as const satisfies readonly TemplateKey[];
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
const FOOTER_NOTE: Record = {
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
en: "You are receiving this notification because of your role in your company. You can change this in your profile.",
};
type Builder = (vars: TemplateVars[K]) => EmailContent;
// ---- Craftvia domain notifications (lane L6) ----
type CraftviaKey = (typeof CRAFTVIA_TEMPLATE_KEYS)[number];
const CRAFTVIA_FOOTER: Record> = {
de: {
user: FOOTER_NOTE.de,
mandatory: "Pflichtbenachrichtigung zum Notdienst – sie lässt sich nicht abbestellen.",
configured: "Sie erhalten diese Nachricht, weil Ihre Adresse im Betrieb als fester Empfänger hinterlegt ist.",
},
en: {
user: FOOTER_NOTE.en,
mandatory: "Mandatory emergency call-out notification – it cannot be turned off.",
configured: "You are receiving this message because your address is set up as a fixed recipient in your company.",
},
};
const greetDe = (name: string) => (name ? `Hallo ${name},` : "Guten Tag,");
const greetEn = (name: string) => (name ? `Hello ${name},` : "Hello,");
const craftviaDe: { [K in CraftviaKey]: Builder } = {
craftvia_team_assigned: (v) => ({
subject: `${BRAND.name}: Neuer Auftrag ${v.number}`,
heading: "Neuer Auftrag für Ihr Team",
paragraphs: [
greetDe(v.name),
`der Auftrag ${v.number} „${v.title}" wurde ${v.team ? `dem Team ${v.team}` : "Ihnen"} zugewiesen.`,
`Kunde: ${v.customer}`,
...(v.site ? [`Objekt: ${v.site}`] : []),
...(v.plannedStart ? [`Geplanter Beginn: ${v.plannedStart}`] : []),
],
action: { label: "Auftrag öffnen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
craftvia_report_review: (v) => ({
subject: `${BRAND.name}: Bericht zur Prüfung – ${v.number}`,
heading: "Bericht zur Prüfung",
paragraphs: [
greetDe(v.name),
`${v.submittedBy ?? "Das Team"} hat ${v.reportType === "daily" ? "einen Tagesbericht" : v.reportType === "completion" ? "einen Abschlussbericht" : "einen Bericht"} zu ${v.number} „${v.title}" eingereicht.`,
`Kunde: ${v.customer}`,
"Bitte prüfen und freigeben oder eine Korrektur anfordern.",
],
action: { label: "Bericht prüfen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
craftvia_billing_release: (v) => ({
subject: `${BRAND.name}: Auftrag ${v.number} zur Abrechnung`,
heading: "Auftrag bereit zur Abrechnung",
paragraphs: [
greetDe(v.name),
`der Auftrag ${v.number} „${v.title}" ist geprüft und zur Abrechnung freigegeben.`,
`Kunde: ${v.customer}`,
],
action: { label: "Auftrag öffnen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
craftvia_emergency: (v) => ({
subject: `${BRAND.name}: Notdiensteinsatz ${v.number} ${v.phase === "completed" ? "abgeschlossen" : "erstellt"}`,
heading: v.phase === "completed" ? "Neuer Notdiensteinsatz abgeschlossen" : "Neuer Notdiensteinsatz erstellt",
paragraphs: [
greetDe(v.name),
`Monteur: ${v.technician}`,
`Kunde: ${v.customer}`,
`Einsatzbeginn: ${v.start}`,
...(v.end ? [`Einsatzende: ${v.end}`] : []),
`Status: ${v.phase === "completed" ? "Zur Prüfung und Abrechnung" : "In Arbeit"}`,
],
action: { label: "Einsatz öffnen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "mandatory"],
}),
craftvia_document_failed: (v) => ({
subject: `${BRAND.name}: Fehler bei der Dokumentverarbeitung`,
heading: "Dokument konnte nicht verarbeitet werden",
paragraphs: [
greetDe(v.name),
`die Datei „${v.fileName}" konnte nicht automatisch ausgelesen werden.`,
...(v.error ? [`Grund: ${v.error}`] : []),
"Die Datei bleibt gespeichert. Den Auftrag bitte manuell erfassen oder die Datei erneut hochladen.",
],
action: { label: "Import öffnen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
craftvia_notification: (v) => ({
subject: `${BRAND.name}: ${v.subject}`,
heading: v.subject,
paragraphs: [greetDe(v.name), v.body],
action: v.actionUrl ? { label: `In ${BRAND.name} öffnen`, url: v.actionUrl } : undefined,
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
};
const craftviaEn: { [K in CraftviaKey]: Builder } = {
craftvia_team_assigned: (v) => ({
subject: `${BRAND.name}: New work order ${v.number}`,
heading: "New work order for your team",
paragraphs: [
greetEn(v.name),
`work order ${v.number} "${v.title}" was assigned to ${v.team ? `team ${v.team}` : "you"}.`,
`Customer: ${v.customer}`,
...(v.site ? [`Site: ${v.site}`] : []),
...(v.plannedStart ? [`Planned start: ${v.plannedStart}`] : []),
],
action: { label: "Open work order", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
craftvia_report_review: (v) => ({
subject: `${BRAND.name}: Report for review – ${v.number}`,
heading: "Report for review",
paragraphs: [
greetEn(v.name),
`${v.submittedBy ?? "The team"} submitted ${v.reportType === "daily" ? "a daily report" : v.reportType === "completion" ? "a completion report" : "a report"} for ${v.number} "${v.title}".`,
`Customer: ${v.customer}`,
"Please review and approve it or request a correction.",
],
action: { label: "Review report", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
craftvia_billing_release: (v) => ({
subject: `${BRAND.name}: Work order ${v.number} ready for billing`,
heading: "Work order ready for billing",
paragraphs: [
greetEn(v.name),
`work order ${v.number} "${v.title}" has been reviewed and released for billing.`,
`Customer: ${v.customer}`,
],
action: { label: "Open work order", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
craftvia_emergency: (v) => ({
subject: `${BRAND.name}: Emergency call-out ${v.number} ${v.phase === "completed" ? "completed" : "created"}`,
heading: v.phase === "completed" ? "New emergency call-out completed" : "New emergency call-out created",
paragraphs: [
greetEn(v.name),
`Technician: ${v.technician}`,
`Customer: ${v.customer}`,
`Start: ${v.start}`,
...(v.end ? [`End: ${v.end}`] : []),
`Status: ${v.phase === "completed" ? "Ready for review and billing" : "In progress"}`,
],
action: { label: "Open call-out", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "mandatory"],
}),
craftvia_document_failed: (v) => ({
subject: `${BRAND.name}: Document processing failed`,
heading: "Document could not be processed",
paragraphs: [
greetEn(v.name),
`the file "${v.fileName}" could not be read automatically.`,
...(v.error ? [`Reason: ${v.error}`] : []),
"The file stays stored. Please enter the work order manually or upload the file again.",
],
action: { label: "Open import", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
craftvia_notification: (v) => ({
subject: `${BRAND.name}: ${v.subject}`,
heading: v.subject,
paragraphs: [greetEn(v.name), v.body],
action: v.actionUrl ? { label: `Open in ${BRAND.name}`, url: v.actionUrl } : undefined,
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
};
// ---- Customer mails (lane L11) ----
type CustomerKey = (typeof CUSTOMER_TEMPLATE_KEYS)[number];
/** Subject lines must never carry CR/LF (header injection); free text is HTML-escaped by email-brand. */
const oneLine = (s: string) => s.replace(/[\r\n]+/g, " ").trim();
const messageParagraphs = (m?: string) =>
(m ?? "")
.split(/\r?\n\s*\r?\n|\r?\n/)
.map((p) => p.trim())
.filter(Boolean);
const customerDe: { [K in CustomerKey]: Builder } = {
craftvia_report_customer: (v) => ({
subject: oneLine(`${v.reportTitle} – ${v.tenantName}`),
heading: oneLine(v.reportTitle),
paragraphs: [
"Guten Tag,",
`anbei erhalten Sie von ${v.tenantName} den ${v.reportTitle} vom ${v.reportDate} für ${v.customerName}.`,
...messageParagraphs(v.message),
"Der Arbeitsnachweis ist als PDF angehängt.",
],
footerNote: `Diese Nachricht wurde von ${v.tenantName} über ${BRAND.name} versendet.`,
}),
};
const customerEn: { [K in CustomerKey]: Builder } = {
craftvia_report_customer: (v) => ({
subject: oneLine(`${v.reportTitle} – ${v.tenantName}`),
heading: oneLine(v.reportTitle),
paragraphs: [
"Hello,",
`please find attached the ${v.reportTitle} dated ${v.reportDate} from ${v.tenantName} for ${v.customerName}.`,
...messageParagraphs(v.message),
"The work record is attached as a PDF.",
],
footerNote: `This message was sent by ${v.tenantName} via ${BRAND.name}.`,
}),
};
// ---- L15 Testphase ----
type TrialKey = (typeof TRIAL_TEMPLATE_KEYS)[number];
const contactDe = (c?: string) => (c ? `Fragen oder Wunsch nach der Vollversion? Schreiben Sie uns: ${c}` : "Fragen oder Wunsch nach der Vollversion? Antworten Sie einfach auf diese E-Mail.");
const contactEn = (c?: string) => (c ? `Questions or ready for the full version? Write to us: ${c}` : "Questions or ready for the full version? Simply reply to this e-mail.");
const daysDe = (n: number) => (n <= 0 ? "heute" : n === 1 ? "morgen" : `in ${n} Tagen`);
const daysEn = (n: number) => (n <= 0 ? "today" : n === 1 ? "tomorrow" : `in ${n} days`);
const trialDe: { [K in TrialKey]: Builder } = {
trial_confirm: (v) => ({
subject: `${BRAND.name}: Testphase bestätigen`,
heading: "Testphase bestätigen",
paragraphs: [
greetDe(v.name),
`Sie möchten ${BRAND.name} für „${oneLine(v.companyName)}" testen. Ihre Testphase läuft bis ${v.trialEnd}.`,
"Bestätigen Sie Ihre E-Mail-Adresse – danach richten wir Ihren Zugang sofort ein.",
"Haben Sie das nicht angefordert, ignorieren Sie diese E-Mail. Es wird dann nichts angelegt.",
],
action: { label: "Testphase starten", url: v.actionUrl },
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
}),
trial_existing_account: (v) => ({
subject: `${BRAND.name}: Sie haben bereits einen Zugang`,
heading: "Sie haben bereits einen Zugang",
paragraphs: [
greetDe(v.name),
`für diese E-Mail-Adresse besteht bereits ein Zugang zu ${BRAND.name}. Eine weitere Testphase mit derselben Adresse ist nicht möglich.`,
"Melden Sie sich mit Ihrem bestehenden Zugang an. Passwort vergessen? Dann setzen Sie es über den zweiten Link zurück.",
"Haben Sie das nicht angefordert, ignorieren Sie diese E-Mail.",
],
action: { label: "Zur Anmeldung", url: v.loginUrl },
note: `Passwort zurücksetzen: ${v.resetUrl}`,
}),
trial_reminder: (v) => ({
subject: `${BRAND.name}: Testphase endet ${daysDe(v.daysLeft)}`,
heading: `Ihre Testphase endet ${daysDe(v.daysLeft)}`,
paragraphs: [
greetDe(v.name),
`die Testphase von „${oneLine(v.tenantName)}" läuft bis ${v.endDate}. Danach sind Ihre Daten nur noch lesbar und exportierbar.`,
contactDe(v.contact),
],
action: { label: `${BRAND.name} öffnen`, url: v.actionUrl },
}),
trial_expired: (v) => ({
subject: `${BRAND.name}: Testphase abgelaufen`,
heading: "Testphase abgelaufen – nur Lesezugriff",
paragraphs: [
greetDe(v.name),
`die Testphase von „${oneLine(v.tenantName)}" ist am ${v.endDate} abgelaufen. Sie können sich weiter anmelden, Daten ansehen und exportieren – Änderungen sind nicht mehr möglich.`,
...(v.deletionDate ? [`Ohne Umwandlung in die Vollversion werden die Daten am ${v.deletionDate} gelöscht.`] : []),
contactDe(v.contact),
],
action: { label: "Daten exportieren", url: v.exportUrl },
}),
trial_deletion_notice: (v) => ({
subject: `${BRAND.name}: Daten werden am ${v.deletionDate} gelöscht`,
heading: "Löschung Ihrer Testdaten",
paragraphs: [
greetDe(v.name),
`die Daten von „${oneLine(v.tenantName)}" werden am ${v.deletionDate} endgültig gelöscht. Exportieren Sie vorher alles, was Sie behalten möchten.`,
contactDe(v.contact),
],
action: { label: "Daten exportieren", url: v.exportUrl },
}),
};
const trialEn: { [K in TrialKey]: Builder } = {
trial_confirm: (v) => ({
subject: `${BRAND.name}: confirm your trial`,
heading: "Confirm your trial",
paragraphs: [
greetEn(v.name),
`you would like to try ${BRAND.name} for "${oneLine(v.companyName)}". Your trial runs until ${v.trialEnd}.`,
"Confirm your e-mail address – we set up your account right away.",
"If you did not request this, ignore this e-mail. Nothing will be created.",
],
action: { label: "Start trial", url: v.actionUrl },
note: `The link is valid until ${v.expires} and can only be used once.`,
}),
trial_existing_account: (v) => ({
subject: `${BRAND.name}: you already have an account`,
heading: "You already have an account",
paragraphs: [
greetEn(v.name),
`there already is a ${BRAND.name} account for this e-mail address. Another trial with the same address is not possible.`,
"Sign in with your existing account. Forgot your password? Reset it with the second link.",
"If you did not request this, ignore this e-mail.",
],
action: { label: "Sign in", url: v.loginUrl },
note: `Reset password: ${v.resetUrl}`,
}),
trial_reminder: (v) => ({
subject: `${BRAND.name}: trial ends ${daysEn(v.daysLeft)}`,
heading: `Your trial ends ${daysEn(v.daysLeft)}`,
paragraphs: [
greetEn(v.name),
`the trial of "${oneLine(v.tenantName)}" runs until ${v.endDate}. Afterwards your data can only be viewed and exported.`,
contactEn(v.contact),
],
action: { label: `Open ${BRAND.name}`, url: v.actionUrl },
}),
trial_expired: (v) => ({
subject: `${BRAND.name}: trial expired`,
heading: "Trial expired – read-only access",
paragraphs: [
greetEn(v.name),
`the trial of "${oneLine(v.tenantName)}" expired on ${v.endDate}. You can still sign in, view and export your data – changes are no longer possible.`,
...(v.deletionDate ? [`Unless converted to the full version, the data will be deleted on ${v.deletionDate}.`] : []),
contactEn(v.contact),
],
action: { label: "Export data", url: v.exportUrl },
}),
trial_deletion_notice: (v) => ({
subject: `${BRAND.name}: data will be deleted on ${v.deletionDate}`,
heading: "Deletion of your trial data",
paragraphs: [
greetEn(v.name),
`the data of "${oneLine(v.tenantName)}" will be deleted permanently on ${v.deletionDate}. Export everything you want to keep beforehand.`,
contactEn(v.contact),
],
action: { label: "Export data", url: v.exportUrl },
}),
};
const de: { [K in TemplateKey]: Builder } = {
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 ${BRAND.name} öffnen`, url: v.actionUrl } : undefined,
footerNote: 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.",
],
}),
...craftviaDe,
...customerDe,
...trialDe,
};
const en: { [K in TemplateKey]: Builder } = {
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,
}),
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.",
],
}),
...craftviaEn,
...customerEn,
...trialEn,
};
const CATALOG: Record }> = { 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(
template: K,
locale: Locale,
vars: TemplateVars[K],
): RenderedMail {
const build = CATALOG[locale][template] as Builder;
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);
}