- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt, Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status - /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung), Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot, Enumeration-Schutz, Slug-Kollisionen - Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern, umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit) - Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi), Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil - Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand - Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding - Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
632 lines
28 KiB
TypeScript
632 lines
28 KiB
TypeScript
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<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}.`,
|
||
}),
|
||
};
|
||
|
||
// ---- 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<K> } = {
|
||
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<K> } = {
|
||
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<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,
|
||
...trialDe,
|
||
};
|
||
|
||
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,
|
||
...trialEn,
|
||
};
|
||
|
||
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);
|
||
}
|