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:
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user