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>
8.0 KiB
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:
- Den Rumpf vorher geparst und wieder serialisiert. Dann stimmt die Signatur nicht mehr —
JSON.stringifysetzt Leerzeichen und Reihenfolge anders. Signiert wird der rohe Rumpf, also vor jeder Verarbeitung lesen. - Mit
===verglichen. Das bricht beim ersten Unterschied ab; aus der Dauer ließe sich die richtige Signatur Zeichen für Zeichen erraten.timingSafeEqualoder das Äquivalent benutzen. - 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.