Files
Laufzettel/docs/betrieb/benachrichtigungen.md
T
Florian WildeandClaude Opus 5 2d3325ef20
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
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>
2026-10-05 17:22:20 +02:00

8.0 KiB
Raw Blame History

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:

{
  "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):

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

# 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.