Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

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:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+102
View File
@@ -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(/^\/+/, "")}`;
}
+91
View File
@@ -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) },
});
}
+294
View File
@@ -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;
}
+34
View File
@@ -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";
+221
View File
@@ -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);
}
+110
View File
@@ -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;
}
+37
View File
@@ -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";
}
}
+174
View File
@@ -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;
}
+115
View File
@@ -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 };
}
}
+276
View File
@@ -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);
}
+120
View File
@@ -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();
}