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 { prisma } from "@/server/db";
|
||||||
import { getMailConfig, mailFrom, type MailConfig } from "./config";
|
import { getMailConfig, mailFrom, type MailConfig } from "./config";
|
||||||
import { getMailProvider } from "./provider-smtp";
|
import { getMailProvider } from "./provider-smtp";
|
||||||
import { TransientMailError } from "./provider";
|
import { TransientMailError, type MailProvider, type OutgoingAttachment } from "./provider";
|
||||||
import { renderTemplate } from "./templates";
|
import { renderTemplate } from "./templates";
|
||||||
import type { MailJob } from "./job";
|
import type { MailAttachmentRef, MailJob } from "./job";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEC1 — die eigentliche Zustellung.
|
* 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.
|
* Rendert und versendet einen Job und schreibt das Ergebnis ins MailLog.
|
||||||
* Wirft bei temporären Fehlern `TransientMailError` (→ Retry durch den Worker).
|
* 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 { config, reason } = getMailConfig();
|
||||||
const provider = getMailProvider();
|
const provider = options.provider ?? getMailProvider();
|
||||||
if (!config || !provider) {
|
if (!config || !provider) {
|
||||||
// Kein stiller Fehlversand: die Zeile bleibt `pending`, der Grund steht dran.
|
// Kein stiller Fehlversand: die Zeile bleibt `pending`, der Grund steht dran.
|
||||||
await prisma.mailLog.update({
|
await prisma.mailLog.update({
|
||||||
@@ -45,6 +68,8 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
|
|||||||
const sender = await tenantSender(job.mailLogId, config);
|
const sender = await tenantSender(job.mailLogId, config);
|
||||||
|
|
||||||
try {
|
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({
|
const { messageId } = await provider.send({
|
||||||
from: sender.from,
|
from: sender.from,
|
||||||
to: job.to,
|
to: job.to,
|
||||||
@@ -54,6 +79,7 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
|
|||||||
text: rendered.text,
|
text: rendered.text,
|
||||||
// Auto-Antworten und Abwesenheitsnotizen unterdrücken (RFC 3834).
|
// Auto-Antworten und Abwesenheitsnotizen unterdrücken (RFC 3834).
|
||||||
headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" },
|
headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" },
|
||||||
|
...(attachments ? { attachments } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.mailLog.update({
|
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). */
|
/** Endgültiges Scheitern nach Ausschöpfung aller Versuche (Dead-Letter). */
|
||||||
export async function markMailFailed(mailLogId: string, error: string): Promise<void> {
|
export async function markMailFailed(mailLogId: string, error: string): Promise<void> {
|
||||||
await prisma.mailLog.update({
|
await prisma.mailLog.update({
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ export type MailJob = {
|
|||||||
to: string;
|
to: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
vars: TemplateVars[K];
|
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];
|
}[TemplateKey];
|
||||||
|
|
||||||
|
/** L11 — Verweis auf ein gespeichertes Dokument des Mandanten der Mail. */
|
||||||
|
export type MailAttachmentRef = { documentId: string };
|
||||||
|
|
||||||
export const MAIL_QUEUE = "mail";
|
export const MAIL_QUEUE = "mail";
|
||||||
export const MAIL_DLQ = "mail-dead-letter";
|
export const MAIL_DLQ = "mail-dead-letter";
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export class SmtpMailProvider implements MailProvider {
|
|||||||
html: msg.html,
|
html: msg.html,
|
||||||
text: msg.text,
|
text: msg.text,
|
||||||
headers: msg.headers,
|
headers: msg.headers,
|
||||||
|
...(msg.attachments?.length
|
||||||
|
? { attachments: msg.attachments.map((a) => ({ filename: a.filename, content: a.content, contentType: a.contentType })) }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
return { messageId: info.messageId };
|
return { messageId: info.messageId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ export type OutgoingMail = {
|
|||||||
html: string;
|
html: string;
|
||||||
text: string;
|
text: string;
|
||||||
headers?: Record<string, 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 type SendResult = { messageId: string };
|
||||||
|
|
||||||
export interface MailProvider {
|
export interface MailProvider {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { prisma } from "@/server/db";
|
|||||||
import { deliverMail, MailNotConfiguredError } from "./deliver";
|
import { deliverMail, MailNotConfiguredError } from "./deliver";
|
||||||
import { getMailQueue, isQueueEnabled, isQueueReady } from "./queue";
|
import { getMailQueue, isQueueEnabled, isQueueReady } from "./queue";
|
||||||
import { normalizeLocale, type Locale, type TemplateKey, type TemplateVars } from "./templates";
|
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(...)`.
|
* 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.
|
* erzeugt eine eigene Mail), für Benachrichtigungen gesetzt.
|
||||||
*/
|
*/
|
||||||
dedupeKey?: string;
|
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 =
|
export type EnqueueResult =
|
||||||
@@ -48,6 +53,9 @@ export async function enqueueMail<K extends TemplateKey>(
|
|||||||
): Promise<EnqueueResult> {
|
): Promise<EnqueueResult> {
|
||||||
const locale: Locale = normalizeLocale(input.locale);
|
const locale: Locale = normalizeLocale(input.locale);
|
||||||
const to = input.to.trim().toLowerCase();
|
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;
|
let mailLogId: string;
|
||||||
try {
|
try {
|
||||||
@@ -72,7 +80,7 @@ export async function enqueueMail<K extends TemplateKey>(
|
|||||||
throw err;
|
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
|
// 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
|
// einem Redis-Ausfall fällt der Versand auf den Inline-Pfad zurück, statt die
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export type TemplateVars = {
|
|||||||
craftvia_notification: {
|
craftvia_notification: {
|
||||||
name: string; subject: string; body: string; actionUrl?: string; footer?: CraftviaFooter;
|
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. */
|
/** Why the recipient gets a Craftvia notification — controls the footer line. */
|
||||||
@@ -90,6 +94,9 @@ export const CRAFTVIA_TEMPLATE_KEYS = [
|
|||||||
"craftvia_notification",
|
"craftvia_notification",
|
||||||
] as const satisfies readonly TemplateKey[];
|
] 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. */
|
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
|
||||||
const FOOTER_NOTE: Record<Locale, string> = {
|
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.",
|
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> } = {
|
const de: { [K in TemplateKey]: Builder<K> } = {
|
||||||
invitation: (v) => ({
|
invitation: (v) => ({
|
||||||
subject: `Ihr Zugang zu ${BRAND.name}`,
|
subject: `Ihr Zugang zu ${BRAND.name}`,
|
||||||
@@ -338,6 +384,7 @@ const de: { [K in TemplateKey]: Builder<K> } = {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
...craftviaDe,
|
...craftviaDe,
|
||||||
|
...customerDe,
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: { [K in TemplateKey]: Builder<K> } = {
|
const en: { [K in TemplateKey]: Builder<K> } = {
|
||||||
@@ -417,6 +464,7 @@ const en: { [K in TemplateKey]: Builder<K> } = {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
...craftviaEn,
|
...craftviaEn,
|
||||||
|
...customerEn,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
|
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
|
||||||
|
|||||||
Reference in New Issue
Block a user