feat(benachrichtigung): E-Mail und Webhook bei Drift und Ablauf (#028)

Drift, ablaufende und abgelaufene Schluessel, unerreichbare Hosts und
geaenderte Host-Keys sollen auffallen, ohne dass jemand taeglich in die
Oberflaeche schaut. Versand per SMTP (Microsoft 365, STARTTLS) und per
Webhook mit HMAC-Signatur, eingestellt unter /einstellungen.

Die Anlaesse liest der Worker aus der Datenbank, statt sie im Abgleich
auszuloesen: der Abgleich-Ablauf bleibt unberuehrt, und ein verpasster
Lauf holt sich der naechste. Ein Versandprotokoll (Zustellung) mit
Anlass-Fingerabdruck verhindert Doppelmeldungen und haelt Fehlschlaege
fuer begrenzte Wiederholungen fest. Taeglich eine Zusammenfassung,
geaenderte Host-Keys im Fuenf-Minuten-Takt sofort.

Versand und Testnachricht laufen im Worker, damit der Webprozess keinen
Weg zu Mailserver oder Webhook-Ziel braucht. Passwort und Geheimnis
liegen nur verschluesselt in der Datenbank; die Seite zeigt "gesetzt".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:54:43 +02:00
co-authored by Claude Opus 5.5
parent 83bd9e0922
commit 8f5b31c0cb
26 changed files with 3243 additions and 12 deletions
+141
View File
@@ -0,0 +1,141 @@
import { z } from "@/lib/validierung";
import { ANLAESSE, webhookUrlPruefen, type AnlassArt } from "./regeln";
/**
* Form der Benachrichtigungs-Einstellungen (#028). Gespeichert als JSON in `Einstellung`
* unter EINSTELLUNG_BENACHRICHTIGUNG; Passwort und Webhook-Geheimnis liegen dort nur
* verschluesselt (`verschluesseln()` mit SSHKV_APP_SECRET).
*/
export const EINSTELLUNG_BENACHRICHTIGUNG = "benachrichtigungen";
/** STARTTLS ist der Weg von Microsoft 365 (Port 587); KEINE nur fuer interne Relays. */
export const SMTP_SICHERHEIT = ["STARTTLS", "TLS", "KEINE"] as const;
export type SmtpSicherheit = (typeof SMTP_SICHERHEIT)[number];
export type GespeicherteBenachrichtigung = {
email: {
aktiv: boolean;
server: string;
port: number;
sicherheit: SmtpSicherheit;
absender: string;
benutzer: string;
/** Chiffrat aus `verschluesseln()` oder null. */
passwort: string | null;
empfaenger: string[];
};
webhook: {
aktiv: boolean;
url: string;
httpErlaubt: boolean;
/** Chiffrat aus `verschluesseln()` oder null. */
geheimnis: string | null;
};
anlaesse: Record<AnlassArt, boolean>;
};
/** Vorbelegung fuer Microsoft 365 – alles aus, bis ein Admin es einschaltet. */
export const STANDARD_BENACHRICHTIGUNG: GespeicherteBenachrichtigung = {
email: {
aktiv: false,
server: "smtp.office365.com",
port: 587,
sicherheit: "STARTTLS",
absender: "",
benutzer: "",
passwort: null,
empfaenger: [],
},
webhook: { aktiv: false, url: "", httpErlaubt: false, geheimnis: null },
anlaesse: { drift: true, ablauf: true, abgelaufen: true, unerreichbar: true, hostkey: true },
};
/**
* Was die Oberflaeche sehen darf: statt der Geheimnisse nur, ob sie gesetzt sind. Auch das
* Chiffrat geht nicht in Client-Props – es waere mit SSHKV_APP_SECRET lesbar.
*/
export type BenachrichtigungAnsicht = {
email: Omit<GespeicherteBenachrichtigung["email"], "passwort"> & { passwortGesetzt: boolean };
webhook: Omit<GespeicherteBenachrichtigung["webhook"], "geheimnis"> & { geheimnisGesetzt: boolean };
anlaesse: Record<AnlassArt, boolean>;
};
export function alsAnsicht(gespeichert: GespeicherteBenachrichtigung): BenachrichtigungAnsicht {
const { passwort, ...email } = gespeichert.email;
const { geheimnis, ...webhook } = gespeichert.webhook;
return {
email: { ...email, passwortGesetzt: passwort !== null },
webhook: { ...webhook, geheimnisGesetzt: geheimnis !== null },
anlaesse: { ...gespeichert.anlaesse },
};
}
/** Liest gespeichertes JSON tolerant: fehlende Felder bekommen die Vorbelegung. */
export function gespeichertLesen(wert: unknown): GespeicherteBenachrichtigung {
const roh = (
typeof wert === "object" && wert !== null ? wert : {}
) as Partial<GespeicherteBenachrichtigung>;
const s = STANDARD_BENACHRICHTIGUNG;
return {
email: { ...s.email, ...(roh.email ?? {}) },
webhook: { ...s.webhook, ...(roh.webhook ?? {}) },
anlaesse: { ...s.anlaesse, ...(roh.anlaesse ?? {}) },
};
}
// ---------------------------------------------------------------- Formular
/** Empfaengerliste: Komma, Semikolon, Leerzeichen oder Zeilenumbruch trennen. */
export function empfaengerAus(text: string): string[] {
return [
...new Set(
text
.split(/[\s,;]+/)
.map((e) => e.trim())
.filter(Boolean),
),
];
}
const email = z.email({ error: "Keine gültige E-Mail-Adresse." });
export const benachrichtigungSchema = z
.object({
emailAktiv: z.boolean(),
emailServer: z.string().trim().max(253),
emailPort: z.coerce.number().int().min(1, "Port 1–65535.").max(65535, "Port 1–65535."),
emailSicherheit: z.enum(SMTP_SICHERHEIT),
emailAbsender: z.string().trim().max(254),
emailBenutzer: z.string().trim().max(254),
/** Leer = unveraendert lassen. */
emailPasswort: z.string().max(1024).optional(),
emailPasswortEntfernen: z.boolean(),
emailEmpfaenger: z.array(z.string()).max(50, "Höchstens 50 Empfänger."),
webhookAktiv: z.boolean(),
webhookUrl: z.string().trim().max(2048),
webhookHttpErlaubt: z.boolean(),
webhookGeheimnis: z.string().max(1024).optional(),
webhookGeheimnisEntfernen: z.boolean(),
anlaesse: z.array(z.enum(ANLAESSE)),
})
.superRefine((e, ctx) => {
const fehler = (pfad: string, message: string) => ctx.addIssue({ code: "custom", path: [pfad], message });
if (e.emailAktiv && !e.emailServer) fehler("emailServer", "Server angeben.");
if (e.emailServer && !/^[A-Za-z0-9.-]+$/.test(e.emailServer))
fehler("emailServer", "Nur Hostname oder IP.");
if (e.emailAktiv && !e.emailAbsender) fehler("emailAbsender", "Absender angeben.");
if (e.emailAbsender && !email.safeParse(e.emailAbsender).success)
fehler("emailAbsender", "Keine gültige E-Mail-Adresse.");
if (e.emailAktiv && e.emailEmpfaenger.length === 0)
fehler("emailEmpfaenger", "Mindestens ein Empfänger.");
const falsch = e.emailEmpfaenger.find((a) => !email.safeParse(a).success);
if (falsch) fehler("emailEmpfaenger", `„${falsch.slice(0, 80)}“ ist keine gültige E-Mail-Adresse.`);
if (e.webhookAktiv && !e.webhookUrl) fehler("webhookUrl", "Adresse angeben.");
if (e.webhookUrl) {
const meldung = webhookUrlPruefen(e.webhookUrl, e.webhookHttpErlaubt);
if (meldung) fehler("webhookUrl", meldung);
}
});
export type BenachrichtigungEingabe = z.input<typeof benachrichtigungSchema>;
export type BenachrichtigungDaten = z.output<typeof benachrichtigungSchema>;