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
+84 -4
View File
@@ -1,9 +1,10 @@
import { createHash } from "node:crypto";
import { prisma } from "@/server/db";
import { getMailConfig, mailFrom, type MailConfig } from "./config";
import { getMailProvider } from "./provider-smtp";
import { TransientMailError } from "./provider";
import { TransientMailError, type MailProvider, type OutgoingAttachment } from "./provider";
import { renderTemplate } from "./templates";
import type { MailJob } from "./job";
import type { MailAttachmentRef, MailJob } from "./job";
/**
* SEC1 — die eigentliche Zustellung.
@@ -25,13 +26,35 @@ export class MailNotConfiguredError extends Error {
}
}
/** L11 — Anhang nicht zustellbar (fehlt, fremder Mandant, Prüfsumme, Größe). Permanent, kein Retry-Grund. */
export class MailAttachmentError extends Error {
constructor(message: string) {
super(message);
this.name = "MailAttachmentError";
}
}
/** L11 — Obergrenze aller Anhänge einer Mail (Summe), `MAIL_MAX_ATTACHMENT_BYTES`, Default 10 MB. */
export const DEFAULT_MAIL_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const MAX_ATTACHMENTS_PER_MAIL = 10;
export function mailMaxAttachmentBytes(): number {
const n = Number(process.env.MAIL_MAX_ATTACHMENT_BYTES);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_MAIL_MAX_ATTACHMENT_BYTES;
}
export type DeliverOptions = {
/** Nur für Tests/Sonderfälle: anderer Provider als der konfigurierte SMTP-Singleton. */
provider?: MailProvider;
};
/**
* Rendert und versendet einen Job und schreibt das Ergebnis ins MailLog.
* Wirft bei temporären Fehlern `TransientMailError` (→ Retry durch den Worker).
*/
export async function deliverMail(job: MailJob): Promise<{ messageId: string }> {
export async function deliverMail(job: MailJob, options: DeliverOptions = {}): Promise<{ messageId: string }> {
const { config, reason } = getMailConfig();
const provider = getMailProvider();
const provider = options.provider ?? getMailProvider();
if (!config || !provider) {
// Kein stiller Fehlversand: die Zeile bleibt `pending`, der Grund steht dran.
await prisma.mailLog.update({
@@ -45,6 +68,8 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
const sender = await tenantSender(job.mailLogId, config);
try {
// L11: Anhänge vor dem Versand auflösen – schlägt das fehl, geht KEINE Mail (auch ohne Anhang) raus.
const attachments = job.attachments?.length ? await loadMailAttachments(job.mailLogId, job.attachments) : undefined;
const { messageId } = await provider.send({
from: sender.from,
to: job.to,
@@ -54,6 +79,7 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
text: rendered.text,
// Auto-Antworten und Abwesenheitsnotizen unterdrücken (RFC 3834).
headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" },
...(attachments ? { attachments } : {}),
});
await prisma.mailLog.update({
@@ -83,6 +109,60 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
}
}
/**
* L11 — Dokument-Referenzen eines Jobs in Anhänge auflösen.
*
* Sicherheitsgrenzen (der Job kommt aus Redis und gilt als nicht vertrauenswürdig):
* - Mandant ausschließlich aus der MailLog-Zeile (nicht aus dem Job); Plattform-Mails → keine Anhänge.
* - Dokument nur mit `tenantId = MailLog.tenantId`, nicht soft-gelöscht, Storage-Key mit Mandanten-Präfix.
* - SHA-256 der gelesenen Bytes muss der gespeicherten Prüfsumme entsprechen.
* - Summe ≤ `MAIL_MAX_ATTACHMENT_BYTES`, höchstens 10 Anhänge.
* Owner-Client bewusst: der Worker hat keinen Request-/Mandantenkontext (wie tenantSender).
*/
export async function loadMailAttachments(mailLogId: string, refs: MailAttachmentRef[]): Promise<OutgoingAttachment[]> {
const log = await prisma.mailLog.findUnique({ where: { id: mailLogId }, select: { tenantId: true } });
const tenantId = log?.tenantId;
if (!tenantId) throw new MailAttachmentError("Anhänge sind nur für Mandanten-Mails zulässig.");
if (refs.length > MAX_ATTACHMENTS_PER_MAIL) throw new MailAttachmentError("Zu viele Anhänge.");
const max = mailMaxAttachmentBytes();
const { readStoredBytes } = await import("@/server/services/documents/read");
let total = 0;
const out: OutgoingAttachment[] = [];
for (const ref of refs) {
const documentId = typeof ref?.documentId === "string" ? ref.documentId : "";
const doc = documentId
? await prisma.document.findFirst({
where: { id: documentId, tenantId, deletedAt: null },
select: { storageKey: true, fileName: true, mimeType: true, checksum: true, fileSize: true },
})
: null;
if (!doc || !doc.storageKey.startsWith(`${tenantId}/`)) throw new MailAttachmentError("Anhang nicht verfügbar.");
if (total + doc.fileSize > max) throw new MailAttachmentError("Anhänge überschreiten die zulässige Größe.");
let bytes: Buffer | null;
try {
bytes = await readStoredBytes(doc.storageKey);
} catch (err) {
// Speicher kurz nicht erreichbar → erneuter Versuch sinnvoll.
throw new TransientMailError("Anhang konnte nicht gelesen werden.", { cause: err });
}
if (!bytes) throw new MailAttachmentError("Anhang nicht verfügbar.");
total += bytes.byteLength;
if (total > max) throw new MailAttachmentError("Anhänge überschreiten die zulässige Größe.");
if (createHash("sha256").update(bytes).digest("hex") !== doc.checksum) {
throw new MailAttachmentError("Prüfsumme des Anhangs stimmt nicht.");
}
out.push({ filename: safeAttachmentName(doc.fileName), content: bytes, contentType: doc.mimeType });
}
return out;
}
function safeAttachmentName(name: string): string {
const cleaned = name.replace(/[\r\n"\\/<>]/g, "_").trim().slice(0, 150);
return cleaned || "anhang";
}
/** Endgültiges Scheitern nach Ausschöpfung aller Versuche (Dead-Letter). */
export async function markMailFailed(mailLogId: string, error: string): Promise<void> {
await prisma.mailLog.update({
+8
View File
@@ -18,9 +18,17 @@ export type MailJob = {
to: string;
locale: Locale;
vars: TemplateVars[K];
/**
* L11 — Anhänge NUR als Referenz auf ein `Document` (nie Bytes in Redis). Aufgelöst wird beim
* Zustellen in deliver.ts, strikt im Mandanten der MailLog-Zeile und mit Prüfsummen-Vergleich.
*/
attachments?: MailAttachmentRef[];
};
}[TemplateKey];
/** L11 — Verweis auf ein gespeichertes Dokument des Mandanten der Mail. */
export type MailAttachmentRef = { documentId: string };
export const MAIL_QUEUE = "mail";
export const MAIL_DLQ = "mail-dead-letter";
/**
+3
View File
@@ -66,6 +66,9 @@ export class SmtpMailProvider implements MailProvider {
html: msg.html,
text: msg.text,
headers: msg.headers,
...(msg.attachments?.length
? { attachments: msg.attachments.map((a) => ({ filename: a.filename, content: a.content, contentType: a.contentType })) }
: {}),
});
return { messageId: info.messageId };
} catch (err) {
+4
View File
@@ -14,8 +14,12 @@ export type OutgoingMail = {
html: string;
text: string;
headers?: Record<string, string>;
/** L11 — bereits aufgelöste, geprüfte Anhänge (Bytes nur im Prozess, nie in der Queue). */
attachments?: OutgoingAttachment[];
};
export type OutgoingAttachment = { filename: string; content: Buffer; contentType: string };
export type SendResult = { messageId: string };
export interface MailProvider {
+10 -2
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/server/db";
import { deliverMail, MailNotConfiguredError } from "./deliver";
import { getMailQueue, isQueueEnabled, isQueueReady } from "./queue";
import { normalizeLocale, type Locale, type TemplateKey, type TemplateVars } from "./templates";
import type { MailJob } from "./job";
import type { MailAttachmentRef, MailJob } from "./job";
/**
* SEC1 — Einstiegspunkt für alle Mails: `enqueueMail(...)`.
@@ -34,6 +34,11 @@ export type EnqueueInput<K extends TemplateKey = TemplateKey> = {
* erzeugt eine eigene Mail), für Benachrichtigungen gesetzt.
*/
dedupeKey?: string;
/**
* L11 — Dokument-Referenzen als Anhang (nur mit `tenantId`; Plattform-Mails haben keine Anhänge).
* Es landen nur IDs in Redis; Bytes werden erst beim Zustellen geladen und geprüft.
*/
attachments?: MailAttachmentRef[];
};
export type EnqueueResult =
@@ -48,6 +53,9 @@ export async function enqueueMail<K extends TemplateKey>(
): Promise<EnqueueResult> {
const locale: Locale = normalizeLocale(input.locale);
const to = input.to.trim().toLowerCase();
const attachments = input.attachments?.length ? input.attachments.map((a) => ({ documentId: a.documentId })) : undefined;
// Programmierfehler, kein Zustellproblem: vor dem MailLog-Insert abweisen.
if (attachments && !input.tenantId) throw new Error("Mail-Anhänge sind nur für Mandanten-Mails zulässig.");
let mailLogId: string;
try {
@@ -72,7 +80,7 @@ export async function enqueueMail<K extends TemplateKey>(
throw err;
}
const job = { mailLogId, template: input.template, to, locale, vars: input.vars } as MailJob;
const job = { mailLogId, template: input.template, to, locale, vars: input.vars, ...(attachments ? { attachments } : {}) } as MailJob;
// Queue nur nutzen, wenn Redis konfiguriert UND gerade erreichbar ist. Bei
// einem Redis-Ausfall fällt der Versand auf den Inline-Pfad zurück, statt die
+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 };