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; }; }; /** 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[]; /** 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}.`, }), }; 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, }; 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, }; 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); }