Files
craftvia/src/server/mail/templates.ts
T
msolarczekandClaude Opus 5 e0106b8f8e L11 Kundenversand: Mail-Anhänge per Dokument-Referenz und Template craftvia_report_customer
- MailJob/EnqueueInput: attachments als { documentId } (keine Bytes in Redis, nur mit tenantId)
- deliverMail: Anhänge mandantengebunden aus MailLog.tenantId laden, SHA-256 prüfen,
  Größenlimit MAIL_MAX_ATTACHMENT_BYTES (Default 10 MB); Fehler -> failed ohne Versand
- SMTP-Provider reicht Anhänge an nodemailer durch; optionaler Provider für Tests
- Template craftvia_report_customer (de/en) ohne App-Link, eigene CUSTOMER_TEMPLATE_KEYS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:48:45 +02:00

497 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Locale, string> = {
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<K extends TemplateKey> = (vars: TemplateVars[K]) => EmailContent;
// ---- Craftvia domain notifications (lane L6) ----
type CraftviaKey = (typeof CRAFTVIA_TEMPLATE_KEYS)[number];
const CRAFTVIA_FOOTER: Record<Locale, Record<CraftviaFooter, string>> = {
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<K> } = {
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<K> } = {
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<K> } = {
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<K> } = {
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<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 ${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<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,
}),
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<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);
}