feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender
Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine
Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und
Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war.

In keiner Nachricht steht ein Schrittinhalt, und damit auch kein
erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen
dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft
wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt
wurden, so wie #012 es fuer die Druckansicht tat.

Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der
Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index:
der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der
Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880
Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der
Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn
hinter einem Lastverteiler laufen zwei Instanzen.

Eine stille Warteschlange ist schlimmer als keine: Verwaltung →
Zustellungen zeigt mit Grund, was hinausging und was nicht.

Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine
abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem
Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-05 22:52:36 +02:00
co-authored by Claude Opus 5
parent 1be73b266c
commit a417426f43
36 changed files with 3573 additions and 130 deletions
+170
View File
@@ -0,0 +1,170 @@
# Benachrichtigungen einrichten
Laufzettel meldet vier Dinge, und nur diese vier:
| Anlass | Wer bekommt es |
|---|---|
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
| Ein Lauf ist **überfällig** | der Zugewiesene, **einmal** je Lauf |
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
| Ein **Verzeichnisabgleich schlägt fehl** | alle aktiven Administratoren |
**Niemand bekommt Post über das, was er selbst gerade getan hat.** Das ist der häufigste Grund,
Benachrichtigungen ganz abzuschalten, und deshalb fällt der Handelnde immer aus der Empfängerliste.
> **In keiner Nachricht steht ein Schrittinhalt.** Titel, Status, Gerät, Person, Frist und ein Link —
> mehr nicht. Kein Wert, keine Notiz, kein Befehl, und damit auch kein `{{generiertes_passwort}}`.
> Das Passwort entsteht bei jeder Anzeige neu und wird nirgends gespeichert; eine Mail dagegen liegt
> für immer in einem Postfach, oft auf einem Server, der nicht im Haus steht.
## Was gebraucht wird
- **`APP_URL`**, damit der Link in der Mail irgendwohin führt. Fehlt sie, gehen die Meldungen
trotzdem hinaus — die Einstellungsseite sagt es dann deutlich.
- **Ein SMTP-Server** für Mail, **oder** eine Webhook-Adresse, **oder** beides. Ohne eines von beiden
wird nichts eingereiht; es entstehen also keine Zeilen, die nie ankommen.
## Mail über SMTP
Unter **Admin → Einstellungen → Benachrichtigungen**:
| Feld | Was hineingehört |
|---|---|
| Host | `mail.firma.de` — der Mailserver des Hauses |
| Port | `587` für StartTLS, `465` für TLS von Anfang an, `25` nur im eigenen Netz |
| Benutzer | leer lassen, wenn der Server dem Rechner ohne Anmeldung traut |
| Passwort | wird verschlüsselt gespeichert und **nie wieder angezeigt** |
| Absender | `laufzettel@firma.de` — muss der Server akzeptieren |
| TLS verlangen | an, außer der Server kann es wirklich nicht |
Dann **Speichern**, dann **Testmail schicken**. Die Testmail geht **direkt** hinaus und nicht über
die Warteschlange: ein Fehler erscheint sofort und im Klartext, statt eine Minute später in einer
Liste.
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist** — das ist Absicht. Leer heißt
„unverändert lassen“. Sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
### Wenn die Testmail nicht ankommt
| Meldung | Ursache |
|---|---|
| `greeting never received` | `secure` passt nicht zum Port. Auf 587 gehört StartTLS, nicht TLS von Anfang an — Laufzettel entscheidet das selbst am Port, aber ein Server, der auf 465 StartTLS erwartet, ist falsch konfiguriert. |
| `535 Authentication failed` | Benutzer oder Passwort stimmen nicht. Manche Server wollen die vollständige Mailadresse als Benutzernamen. |
| `550 relay not permitted` | Der Server nimmt von dieser Maschine keine Mail für fremde Domains an. Entweder den Rechner freischalten oder sich anmelden. |
| `self signed certificate` | Der Mailserver hat ein eigenes Zertifikat. Entweder ein richtiges einrichten oder TLS für diesen Server abschalten — im eigenen Netz ist das zweite vertretbar. |
| `ECONNREFUSED` | Port falsch, oder eine Firewall dazwischen. |
## Webhook
Dieselben vier Anlässe als `POST` mit JSON-Rumpf:
```json
{
"anlass": "LAUF_ABGESCHLOSSEN",
"betreff": "Laufzettel abgeschlossen: „PC Neuinstallation – PC-001“",
"link": "https://laufzettel.firma.de/laeufe/cmu…",
"laufId": "cmu…",
"verzeichnisquelleId": null,
"zeitpunkt": "2026-10-05T08:00:00.000Z"
}
```
`anlass` steht vorn, damit sich ohne Auspacken filtern lässt. Auch hier: **keine Schrittinhalte.**
Als erfolgreich gilt nur eine Antwort mit `2xx`. Alles andere ist ein Fehlschlag und wird wiederholt.
### Die Signatur nachrechnen
Ist ein Geheimnis gesetzt, trägt jede Anfrage den Kopf:
```
X-Laufzettel-Signatur: t=1762334400,v1=9f86d08188...
```
Signiert wird `<t>.<rumpf>` mit HMAC-SHA256. **Der Zeitstempel ist Teil der signierten Nachricht**,
nicht Beiwerk — stünde er nur daneben, könnte ihn jeder ändern, ohne die Signatur zu brechen, und
eine abgefangene Nachricht ließe sich in einem Jahr noch einspielen.
So prüft die Gegenseite (Node, aber in jeder Sprache dasselbe):
```js
import { createHmac, timingSafeEqual } from "node:crypto";
function pruefen(geheimnis, rumpf, kopf) {
const teile = Object.fromEntries(kopf.split(",").map((s) => s.split("=")));
const alter = Math.abs(Math.floor(Date.now() / 1000) - Number(teile.t));
if (alter > 300) return false; // älter als fünf Minuten: ablehnen
const erwartet = createHmac("sha256", geheimnis).update(`${teile.t}.${rumpf}`).digest("hex");
// Zeitkonstant vergleichen, nicht mit ===
return timingSafeEqual(Buffer.from(erwartet), Buffer.from(teile.v1));
}
```
Drei Dinge, die dabei schiefgehen:
1. **Den Rumpf vorher geparst und wieder serialisiert.** Dann stimmt die Signatur nicht mehr —
`JSON.stringify` setzt Leerzeichen und Reihenfolge anders. Signiert wird der **rohe** Rumpf, also
vor jeder Verarbeitung lesen.
2. **Mit `===` verglichen.** Das bricht beim ersten Unterschied ab; aus der Dauer ließe sich die
richtige Signatur Zeichen für Zeichen erraten. `timingSafeEqual` oder das Äquivalent benutzen.
3. **Nur „zu alt“ geprüft.** Eine Nachricht mit Zeitstempel im Jahr 2040 wäre dann für immer gültig.
Der Abstand gehört in **beide** Richtungen geprüft.
Ohne Geheimnis fehlt die Kopfzeile ganz, statt dass eine leere Signatur Sicherheit vortäuscht.
## Die Warteschlange
**Eingereiht wird sofort, gesendet nicht.** Ein hängender Mailserver darf kein Abhaken blockieren:
der Dienst schreibt eine Zeile, und ein Zeitplan im selben Prozess schickt sie — jede Minute, kein
zweiter Dienst, kein Systemd-Timer.
Ein Fehlschlag wird wiederholt, mit wachsendem Abstand: nach **1, 5, 30 und 120 Minuten**. Beim
fünften Fehlschlag gibt Laufzettel auf und lässt die Zeile als **aufgegeben** stehen.
> **Eine stille Warteschlange ist schlimmer als keine.** Unter **Admin → Zustellungen** steht, was
> hinausgegangen ist und was nicht — mit Grund. Sieh dort hin, wenn jemand sagt, er bekomme keine
> Mails: fast immer ist das SMTP-Passwort abgelaufen, und ohne diese Seite merkt es wochenlang
> niemand, weil jeder Lauf weiter funktioniert.
Betreff und Rumpf stehen **fertig** in der Zeile. Eine Meldung sagt, was damals war; würde der Text
erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
### Mehrere Instanzen
Läuft Laufzettel hinter einem Lastverteiler mehrfach, nimmt genau eine Instanz je Durchgang die
Warteschlange — über eine Sperre in der Datenbank, dieselbe Mechanik wie beim Verzeichnisabgleich.
Ohne sie bekäme jeder Empfänger jede Mail doppelt. Dafür ist nichts einzurichten.
## Je Person abschalten
Unter **Admin → Benutzer** schaltet der **Mail**-Knopf die Benachrichtigungen einer Person ab. Das
gilt auch für das eigene Konto — sich selbst zu deaktivieren wäre ein Eigentor, seine eigene Post
abzustellen ist der häufigste Wunsch überhaupt.
Wer abgeschaltet hat, bekommt keine Zeile und keinen Fehler: es wird gar nichts eingereiht. Ein
deaktiviertes Konto ebenso.
## Was nicht gemeldet wird
- **Ein Kommentar oder ein angehängtes Foto.** Das sieht die Live-Aktualisierung in der
Abarbeitungsansicht sofort; eine Mail dafür wäre Lärm.
- **Ein einzelner abgehakter Schritt.** Dasselbe.
- **Ein archivierter Lauf.** Archivieren ist eine Aufräumarbeit, keine Nachricht.
Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich. Diese vier decken
ab, was im Betrieb wirklich fehlt.
## Prüfen, dass es läuft
```bash
# Steht etwas in der Warteschlange, das nicht hinausgeht?
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc \
"SELECT zustand, count(*) FROM \"Zustellung\" GROUP BY zustand"
```
Nativ dasselbe über `sudo -u postgres psql -d laufzettel -tAc "…"`.
Steht dort etwas unter `AUFGEGEBEN`, schau unter **Admin → Zustellungen** nach dem Grund. Alles unter
`OFFEN` oder `FEHLGESCHLAGEN` regelt der Zeitplan von selbst — es sei denn, es steht dort seit
Stunden, dann läuft der Prozess nicht oder `ZEITPLAN_AKTIV` ist auf `false`.