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>
This commit is contained in:
2026-09-14 18:48:45 +02:00
co-authored by Claude Opus 5
parent bc58738129
commit e0106b8f8e
6 changed files with 157 additions and 6 deletions
+48
View File
@@ -62,6 +62,10 @@ export type TemplateVars = {
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. */
@@ -90,6 +94,9 @@ export const CRAFTVIA_TEMPLATE_KEYS = [
"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.",
@@ -261,6 +268,45 @@ const craftviaEn: { [K in CraftviaKey]: Builder<K> } = {
}),
};
// ---- 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}`,
@@ -338,6 +384,7 @@ const de: { [K in TemplateKey]: Builder<K> } = {
],
}),
...craftviaDe,
...customerDe,
};
const en: { [K in TemplateKey]: Builder<K> } = {
@@ -417,6 +464,7 @@ const en: { [K in TemplateKey]: Builder<K> } = {
],
}),
...craftviaEn,
...customerEn,
};
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };