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:
@@ -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>;
|
||||
Reference in New Issue
Block a user