Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod";
|
||||
import { BRAND } from "@/lib/brand";
|
||||
|
||||
/**
|
||||
* SEC1 — Mail-Konfiguration aus Env/Secret-Store.
|
||||
*
|
||||
* Grundsatz aus dem Aufgabenpaket (§3): **kein stiller Fehlversand**. Fehlt die
|
||||
* SMTP-Konfiguration, wird der Versand deaktiviert — Jobs bleiben `pending`, es
|
||||
* gibt eine deutliche Warnung im Log, aber keinen Absturz und keinen scheinbar
|
||||
* erfolgreichen Versand.
|
||||
*
|
||||
* Namensgebung: die Variablen `SMTP_HOST/PORT/USER/PASSWORD/FROM` existieren
|
||||
* bereits im Repo (.env.example, docker-compose.coolify.yml) und bleiben führend.
|
||||
* Die im Aufgabenpaket genannten Aliasse `SMTP_PASS`, `MAIL_FROM`,
|
||||
* `MAIL_FROM_NAME`, `MAIL_REPLY_TO`, `APP_BASE_URL` werden zusätzlich akzeptiert,
|
||||
* damit beide Schreibweisen funktionieren.
|
||||
*/
|
||||
|
||||
const schema = z.object({
|
||||
host: z.string().min(1),
|
||||
port: z.coerce.number().int().min(1).max(65535),
|
||||
/** true = implizites TLS (465), false = STARTTLS (587) */
|
||||
secure: z.boolean(),
|
||||
user: z.string().optional(),
|
||||
pass: z.string().optional(),
|
||||
from: z.string().min(3),
|
||||
fromName: z.string().min(1),
|
||||
replyTo: z.string().optional(),
|
||||
baseUrl: z.string().url(),
|
||||
});
|
||||
|
||||
export type MailConfig = z.infer<typeof schema>;
|
||||
|
||||
const env = (...names: string[]): string | undefined => {
|
||||
for (const n of names) {
|
||||
const v = process.env[n];
|
||||
if (v != null && v.trim() !== "") return v.trim();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function parseSecure(port: number): boolean {
|
||||
const raw = env("SMTP_SECURE");
|
||||
if (raw != null) return raw.toLowerCase() === "true" || raw === "1";
|
||||
// Ohne explizite Angabe: 465 = implizites TLS, sonst STARTTLS.
|
||||
return port === 465;
|
||||
}
|
||||
|
||||
let cached: { config: MailConfig | null; reason?: string } | null = null;
|
||||
|
||||
/**
|
||||
* Liefert die Mail-Konfiguration oder `null`, wenn sie unvollständig ist.
|
||||
* Das Ergebnis wird gecacht (Env ändert sich zur Laufzeit nicht).
|
||||
*/
|
||||
export function getMailConfig(): { config: MailConfig | null; reason?: string } {
|
||||
if (cached) return cached;
|
||||
|
||||
const host = env("SMTP_HOST");
|
||||
const portRaw = env("SMTP_PORT");
|
||||
const port = portRaw ? Number(portRaw) : undefined;
|
||||
|
||||
const candidate = {
|
||||
host,
|
||||
port,
|
||||
secure: port != null && Number.isFinite(port) ? parseSecure(port) : false,
|
||||
user: env("SMTP_USER"),
|
||||
pass: env("SMTP_PASSWORD", "SMTP_PASS"),
|
||||
from: env("MAIL_FROM", "SMTP_FROM"),
|
||||
fromName: env("MAIL_FROM_NAME") ?? BRAND.name,
|
||||
replyTo: env("MAIL_REPLY_TO"),
|
||||
baseUrl: env("APP_BASE_URL", "AUTH_URL", "NEXTAUTH_URL"),
|
||||
};
|
||||
|
||||
const parsed = schema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
const missing = parsed.error.issues.map((i) => i.path.join(".")).join(", ");
|
||||
cached = {
|
||||
config: null,
|
||||
reason: `Mail-Versand deaktiviert — unvollständige SMTP-Konfiguration (${missing}). Erwartet: SMTP_HOST, SMTP_PORT, SMTP_FROM/MAIL_FROM, APP_BASE_URL/AUTH_URL.`,
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
cached = { config: parsed.data };
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Nur für Tests: gecachte Konfiguration verwerfen. */
|
||||
export function resetMailConfigCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** Absenderzeile `Certvia <no-reply@certvia.de>`. */
|
||||
export function mailFrom(config: MailConfig): string {
|
||||
return `${config.fromName} <${config.from}>`;
|
||||
}
|
||||
|
||||
/** Baut eine absolute URL auf Basis von APP_BASE_URL (Mails brauchen absolute Links). */
|
||||
export function absoluteUrl(path: string, config?: MailConfig | null): string {
|
||||
const base = (config ?? getMailConfig().config)?.baseUrl ?? "http://localhost:3000";
|
||||
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { getMailConfig, mailFrom } from "./config";
|
||||
import { getMailProvider } from "./provider-smtp";
|
||||
import { TransientMailError } from "./provider";
|
||||
import { renderTemplate } from "./templates";
|
||||
import type { MailJob } from "./job";
|
||||
|
||||
/**
|
||||
* SEC1 — die eigentliche Zustellung.
|
||||
*
|
||||
* Bewusst getrennt von Queue und Worker, damit derselbe Pfad sowohl vom
|
||||
* BullMQ-Worker als auch vom Inline-Fallback (Betrieb ohne Redis) verwendet wird
|
||||
* — es gibt genau eine Stelle, an der eine Mail rausgeht.
|
||||
*
|
||||
* MailLog-Schreibzugriffe laufen über den rohen `prisma`-Client: der Worker hat
|
||||
* keinen Mandantenkontext (kein Request, keine Session), und Plattform-Zeilen
|
||||
* haben ohnehin `tenantId = null`. Die Mandantenzuordnung steckt bereits in der
|
||||
* beim Einstellen angelegten Zeile — hier wird nur ihr Status fortgeschrieben.
|
||||
*/
|
||||
|
||||
export class MailNotConfiguredError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(reason);
|
||||
this.name = "MailNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }> {
|
||||
const { config, reason } = getMailConfig();
|
||||
const provider = getMailProvider();
|
||||
if (!config || !provider) {
|
||||
// Kein stiller Fehlversand: die Zeile bleibt `pending`, der Grund steht dran.
|
||||
await prisma.mailLog.update({
|
||||
where: { id: job.mailLogId },
|
||||
data: { error: reason ?? "SMTP nicht konfiguriert" },
|
||||
});
|
||||
throw new MailNotConfiguredError(reason ?? "SMTP nicht konfiguriert");
|
||||
}
|
||||
|
||||
const rendered = renderTemplate(job.template, job.locale, job.vars);
|
||||
|
||||
try {
|
||||
const { messageId } = await provider.send({
|
||||
from: mailFrom(config),
|
||||
to: job.to,
|
||||
replyTo: config.replyTo,
|
||||
subject: rendered.subject,
|
||||
html: rendered.html,
|
||||
text: rendered.text,
|
||||
// Auto-Antworten und Abwesenheitsnotizen unterdrücken (RFC 3834).
|
||||
headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" },
|
||||
});
|
||||
|
||||
await prisma.mailLog.update({
|
||||
where: { id: job.mailLogId },
|
||||
data: {
|
||||
status: "sent",
|
||||
providerMessageId: messageId,
|
||||
sentAt: new Date(),
|
||||
error: null,
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
return { messageId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const transient = err instanceof TransientMailError;
|
||||
await prisma.mailLog.update({
|
||||
where: { id: job.mailLogId },
|
||||
data: {
|
||||
// Temporär → bleibt `pending`, der Worker versucht es erneut.
|
||||
status: transient ? "pending" : "failed",
|
||||
error: message.slice(0, 500),
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Endgültiges Scheitern nach Ausschöpfung aller Versuche (Dead-Letter). */
|
||||
export async function markMailFailed(mailLogId: string, error: string): Promise<void> {
|
||||
await prisma.mailLog.update({
|
||||
where: { id: mailLogId },
|
||||
data: { status: "failed", error: error.slice(0, 500) },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { absoluteUrl } from "./config";
|
||||
import { enqueueMail } from "./service";
|
||||
import { normalizeLocale, type Locale } from "./templates";
|
||||
import {
|
||||
openReportDeadlines,
|
||||
type DeadlineKind,
|
||||
} from "@/lib/incident-deadlines";
|
||||
|
||||
/**
|
||||
* Vorfall-Benachrichtigungen (Fachkonzept §7, SEC1-Infrastruktur) — IM-B.
|
||||
*
|
||||
* Modelliert nach `notifications.ts` (Aufgaben), gleiche Zusagen:
|
||||
* - **Opt-in per Default** (fehlt eine `NotificationPreference`, wird versendet).
|
||||
* - **Mandantenisolation** — Empfänger immer innerhalb des auslösenden Mandanten.
|
||||
* - **Sprache je Empfänger:** Präferenz → Mandanten-Locale → `de`.
|
||||
* - **Kein Selbstversand** — der Auslöser bekommt keine Mail über die eigene Aktion.
|
||||
* - **Idempotenz** über `dedupeKey`; der Fristen-Job hängt das Datum an.
|
||||
*
|
||||
* „Fire and forget": Fehler werden geloggt, nie an die Fachaktion durchgereicht.
|
||||
*/
|
||||
|
||||
export type IncidentEvent =
|
||||
| "incident_created"
|
||||
| "incident_assigned"
|
||||
| "incident_status_changed"
|
||||
| "incident_report_due"
|
||||
| "incident_closed";
|
||||
|
||||
type Recipient = { id: string; email: string; name: string; locale: Locale };
|
||||
|
||||
async function resolveRecipient(
|
||||
tenantId: string,
|
||||
userId: string | null | undefined,
|
||||
event: IncidentEvent,
|
||||
): Promise<Recipient | null> {
|
||||
if (!userId) return null;
|
||||
|
||||
const [user, preference, settings] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: { id: userId, tenantId, status: "ACTIVE" },
|
||||
select: { id: true, email: true, name: true },
|
||||
}),
|
||||
prisma.notificationPreference.findUnique({
|
||||
where: { userId_eventType: { userId, eventType: event } },
|
||||
select: { email: true, locale: true },
|
||||
}),
|
||||
prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } }),
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
if (preference && !preference.email) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
locale: normalizeLocale(preference?.locale ?? settings?.locale),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs der ISB/Incident-Manager eines Mandanten = aktive Nutzer mit dem Recht
|
||||
* `incident:manage` (§1/§7). Streng mandantengebunden.
|
||||
*/
|
||||
export async function incidentManagerIds(tenantId: string): Promise<string[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status: "ACTIVE",
|
||||
userRoles: {
|
||||
some: { role: { rolePermissions: { some: { permission: { key: "incident:manage" } } } } },
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return users.map((u) => u.id);
|
||||
}
|
||||
|
||||
type TextBuilder = (title: string, refNo: string, detail?: string) => { subject: string; body: string };
|
||||
|
||||
const TEXTS: Record<IncidentEvent, Record<Locale, TextBuilder>> = {
|
||||
incident_created: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Neuer Vorfall gemeldet",
|
||||
body: `Ein neuer Vorfall „${title}" (${refNo}) wurde gemeldet und wartet auf Triage.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "New incident reported",
|
||||
body: `A new incident "${title}" (${refNo}) has been reported and awaits triage.`,
|
||||
}),
|
||||
},
|
||||
incident_assigned: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Vorfall zugewiesen",
|
||||
body: `Ihnen wurde der Vorfall „${title}" (${refNo}) zur Bearbeitung zugewiesen.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "Incident assigned",
|
||||
body: `The incident "${title}" (${refNo}) has been assigned to you.`,
|
||||
}),
|
||||
},
|
||||
incident_status_changed: {
|
||||
de: (title, refNo, detail) => ({
|
||||
subject: "Statusänderung am Vorfall",
|
||||
body: `Der Vorfall „${title}" (${refNo}) hat einen neuen Stand: ${detail ?? "aktualisiert"}.`,
|
||||
}),
|
||||
en: (title, refNo, detail) => ({
|
||||
subject: "Incident status changed",
|
||||
body: `The incident "${title}" (${refNo}) has a new state: ${detail ?? "updated"}.`,
|
||||
}),
|
||||
},
|
||||
incident_report_due: {
|
||||
de: (title, refNo, detail) => ({
|
||||
subject: "Meldefrist für Vorfall",
|
||||
body: `Für den Vorfall „${title}" (${refNo}) ${detail ?? "steht eine Meldefrist an"}.`,
|
||||
}),
|
||||
en: (title, refNo, detail) => ({
|
||||
subject: "Reporting deadline for incident",
|
||||
body: `For the incident "${title}" (${refNo}) ${detail ?? "a reporting deadline is due"}.`,
|
||||
}),
|
||||
},
|
||||
incident_closed: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Vorfall abgeschlossen",
|
||||
body: `Der Vorfall „${title}" (${refNo}) wurde abgeschlossen.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "Incident closed",
|
||||
body: `The incident "${title}" (${refNo}) has been closed.`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Versendet eine Vorfall-Benachrichtigung an eine Menge von Empfängern. Der
|
||||
* Auslöser (`actorId`) wird ausgenommen, Empfänger werden dedupliziert und je
|
||||
* Sprache/Präferenz aufgelöst.
|
||||
*/
|
||||
export async function notifyIncidentEvent(input: {
|
||||
tenantId: string;
|
||||
event: IncidentEvent;
|
||||
incidentId: string;
|
||||
refNo: string;
|
||||
title: string;
|
||||
recipientIds: (string | null | undefined)[];
|
||||
actorId?: string | null;
|
||||
detail?: string;
|
||||
/** Ergänzt den Idempotenzschlüssel (der Fristen-Job hängt Frist-Art + Datum an). */
|
||||
dedupeSuffix?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const ids = [...new Set(input.recipientIds.filter((x): x is string => !!x))].filter(
|
||||
(id) => id !== input.actorId,
|
||||
);
|
||||
|
||||
for (const id of ids) {
|
||||
const recipient = await resolveRecipient(input.tenantId, id, input.event);
|
||||
if (!recipient) continue;
|
||||
|
||||
const text = TEXTS[input.event][recipient.locale](input.title, input.refNo, input.detail);
|
||||
await enqueueMail({
|
||||
template: "incident_notification",
|
||||
to: recipient.email,
|
||||
tenantId: input.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `${input.event}:${input.incidentId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/incidents?detail=${input.incidentId}`),
|
||||
refNo: input.refNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[mail] Vorfall-Benachrichtigung fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const DEADLINE_LABEL: Record<DeadlineKind, Record<Locale, string>> = {
|
||||
erstmeldung: { de: "Die NIS2-Erstmeldung (24 h)", en: "The NIS2 initial report (24 h)" },
|
||||
folgemeldung: { de: "Die NIS2-Folgemeldung (72 h)", en: "The NIS2 follow-up report (72 h)" },
|
||||
abschluss: { de: "Der NIS2-Abschlussbericht (1 Monat)", en: "The NIS2 final report (1 month)" },
|
||||
dsgvo: { de: "Die DSGVO-Meldung (Art. 33, 72 h)", en: "The GDPR notification (Art. 33, 72 h)" },
|
||||
reaction: { de: "Die interne Reaktionsfrist", en: "The internal reaction SLA" },
|
||||
resolution: { de: "Die interne Behebungsfrist", en: "The internal resolution SLA" },
|
||||
};
|
||||
|
||||
function deadlineDetail(kind: DeadlineKind, overdue: boolean, remainingMs: number, locale: Locale): string {
|
||||
const label = DEADLINE_LABEL[kind][locale];
|
||||
if (overdue) {
|
||||
const days = Math.max(0, Math.floor(-remainingMs / 86_400_000));
|
||||
return locale === "en"
|
||||
? `${label} is overdue${days > 0 ? ` by ${days} day(s)` : ""}`
|
||||
: `${label} ist überfällig${days > 0 ? ` (seit ${days} Tag(en))` : ""}`;
|
||||
}
|
||||
const hours = Math.max(0, Math.ceil(remainingMs / 3_600_000));
|
||||
return locale === "en"
|
||||
? `${label} is due within ${hours} h`
|
||||
: `${label} läuft in ${hours} h ab`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fristen-Erinnerung/Eskalation für Meldepflichten (§6/§7). Läuft täglich im
|
||||
* Reminder-Worker (mandantenübergreifend über den rohen Client; die Zuordnung
|
||||
* kommt aus dem Vorfall). Pro Vorfall, Frist-Art und Tag höchstens eine Mail.
|
||||
*
|
||||
* Empfänger: drohend → owner/assignee (bzw. Manager, falls unbesetzt);
|
||||
* überfällig → zusätzlich alle Incident-Manager (Eskalation).
|
||||
*/
|
||||
export async function sendIncidentDeadlineReminders(now: Date = new Date()): Promise<number> {
|
||||
const incidents = await prisma.incident.findMany({
|
||||
where: {
|
||||
status: { not: "abgeschlossen" },
|
||||
reportStatus: { not: "abschluss" },
|
||||
OR: [
|
||||
{ erstmeldungDueAt: { not: null } },
|
||||
{ folgemeldungDueAt: { not: null } },
|
||||
{ abschlussDueAt: { not: null } },
|
||||
{ dsgvoDueAt: { not: null } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
refNo: true,
|
||||
title: true,
|
||||
severity: true,
|
||||
reportStatus: true,
|
||||
ownerId: true,
|
||||
assigneeId: true,
|
||||
createdBy: true,
|
||||
detectedAt: true,
|
||||
reportedAt: true,
|
||||
occurredAt: true,
|
||||
createdAt: true,
|
||||
erstmeldungDueAt: true,
|
||||
folgemeldungDueAt: true,
|
||||
abschlussDueAt: true,
|
||||
dsgvoDueAt: true,
|
||||
},
|
||||
take: 500,
|
||||
});
|
||||
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
let sent = 0;
|
||||
const managerCache = new Map<string, string[]>();
|
||||
|
||||
for (const inc of incidents) {
|
||||
const due = openReportDeadlines(inc, now).filter((d) => d.overdue || d.dueSoon);
|
||||
if (due.length === 0) continue;
|
||||
|
||||
for (const item of due) {
|
||||
// Basis-Empfänger: Bearbeiter/Owner; unbesetzt oder überfällig → Manager.
|
||||
const recipients = new Set<string>();
|
||||
if (inc.ownerId) recipients.add(inc.ownerId);
|
||||
if (inc.assigneeId) recipients.add(inc.assigneeId);
|
||||
if (item.overdue || recipients.size === 0) {
|
||||
if (!managerCache.has(inc.tenantId)) {
|
||||
managerCache.set(inc.tenantId, await incidentManagerIds(inc.tenantId));
|
||||
}
|
||||
managerCache.get(inc.tenantId)!.forEach((id) => recipients.add(id));
|
||||
}
|
||||
if (recipients.size === 0) continue;
|
||||
|
||||
// Sprache je Empfänger wird in notifyIncidentEvent aufgelöst; der Detailtext
|
||||
// wird pro Empfänger benötigt → hier einmal in beiden Sprachen vorbereiten.
|
||||
for (const id of recipients) {
|
||||
const recipient = await resolveRecipient(inc.tenantId, id, "incident_report_due");
|
||||
if (!recipient) continue;
|
||||
const detail = deadlineDetail(item.kind, item.overdue, item.remainingMs, recipient.locale);
|
||||
const text = TEXTS.incident_report_due[recipient.locale](inc.title, inc.refNo, detail);
|
||||
const result = await enqueueMail({
|
||||
template: "incident_notification",
|
||||
to: recipient.email,
|
||||
tenantId: inc.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `incident_report_due:${inc.id}:${recipient.id}:${item.kind}:${day}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/incidents?detail=${inc.id}`),
|
||||
refNo: inc.refNo,
|
||||
},
|
||||
});
|
||||
if (result.status !== "duplicate") sent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Locale, TemplateKey, TemplateVars } from "./templates";
|
||||
|
||||
/**
|
||||
* SEC1 — Nutzlast eines Mail-Jobs.
|
||||
*
|
||||
* Diskriminierte Union über den Template-Key: der Compiler erzwingt, dass die
|
||||
* Variablen zum Template passen — auch über die Queue-Grenze hinweg, wo sonst
|
||||
* nur noch JSON läge.
|
||||
*
|
||||
* Bewusst NICHT enthalten: Klartext-Tokens. Aufrufer übergeben fertige
|
||||
* `actionUrl`s; die Nutzlast landet in Redis und darf keine Secrets führen, die
|
||||
* über die ohnehin im Link stehende URL hinausgehen.
|
||||
*/
|
||||
export type MailJob = {
|
||||
[K in TemplateKey]: {
|
||||
mailLogId: string;
|
||||
template: K;
|
||||
to: string;
|
||||
locale: Locale;
|
||||
vars: TemplateVars[K];
|
||||
};
|
||||
}[TemplateKey];
|
||||
|
||||
export const MAIL_QUEUE = "mail";
|
||||
export const MAIL_DLQ = "mail-dead-letter";
|
||||
/**
|
||||
* Eigene Queue für zeitgesteuerte Jobs. Bewusst getrennt von `mail`: ein BullMQ-
|
||||
* Worker konsumiert **alle** Jobs seiner Queue unabhängig vom Job-Namen — lägen
|
||||
* beide auf `mail`, könnte der Zustell-Worker den Fristen-Job abgreifen (und
|
||||
* umgekehrt).
|
||||
*/
|
||||
export const SCHEDULER_QUEUE = "mail-scheduler";
|
||||
/** Wiederkehrender Job: fällige/überfällige Aufgaben erinnern. */
|
||||
export const DUE_REMINDER_JOB = "task-due-reminder";
|
||||
@@ -0,0 +1,221 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { absoluteUrl } from "./config";
|
||||
import { enqueueMail } from "./service";
|
||||
import { formatWhen, normalizeLocale, type Locale } from "./templates";
|
||||
|
||||
/**
|
||||
* SEC1 §6 — Benachrichtigungen aus Aufgaben-Ereignissen.
|
||||
*
|
||||
* Regeln:
|
||||
* - **Opt-in per Default.** Fehlt eine `NotificationPreference`-Zeile, wird
|
||||
* versendet. Erst ein bewusstes `email = false` unterdrückt.
|
||||
* - **Mandantenisolation.** Empfänger wird immer innerhalb des auslösenden
|
||||
* Mandanten aufgelöst; ein Task verweist nie über die Mandantengrenze.
|
||||
* - **Sprache je Empfänger:** Präferenz → Mandanten-Locale → `de`.
|
||||
* - **Kein Selbstversand.** Wer die Aktion auslöst, bekommt keine Mail über
|
||||
* die eigene Handlung.
|
||||
* - **Idempotenz** über `dedupeKey`; der Fristen-Job nutzt zusätzlich das
|
||||
* Datum, damit pro Aufgabe und Tag höchstens eine Erinnerung rausgeht.
|
||||
*
|
||||
* Alle Funktionen sind „fire and forget": Fehler werden geloggt, aber nie an die
|
||||
* auslösende Fachaktion durchgereicht.
|
||||
*/
|
||||
|
||||
export type NotificationEvent =
|
||||
| "task_assigned"
|
||||
| "task_approval_requested"
|
||||
| "task_decided"
|
||||
| "task_due";
|
||||
|
||||
type Recipient = { id: string; email: string; name: string; locale: Locale };
|
||||
|
||||
/**
|
||||
* Löst den Empfänger inklusive Sprache auf und berücksichtigt seine Präferenz.
|
||||
* Gibt `null` zurück, wenn nicht versendet werden soll (kein Konto, inaktiv,
|
||||
* abbestellt).
|
||||
*/
|
||||
async function resolveRecipient(
|
||||
tenantId: string,
|
||||
userId: string | null | undefined,
|
||||
event: NotificationEvent,
|
||||
): Promise<Recipient | null> {
|
||||
if (!userId) return null;
|
||||
|
||||
const [user, preference, settings] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: { id: userId, tenantId, status: "ACTIVE" },
|
||||
select: { id: true, email: true, name: true },
|
||||
}),
|
||||
prisma.notificationPreference.findUnique({
|
||||
where: { userId_eventType: { userId, eventType: event } },
|
||||
select: { email: true, locale: true },
|
||||
}),
|
||||
prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } }),
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
// Default opt-in: nur ein ausdrückliches false unterdrückt.
|
||||
if (preference && !preference.email) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
locale: normalizeLocale(preference?.locale ?? settings?.locale),
|
||||
};
|
||||
}
|
||||
|
||||
const TEXTS: Record<
|
||||
NotificationEvent,
|
||||
Record<Locale, (title: string, extra?: string) => { subject: string; body: string }>
|
||||
> = {
|
||||
task_assigned: {
|
||||
de: (title) => ({
|
||||
subject: "Neue Aufgabe für Sie",
|
||||
body: `Ihnen wurde die Aufgabe „${title}" zugewiesen.`,
|
||||
}),
|
||||
en: (title) => ({
|
||||
subject: "A new task for you",
|
||||
body: `The task "${title}" has been assigned to you.`,
|
||||
}),
|
||||
},
|
||||
task_approval_requested: {
|
||||
de: (title) => ({
|
||||
subject: "Freigabe angefragt",
|
||||
body: `Sie wurden um die Freigabe von „${title}" gebeten.`,
|
||||
}),
|
||||
en: (title) => ({
|
||||
subject: "Approval requested",
|
||||
body: `You have been asked to approve "${title}".`,
|
||||
}),
|
||||
},
|
||||
task_decided: {
|
||||
de: (title, extra) => ({
|
||||
subject: "Entscheidung zu Ihrer Freigabe-Anfrage",
|
||||
body: `Zu „${title}" liegt eine Entscheidung vor: ${extra ?? "bearbeitet"}.`,
|
||||
}),
|
||||
en: (title, extra) => ({
|
||||
subject: "Decision on your approval request",
|
||||
body: `A decision has been made on "${title}": ${extra ?? "processed"}.`,
|
||||
}),
|
||||
},
|
||||
task_due: {
|
||||
de: (title, extra) => ({
|
||||
subject: "Aufgabe fällig",
|
||||
body: `Die Aufgabe „${title}" ist ${extra ?? "fällig"}.`,
|
||||
}),
|
||||
en: (title, extra) => ({
|
||||
subject: "Task due",
|
||||
body: `The task "${title}" is ${extra ?? "due"}.`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Versendet eine Aufgaben-Benachrichtigung. Wird aus den Task-Actions heraus
|
||||
* aufgerufen und darf diese nie scheitern lassen.
|
||||
*/
|
||||
export async function notifyTaskEvent(input: {
|
||||
tenantId: string;
|
||||
event: NotificationEvent;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
taskType: string;
|
||||
recipientId: string | null | undefined;
|
||||
/** Auslöser — bekommt keine Mail über die eigene Handlung. */
|
||||
actorId?: string | null;
|
||||
/** Zusatz, z. B. „freigegeben" / „abgelehnt" oder „seit 3 Tagen überfällig". */
|
||||
detail?: string;
|
||||
/** Überschreibt den Idempotenzschlüssel (Fristen-Job hängt das Datum an). */
|
||||
dedupeSuffix?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (input.recipientId && input.actorId && input.recipientId === input.actorId) return;
|
||||
|
||||
const recipient = await resolveRecipient(input.tenantId, input.recipientId, input.event);
|
||||
if (!recipient) return;
|
||||
|
||||
const text = TEXTS[input.event][recipient.locale](input.taskTitle, input.detail);
|
||||
|
||||
await enqueueMail({
|
||||
template: "notification",
|
||||
to: recipient.email,
|
||||
tenantId: input.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `${input.event}:${input.taskId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/tasks?detail=${input.taskId}`),
|
||||
taskType: input.taskType,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Eine misslungene Benachrichtigung darf die Fachaktion nicht kippen.
|
||||
console.error("[mail] Benachrichtigung fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fristen-Erinnerung (SEC1 §6): einmal täglich für offene Aufgaben, die heute
|
||||
* fällig sind oder es bereits waren.
|
||||
*
|
||||
* Doppelversand ist über den `dedupeKey` inklusive Datum ausgeschlossen: pro
|
||||
* Aufgabe, Empfänger und Tag entsteht höchstens eine Mail — auch wenn der Job
|
||||
* (z. B. nach einem Neustart) mehrfach läuft.
|
||||
*
|
||||
* Läuft mandantenübergreifend über den rohen Client, weil es keinen
|
||||
* Request-/Session-Kontext gibt; die Mandantenzuordnung kommt aus der Aufgabe
|
||||
* selbst und wird an jede Mail durchgereicht.
|
||||
*/
|
||||
export async function sendDueReminders(now: Date = new Date()): Promise<number> {
|
||||
const endOfDay = new Date(now);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { status: "OPEN", dueDate: { not: null, lte: endOfDay }, assigneeId: { not: null } },
|
||||
select: { id: true, tenantId: true, title: true, type: true, assigneeId: true, dueDate: true },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const task of tasks) {
|
||||
const overdueDays = task.dueDate
|
||||
? Math.floor((now.getTime() - task.dueDate.getTime()) / 86_400_000)
|
||||
: 0;
|
||||
const detailDe = overdueDays > 0 ? `seit ${overdueDays} Tag(en) überfällig` : "heute fällig";
|
||||
const detailEn = overdueDays > 0 ? `overdue by ${overdueDays} day(s)` : "due today";
|
||||
|
||||
const recipient = await resolveRecipient(task.tenantId, task.assigneeId, "task_due");
|
||||
if (!recipient) continue;
|
||||
|
||||
const text = TEXTS.task_due[recipient.locale](
|
||||
task.title,
|
||||
recipient.locale === "en" ? detailEn : detailDe,
|
||||
);
|
||||
|
||||
const result = await enqueueMail({
|
||||
template: "notification",
|
||||
to: recipient.email,
|
||||
tenantId: task.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `task_due:${task.id}:${recipient.id}:${day}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/tasks?detail=${task.id}`),
|
||||
taskType: task.type,
|
||||
},
|
||||
});
|
||||
if (result.status !== "duplicate") sent++;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Zeitstempel für Transaktionsmails (Passwort/MFA/E-Mail-Änderung). */
|
||||
export function nowFor(locale: Locale): string {
|
||||
return formatWhen(new Date(), locale);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { getMailConfig, type MailConfig } from "./config";
|
||||
import { TransientMailError, type MailProvider, type OutgoingMail, type SendResult } from "./provider";
|
||||
|
||||
/**
|
||||
* SEC1 — SMTP-Transport auf Basis von nodemailer.
|
||||
*
|
||||
* Verbindungs-Pool: der Transporter wird einmal erzeugt und wiederverwendet.
|
||||
* TLS ist Pflicht — bei Port 465 implizit, sonst `requireTLS` (STARTTLS). Die
|
||||
* Zertifikatsprüfung bleibt aktiv; sie wird nur für `localhost` gelockert, weil
|
||||
* Mailpit/Mailhog in der Entwicklung ein selbstsigniertes Zertifikat verwenden
|
||||
* bzw. gar kein TLS anbieten.
|
||||
*/
|
||||
|
||||
/** SMTP-Antwortcodes 4xx sind temporär (Greylisting, Ratelimit) → Retry sinnvoll. */
|
||||
function isTransient(err: unknown): boolean {
|
||||
const e = err as { responseCode?: number; code?: string } | null;
|
||||
if (!e) return false;
|
||||
if (typeof e.responseCode === "number") return e.responseCode >= 400 && e.responseCode < 500;
|
||||
return (
|
||||
e.code === "ETIMEDOUT" ||
|
||||
e.code === "ECONNRESET" ||
|
||||
e.code === "ECONNECTION" ||
|
||||
e.code === "ESOCKET" ||
|
||||
e.code === "EDNS" ||
|
||||
e.code === "EAI_AGAIN"
|
||||
);
|
||||
}
|
||||
|
||||
function createTransport(config: MailConfig): Transporter {
|
||||
const isLocal = /^(localhost|127\.0\.0\.1|::1|mailpit|mailhog)$/i.test(config.host);
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
// TLS erzwingen, außer gegen den lokalen Test-SMTP (Mailpit/Mailhog).
|
||||
requireTLS: !config.secure && !isLocal,
|
||||
auth: config.user ? { user: config.user, pass: config.pass ?? "" } : undefined,
|
||||
pool: true,
|
||||
maxConnections: 3,
|
||||
maxMessages: 100,
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
socketTimeout: 20_000,
|
||||
tls: { rejectUnauthorized: !isLocal },
|
||||
});
|
||||
}
|
||||
|
||||
export class SmtpMailProvider implements MailProvider {
|
||||
private transporter: Transporter | null = null;
|
||||
|
||||
constructor(private readonly config: MailConfig) {}
|
||||
|
||||
private get transport(): Transporter {
|
||||
if (!this.transporter) this.transporter = createTransport(this.config);
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
async send(msg: OutgoingMail): Promise<SendResult> {
|
||||
try {
|
||||
const info = await this.transport.sendMail({
|
||||
from: msg.from,
|
||||
to: msg.to,
|
||||
replyTo: msg.replyTo,
|
||||
subject: msg.subject,
|
||||
html: msg.html,
|
||||
text: msg.text,
|
||||
headers: msg.headers,
|
||||
});
|
||||
return { messageId: info.messageId };
|
||||
} catch (err) {
|
||||
if (isTransient(err)) {
|
||||
throw new TransientMailError(
|
||||
err instanceof Error ? err.message : "SMTP-Zustellung temporär fehlgeschlagen",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.transporter?.close();
|
||||
this.transporter = null;
|
||||
}
|
||||
|
||||
/** Verbindungstest ohne Versand (für den Admin-Testversand hilfreich). */
|
||||
async verify(): Promise<void> {
|
||||
await this.transport.verify();
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: SmtpMailProvider | null = null;
|
||||
|
||||
/**
|
||||
* Der konfigurierte Provider — oder `null`, wenn keine SMTP-Konfiguration
|
||||
* vorliegt. Aufrufer müssen den Null-Fall behandeln (kein stiller Fehlversand).
|
||||
*/
|
||||
export function getMailProvider(): SmtpMailProvider | null {
|
||||
const { config } = getMailConfig();
|
||||
if (!config) return null;
|
||||
if (!singleton) singleton = new SmtpMailProvider(config);
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/** Nur für Tests/Shutdown. */
|
||||
export async function closeMailProvider(): Promise<void> {
|
||||
await singleton?.close();
|
||||
singleton = null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* SEC1 — Provider-Schnittstelle für den Mailversand.
|
||||
*
|
||||
* Der Rest des Systems kennt nur dieses Interface. Heute steckt SMTP
|
||||
* (nodemailer) dahinter; ein späterer Wechsel auf eine HTTP-API (Postmark,
|
||||
* SES, …) tauscht nur die Implementierung.
|
||||
*/
|
||||
|
||||
export type OutgoingMail = {
|
||||
from: string;
|
||||
to: string;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type SendResult = { messageId: string };
|
||||
|
||||
export interface MailProvider {
|
||||
send(msg: OutgoingMail): Promise<SendResult>;
|
||||
/** Verbindungen sauber schließen (Worker-Shutdown). */
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehler, der einen erneuten Zustellversuch rechtfertigt (Netz, Timeout, 4xx).
|
||||
* Permanente Fehler (5xx, ungültige Adresse) werfen einen normalen Error und
|
||||
* werden vom Worker nicht wiederholt.
|
||||
*/
|
||||
export class TransientMailError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "TransientMailError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Queue } from "bullmq";
|
||||
import IORedis, { type Redis } from "ioredis";
|
||||
import { MAIL_DLQ, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
|
||||
|
||||
/**
|
||||
* SEC1 — Queue-Anbindung (BullMQ/Redis).
|
||||
*
|
||||
* **Betriebsmodus (Entscheidung, vgl. Aufgabenpaket §7):**
|
||||
* - Ist `REDIS_URL` gesetzt, laufen Mails asynchron über die BullMQ-Queue
|
||||
* `mail`; ein separater Worker-Prozess (`npm run worker:mail`) verarbeitet
|
||||
* sie. Das ist der Produktivmodus (eigener Container in Coolify).
|
||||
* - Ohne `REDIS_URL` gibt es **keine** Queue. Der Aufrufer versendet dann
|
||||
* inline (siehe `service.ts`). Das hält die lokale Entwicklung und die
|
||||
* Demo-Umgebung lauffähig, ohne Redis vorauszusetzen — Retry und
|
||||
* Dead-Letter entfallen in diesem Modus bewusst.
|
||||
*
|
||||
* Der Modus wird beim Start einmal geloggt, damit im Betrieb nie unklar ist,
|
||||
* welcher Pfad aktiv war.
|
||||
*/
|
||||
|
||||
let queue: Queue<MailJob> | null = null;
|
||||
let deadLetter: Queue<{ job: MailJob; error: string }> | null = null;
|
||||
let scheduler: Queue | null = null;
|
||||
let producerConnection: Redis | null = null;
|
||||
let workerConnection: Redis | null = null;
|
||||
let logged = false;
|
||||
|
||||
export function redisUrl(): string | undefined {
|
||||
const v = process.env.REDIS_URL?.trim();
|
||||
return v ? v : undefined;
|
||||
}
|
||||
|
||||
export function isQueueEnabled(): boolean {
|
||||
return redisUrl() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Producer-Verbindung gerade wirklich benutzbar?
|
||||
*
|
||||
* Wird vor dem Einstellen geprüft: steht Redis nicht, versendet der Aufrufer
|
||||
* inline weiter, statt die Mail zu verlieren. Die Prüfung erfolgt bewusst
|
||||
* **vor** dem `add()` — ein Fallback *nach* einem fehlgeschlagenen `add()`
|
||||
* könnte doppelt zustellen, falls der Job doch angekommen war und nur die
|
||||
* Bestätigung verloren ging.
|
||||
*/
|
||||
export function isQueueReady(): boolean {
|
||||
const conn = getProducerConnection();
|
||||
return conn?.status === "ready";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindung des **Producers** (App/Server-Actions): bewusst fail-fast.
|
||||
*
|
||||
* `enableOfflineQueue: false` lässt Kommandos sofort scheitern, solange keine
|
||||
* Verbindung steht — sonst würde `queue.add()` in einer Server-Action still
|
||||
* puffern und den Request hängen lassen, wenn Redis nicht erreichbar ist. Der
|
||||
* Aufrufer fängt den Fehler ab und vermerkt ihn im MailLog.
|
||||
*/
|
||||
function getProducerConnection(): Redis | null {
|
||||
const url = redisUrl();
|
||||
if (!url) return null;
|
||||
if (!producerConnection) {
|
||||
producerConnection = new IORedis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableReadyCheck: false,
|
||||
enableOfflineQueue: false,
|
||||
connectTimeout: 3_000,
|
||||
retryStrategy: (times) => Math.min(times * 500, 5_000),
|
||||
lazyConnect: false,
|
||||
});
|
||||
producerConnection.on("error", (err) => {
|
||||
console.error("[mail] Redis (Producer) nicht erreichbar:", err.message);
|
||||
});
|
||||
}
|
||||
return producerConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindung des **Workers**: robust statt fail-fast.
|
||||
*
|
||||
* BullMQ verlangt hier `maxRetriesPerRequest: null` (unbegrenzt), sonst brechen
|
||||
* die blockierenden Reads ab, mit denen der Worker auf neue Jobs wartet. Ein
|
||||
* kurzer Redis-Ausfall darf den Worker nicht beenden.
|
||||
*/
|
||||
export function getConnection(): Redis | null {
|
||||
const url = redisUrl();
|
||||
if (!url) return null;
|
||||
if (!workerConnection) {
|
||||
workerConnection = new IORedis(url, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
});
|
||||
workerConnection.on("error", (err) => {
|
||||
console.error("[mail] Redis (Worker) Verbindungsfehler:", err.message);
|
||||
});
|
||||
}
|
||||
return workerConnection;
|
||||
}
|
||||
|
||||
export function getMailQueue(): Queue<MailJob> | null {
|
||||
const conn = getProducerConnection();
|
||||
if (!conn) {
|
||||
if (!logged) {
|
||||
console.warn(
|
||||
"[mail] REDIS_URL nicht gesetzt — Mails werden inline versendet (kein Retry, keine Dead-Letter-Queue).",
|
||||
);
|
||||
logged = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!queue) {
|
||||
queue = new Queue<MailJob>(MAIL_QUEUE, {
|
||||
connection: conn,
|
||||
defaultJobOptions: {
|
||||
attempts: 5,
|
||||
backoff: { type: "exponential", delay: 30_000 },
|
||||
removeOnComplete: { age: 7 * 24 * 3600, count: 1000 },
|
||||
// Fehlgeschlagene behalten wir länger — für die Fehlersuche im Betrieb.
|
||||
removeOnFail: { age: 30 * 24 * 3600 },
|
||||
},
|
||||
});
|
||||
if (!logged) {
|
||||
console.info("[mail] Queue aktiv (BullMQ) — Zustellung asynchron über den Worker.");
|
||||
logged = true;
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
/** Dead-Letter-Queue: Jobs, die alle Versuche ausgeschöpft haben. */
|
||||
export function getDeadLetterQueue(): Queue<{ job: MailJob; error: string }> | null {
|
||||
// Wird nur vom Worker benutzt → robuste Verbindung.
|
||||
const conn = getConnection();
|
||||
if (!conn) return null;
|
||||
if (!deadLetter) {
|
||||
deadLetter = new Queue<{ job: MailJob; error: string }>(MAIL_DLQ, {
|
||||
connection: conn,
|
||||
defaultJobOptions: { removeOnComplete: false, removeOnFail: false },
|
||||
});
|
||||
}
|
||||
return deadLetter;
|
||||
}
|
||||
|
||||
/** Queue für zeitgesteuerte Jobs (Fristen-Erinnerung). */
|
||||
export function getSchedulerQueue(): Queue | null {
|
||||
const conn = getConnection();
|
||||
if (!conn) return null;
|
||||
if (!scheduler) {
|
||||
scheduler = new Queue(SCHEDULER_QUEUE, {
|
||||
connection: conn,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 60_000 },
|
||||
removeOnComplete: { count: 50 },
|
||||
removeOnFail: { count: 50 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
/** Verbindungen schließen (Worker-Shutdown, Tests). */
|
||||
export async function closeQueues(): Promise<void> {
|
||||
await queue?.close();
|
||||
await deadLetter?.close();
|
||||
await scheduler?.close();
|
||||
queue = null;
|
||||
deadLetter = null;
|
||||
scheduler = null;
|
||||
producerConnection?.disconnect();
|
||||
producerConnection = null;
|
||||
workerConnection?.disconnect();
|
||||
workerConnection = null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
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";
|
||||
|
||||
/**
|
||||
* SEC1 — Einstiegspunkt für alle Mails: `enqueueMail(...)`.
|
||||
*
|
||||
* Ablauf:
|
||||
* 1. `MailLog(pending)` anlegen. Der **unique** `dedupeKey` ist die Sperre
|
||||
* gegen Doppelversand: kollidiert der Insert, wurde die Mail bereits
|
||||
* eingestellt und wir brechen still ab (Idempotenz ohne Read-then-Write-
|
||||
* Rennen zwischen App-Instanzen).
|
||||
* 2. Job in die Queue stellen — oder, wenn keine Queue konfiguriert ist,
|
||||
* inline zustellen (siehe queue.ts zum Betriebsmodus).
|
||||
*
|
||||
* `enqueueMail` wirft nie nach außen: eine fehlgeschlagene Benachrichtigung darf
|
||||
* die auslösende Fachaktion (Aufgabe zuweisen, Passwort setzen) nicht scheitern
|
||||
* lassen. Der Fehler steht im MailLog und im Server-Log.
|
||||
*/
|
||||
|
||||
export type EnqueueInput<K extends TemplateKey = TemplateKey> = {
|
||||
template: K;
|
||||
to: string;
|
||||
vars: TemplateVars[K];
|
||||
/** `null` = Plattform-Mail ohne Mandantenbezug (scope=platform). */
|
||||
tenantId: string | null;
|
||||
locale?: string | null;
|
||||
/**
|
||||
* Idempotenzschlüssel, z. B. `task_assigned:<taskId>:<userId>`. Ohne Schlüssel
|
||||
* ist Mehrfachversand möglich — für Transaktionsmails gewollt (jede Anfrage
|
||||
* erzeugt eine eigene Mail), für Benachrichtigungen gesetzt.
|
||||
*/
|
||||
dedupeKey?: string;
|
||||
};
|
||||
|
||||
export type EnqueueResult =
|
||||
| { status: "queued"; mailLogId: string }
|
||||
| { status: "sent"; mailLogId: string }
|
||||
| { status: "duplicate" }
|
||||
| { status: "not_configured"; mailLogId: string; reason: string }
|
||||
| { status: "error"; mailLogId: string; error: string };
|
||||
|
||||
export async function enqueueMail<K extends TemplateKey>(
|
||||
input: EnqueueInput<K>,
|
||||
): Promise<EnqueueResult> {
|
||||
const locale: Locale = normalizeLocale(input.locale);
|
||||
const to = input.to.trim().toLowerCase();
|
||||
|
||||
let mailLogId: string;
|
||||
try {
|
||||
const row = await prisma.mailLog.create({
|
||||
data: {
|
||||
tenantId: input.tenantId,
|
||||
scope: input.tenantId ? "tenant" : "platform",
|
||||
to,
|
||||
template: input.template,
|
||||
locale,
|
||||
status: "pending",
|
||||
dedupeKey: input.dedupeKey,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
mailLogId = row.id;
|
||||
} catch (err) {
|
||||
// P2002 = Unique-Verletzung auf dedupeKey → bereits eingestellt.
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
return { status: "duplicate" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const job = { mailLogId, template: input.template, to, locale, vars: input.vars } 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
|
||||
// Mail zu verlieren (degradiert: ohne Retry/DLQ, aber zugestellt).
|
||||
if (isQueueEnabled() && isQueueReady()) {
|
||||
const queue = getMailQueue();
|
||||
if (queue) {
|
||||
try {
|
||||
await queue.add(input.template, job, { jobId: mailLogId });
|
||||
return { status: "queued", mailLogId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Bewusst KEIN Inline-Fallback an dieser Stelle: der Job könnte
|
||||
// angekommen sein und nur die Bestätigung verloren gegangen sein —
|
||||
// eine zweite Zustellung wäre dann ein Doppelversand.
|
||||
console.error("[mail] Job konnte nicht eingestellt werden:", message);
|
||||
await prisma.mailLog.update({
|
||||
where: { id: mailLogId },
|
||||
data: { error: message.slice(0, 500) },
|
||||
});
|
||||
return { status: "error", mailLogId, error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline-Modus (kein Redis konfiguriert oder gerade nicht erreichbar):
|
||||
// direkt zustellen, ohne die Fachaktion zu blockieren.
|
||||
try {
|
||||
await deliverMail(job);
|
||||
return { status: "sent", mailLogId };
|
||||
} catch (err) {
|
||||
if (err instanceof MailNotConfiguredError) {
|
||||
console.warn(`[mail] ${err.message}`);
|
||||
return { status: "not_configured", mailLogId, reason: err.message };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[mail] Zustellung fehlgeschlagen (${input.template}):`, message);
|
||||
return { status: "error", mailLogId, error: message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
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`
|
||||
* (Certvia-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;
|
||||
taskType: string;
|
||||
};
|
||||
incident_notification: {
|
||||
name: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
actionUrl?: string;
|
||||
refNo: string;
|
||||
};
|
||||
test: { name: string; when: string };
|
||||
};
|
||||
|
||||
export type TemplateKey = keyof TemplateVars;
|
||||
|
||||
export const TEMPLATE_KEYS = [
|
||||
"invitation",
|
||||
"password_reset",
|
||||
"password_changed",
|
||||
"email_change_verify",
|
||||
"email_changed_notice",
|
||||
"mfa_changed",
|
||||
"notification",
|
||||
"incident_notification",
|
||||
"test",
|
||||
] 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, weil Ihnen eine Aufgabe zugewiesen ist. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||
en: "You are receiving this notification because a task is assigned to you. You can change this in your profile.",
|
||||
};
|
||||
|
||||
/** Fußnote für Vorfall-Benachrichtigungen (§7). */
|
||||
const INCIDENT_FOOTER_NOTE: Record<Locale, string> = {
|
||||
de: "Sie erhalten diese Benachrichtigung, weil Sie am Vorfallmanagement beteiligt sind. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||
en: "You are receiving this notification because you are involved in incident management. You can change this in your profile.",
|
||||
};
|
||||
|
||||
type Builder<K extends TemplateKey> = (vars: TemplateVars[K]) => EmailContent;
|
||||
|
||||
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 Certvia öffnen", url: v.actionUrl } : undefined,
|
||||
footerNote: FOOTER_NOTE.de,
|
||||
}),
|
||||
incident_notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hallo ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: "Vorfall öffnen", url: v.actionUrl } : undefined,
|
||||
footerNote: INCIDENT_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.",
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
incident_notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hello ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: "Open incident", url: v.actionUrl } : undefined,
|
||||
footerNote: INCIDENT_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.",
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { UnrecoverableError, Worker, type Job } from "bullmq";
|
||||
import { deliverMail, markMailFailed, MailNotConfiguredError } from "./deliver";
|
||||
import { closeQueues, getConnection, getDeadLetterQueue, getSchedulerQueue } from "./queue";
|
||||
import { DUE_REMINDER_JOB, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
|
||||
import { sendDueReminders } from "./notifications";
|
||||
import { sendIncidentDeadlineReminders } from "./incident-notifications";
|
||||
import { closeMailProvider } from "./provider-smtp";
|
||||
|
||||
/**
|
||||
* SEC1 — Worker-Prozess: nimmt Mail-Jobs aus der Queue und stellt sie zu.
|
||||
*
|
||||
* Robustheit:
|
||||
* - **Retry** über die Queue-Defaults (5 Versuche, exponentielles Backoff).
|
||||
* - **Dead-Letter**: nach dem letzten Fehlversuch wandert der Job in die
|
||||
* `mail-dead-letter`-Queue und das MailLog wird auf `failed` gesetzt.
|
||||
* - **Limiter**: höchstens 20 Mails pro 10 Sekunden, Concurrency 5 — schützt
|
||||
* Reputation und Relay vor Lastspitzen.
|
||||
* - **Graceful Shutdown** auf SIGTERM/SIGINT: laufende Jobs werden beendet,
|
||||
* danach werden SMTP-Pool und Redis-Verbindung geschlossen.
|
||||
*
|
||||
* Fehlende SMTP-Konfiguration ist **kein** Retry-Grund: der Job würde beliebig
|
||||
* oft scheitern. Er wird einmal als Fehlschlag vermerkt und verworfen; das
|
||||
* MailLog bleibt `pending` mit Begründung (siehe deliver.ts).
|
||||
*/
|
||||
|
||||
export function startMailWorker(): Worker<MailJob> {
|
||||
const connection = getConnection();
|
||||
if (!connection) {
|
||||
throw new Error("REDIS_URL ist nicht gesetzt — ohne Redis gibt es keinen Worker-Betrieb.");
|
||||
}
|
||||
|
||||
const worker = new Worker<MailJob>(
|
||||
MAIL_QUEUE,
|
||||
async (job: Job<MailJob>) => {
|
||||
try {
|
||||
const { messageId } = await deliverMail(job.data);
|
||||
return { messageId };
|
||||
} catch (err) {
|
||||
if (err instanceof MailNotConfiguredError) {
|
||||
// Nicht wiederholen — die Konfiguration ändert sich nicht durch Warten.
|
||||
// BullMQ bricht die Retry-Kette bei UnrecoverableError sofort ab.
|
||||
throw new UnrecoverableError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 5,
|
||||
limiter: { max: 20, duration: 10_000 },
|
||||
},
|
||||
);
|
||||
|
||||
worker.on("failed", async (job, err) => {
|
||||
if (!job) return;
|
||||
const attemptsLeft = (job.opts.attempts ?? 1) - job.attemptsMade;
|
||||
console.error(
|
||||
`[mail] Job ${job.id} fehlgeschlagen (Versuch ${job.attemptsMade}, ${Math.max(0, attemptsLeft)} verbleibend): ${err.message}`,
|
||||
);
|
||||
// UnrecoverableError beendet die Retry-Kette sofort (fehlende Konfiguration).
|
||||
if (attemptsLeft > 0 && !(err instanceof UnrecoverableError)) return;
|
||||
|
||||
// Endgültig: Dead-Letter + MailLog auf failed.
|
||||
await markMailFailed(job.data.mailLogId, err.message).catch(() => {});
|
||||
await getDeadLetterQueue()
|
||||
?.add("dead", { job: job.data, error: err.message })
|
||||
.catch(() => {});
|
||||
console.error(`[mail] ALARM — Job ${job.id} in die Dead-Letter-Queue verschoben.`);
|
||||
});
|
||||
|
||||
worker.on("completed", (job) => {
|
||||
console.info(`[mail] Job ${job.id} zugestellt (${job.data.template} → ${job.data.to}).`);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert den täglichen Fristen-Job. `jobId` ist fix, damit mehrfaches
|
||||
* Starten des Workers keine parallelen Zeitpläne erzeugt.
|
||||
*/
|
||||
export async function scheduleDueReminders(): Promise<void> {
|
||||
const queue = getSchedulerQueue();
|
||||
if (!queue) return;
|
||||
// Fester Scheduler-Schlüssel: mehrfaches Starten des Workers erzeugt keine
|
||||
// parallelen Zeitpläne, der Eintrag wird nur aktualisiert.
|
||||
await queue.upsertJobScheduler(
|
||||
DUE_REMINDER_JOB,
|
||||
{ pattern: "0 7 * * *", tz: "Europe/Berlin" },
|
||||
{ name: DUE_REMINDER_JOB },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker der Scheduler-Queue. Eigene Queue, damit der Fristen-Job nicht mit der
|
||||
* Zustell-Concurrency konkurriert und nicht versehentlich vom Mail-Worker
|
||||
* konsumiert wird (ein BullMQ-Worker nimmt alle Jobs seiner Queue).
|
||||
*/
|
||||
export function startReminderWorker(): Worker | null {
|
||||
const connection = getConnection();
|
||||
if (!connection) return null;
|
||||
return new Worker(
|
||||
SCHEDULER_QUEUE,
|
||||
async (job: Job) => {
|
||||
if (job.name !== DUE_REMINDER_JOB) return;
|
||||
const count = await sendDueReminders();
|
||||
console.info(`[mail] Aufgaben-Fristen-Erinnerungen eingestellt: ${count}`);
|
||||
const incidentCount = await sendIncidentDeadlineReminders();
|
||||
console.info(`[mail] Vorfall-Meldefristen-Erinnerungen/Eskalationen eingestellt: ${incidentCount}`);
|
||||
},
|
||||
{ connection, concurrency: 1 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Sauberes Herunterfahren von Worker, SMTP-Pool und Redis. */
|
||||
export async function shutdownWorkers(workers: (Worker | null)[]): Promise<void> {
|
||||
for (const w of workers) await w?.close();
|
||||
await closeMailProvider();
|
||||
await closeQueues();
|
||||
}
|
||||
Reference in New Issue
Block a user