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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1be73b266c
commit
a417426f43
@@ -169,6 +169,55 @@ nicht archivieren.
|
||||
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
|
||||
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||
Ein zugewiesener Lauf fiel früher nur auf, wenn jemand die Übersicht öffnete. Ein überfälliger
|
||||
überhaupt nicht. Laufzettel meldet deshalb 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 Administratoren |
|
||||
|
||||
**Über die eigene Tat bekommt niemand Post.** Wer selbst zuweist, abschließt oder abhakt, erfährt es
|
||||
nicht per Mail — das ist der häufigste Grund, Benachrichtigungen ganz abzuschalten.
|
||||
|
||||
**In der Nachricht stehen Titel, Status, Gerät, Person, Frist und ein Link.** Keine Schrittinhalte,
|
||||
und damit auch kein erzeugtes Passwort. Das Passwort entsteht bei jeder Anzeige neu; eine Mail
|
||||
dagegen bleibt für immer in einem Postfach.
|
||||
|
||||
### Einrichten
|
||||
|
||||
Unter **Verwaltung → Einstellungen**, Abschnitt **Benachrichtigungen**: Host, Port, Benutzer,
|
||||
Passwort und Absender des Mailservers. Dann **Speichern**, dann **Testmail schicken** — die geht
|
||||
sofort hinaus, nicht über die Warteschlange, und ein Fehler erscheint im Klartext.
|
||||
|
||||
Daneben steht ein **Webhook**: dieselben vier Anlässe als JSON an eine Adresse, mit Signatur. Das ist
|
||||
für Ticketsysteme und Chat-Räume gedacht; die Einzelheiten stehen in
|
||||
[`docs/betrieb/benachrichtigungen.md`](../betrieb/benachrichtigungen.md).
|
||||
|
||||
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist.** Leer heißt „unverändert lassen“ —
|
||||
sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
|
||||
|
||||
### Abschalten
|
||||
|
||||
Unter **Verwaltung → Benutzer** schaltet der **Mail**-Knopf an einer Person die Benachrichtigungen
|
||||
ab. Das geht auch für das eigene Konto. Wer abgeschaltet hat, bekommt nichts und löst auch keinen
|
||||
Fehler aus.
|
||||
|
||||
### Zustellungen
|
||||
|
||||
Unter **Verwaltung → Zustellungen** steht, was hinausgegangen ist und was nicht. Schlägt eine
|
||||
Zustellung fehl, versucht Laufzettel es nach 1, 5, 30 und 120 Minuten erneut; nach dem fünften
|
||||
Fehlschlag bleibt sie als
|
||||
**aufgegeben** stehen.
|
||||
|
||||
**Da hinzusehen lohnt, wenn jemand sagt, er bekomme keine Mails.** Fast immer ist das SMTP-Passwort
|
||||
abgelaufen — und ohne diese Seite merkt es wochenlang niemand, weil die Läufe weiter funktionieren.
|
||||
Ganz oben steht ein Hinweis, sobald etwas aufgegeben wurde.
|
||||
|
||||
## Protokoll
|
||||
|
||||
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
|
||||
|
||||
@@ -232,6 +232,39 @@ versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück
|
||||
Sonderfall und keinen eigenen Fehlercode.
|
||||
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
|
||||
|
||||
### Warum die Benachrichtigungen in einer Warteschlange liegen
|
||||
|
||||
Vier Anlässe gehen seit #034 hinaus: zugewiesen, überfällig, abgeschlossen, Abgleich fehlgeschlagen.
|
||||
Drei Entscheidungen prägen das.
|
||||
|
||||
**Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein hängender Mailserver darf kein
|
||||
Abhaken blockieren. Der Dienst schreibt eine Zeile in `Zustellung` — eine Einfügung in dieselbe
|
||||
Datenbank, die der Vorgang ohnehin anfasst —, und ein Zeitplan im selben Prozess schickt sie. Das
|
||||
Einreihen passiert **nach** der Transaktion und mit abgefangenem Fehler: ein Abhaken darf nicht
|
||||
daran scheitern, dass eine Benachrichtigung nicht eingereiht werden konnte.
|
||||
|
||||
**Betreff und Rumpf stehen fertig in der Zeile.** Das kostet Platz und ist es wert: eine Meldung
|
||||
sagt, was *damals* war. Baute der Zeitplan den Text erst beim Senden, stünde in einer Mail von heute
|
||||
der Stand von jetzt — und in einer aufgegebenen Zustellung von nächster Woche etwas völlig anderes.
|
||||
|
||||
**In keiner Meldung steht ein Schrittinhalt.** Darüber wacht `src/lib/meldungen.ts`: die Funktionen
|
||||
dort nehmen **nur** Kopfdaten, der Typ lässt nichts anderes zu. Eine Mail mit dem Lauf-Inhalt wäre
|
||||
genau die Lücke, die dieses Projekt an jeder anderen Stelle geschlossen hat — das erzeugte Passwort
|
||||
wird nirgends gespeichert, aber eine Mail liegt für immer in einem Postfach, oft auf einem Server,
|
||||
der nicht im Haus steht. `tests/benachrichtigungen.test.ts` prüft es zusätzlich über **alle**
|
||||
Rumpftexte, die wirklich verschickt wurden, so wie #012 es für die Druckansicht tat.
|
||||
|
||||
Zwei Dinge, die ohne Datenbank nicht gehalten hätten. Die **Einmaligkeit der
|
||||
Überfälligkeitsmeldung** liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute
|
||||
nach, und ohne ihn bekäme der Zuständige für einen Lauf, der über das Wochenende steht, 2880 Mails —
|
||||
jede davon für sich korrekt, kein Test rot. Und die **Sperre** der Warteschlange liegt in derselben
|
||||
Tabelle wie die des Abgleichs: hinter einem Lastverteiler laufen zwei Instanzen, und ein `let laeuft
|
||||
= false` im Modul wüsste nichts von der zweiten.
|
||||
|
||||
**Das Polling-Prinzip gilt auch hier nicht umgekehrt:** ein Kommentar und ein angehängtes Foto lösen
|
||||
*keine* Mail aus. Beides sieht die Live-Aktualisierung sofort (Abschnitt 5d); eine Mail dafür wäre
|
||||
Lärm. Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich.
|
||||
|
||||
## 5e. Der Aufkleber ist die Schnittstelle
|
||||
|
||||
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
|
||||
|
||||
@@ -56,6 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
|
||||
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
|
||||
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
|
||||
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
|
||||
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
|
||||
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
|
||||
|
||||
## Wo sonst noch etwas steht
|
||||
|
||||
|
||||
@@ -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`.
|
||||
+65
-2
@@ -32,12 +32,13 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min |
|
||||
| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
|
||||
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
|
||||
| | **Summe** | **50 h 05 min** |
|
||||
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
||||
| | **Summe** | **53 h 35 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||
| v0.2.0 | 14 h 45 min | in Arbeit |
|
||||
| v0.2.0 | 18 h 15 min | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -1239,3 +1240,65 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
|
||||
jetzt stehen. Danach **M22 (#035)** Selbstauskunft.
|
||||
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-05 · Sitzung 19 · 10:45–14:15
|
||||
|
||||
**Issue:** #034 (M21)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Vier Anlässe gehen hinaus**, per Mail über SMTP und optional per Webhook mit
|
||||
HMAC-SHA256-Signatur. Je Person abschaltbar, und niemand bekommt Post über die eigene Tat.
|
||||
- **Eine Warteschlange in der Datenbank**, abgearbeitet von einem eigenen Zeitplan im selben
|
||||
Prozess. Fehlschläge nach 1, 5, 30 und 120 Minuten wiederholt, danach sichtbar aufgegeben.
|
||||
- **Verwaltung → Zustellungen** zeigt, was hinausging und was nicht, mit Grund.
|
||||
- 1302 Vitest-Tests (vorher 1266), davon 36 neue — **gegen einen echten SMTP-Fänger** (mailpit),
|
||||
nicht gegen eine abgefangene Funktion.
|
||||
- Im gebauten Stand gefahren: SMTP eingetragen, Testmail angekommen, ein überfälliger Lauf angelegt,
|
||||
und der Zeitplan hat die Meldung **von selbst** verschickt.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Eingereiht im Anfrage-Pfad, gesendet vom Zeitplan.** Ein hängender Mailserver darf kein Abhaken
|
||||
blockieren. Das Einreihen passiert nach der Transaktion und mit abgefangenem Fehler.
|
||||
- **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.
|
||||
- **Die Texte liegen in `src/lib/meldungen.ts` und nehmen nur Kopfdaten.** Der Typ lässt keinen
|
||||
Schrittinhalt zu — das ist die Absicht, und es ist die einzige Stelle, die man dafür ändern müsste.
|
||||
- **Die Sperre wurde aus dem Abgleich herausgezogen** nach `services/sperre.ts`. Zwei Fassungen
|
||||
derselben Mechanik wären die schlechteste aller Lösungen.
|
||||
- **Die Mandantengrenze gilt auch für Empfänger.** Ohne diese Prüfung wäre die Benachrichtigung der
|
||||
eine Weg nach draußen, den `sichtbareKundenIds` nicht abdeckt.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **pnpm wollte eine Sicherheitsregel umgehen, und ich hätte es fast committet.** `pnpm add
|
||||
nodemailer` trug von sich aus `minimumReleaseAgeExclude: nodemailer@10.0.15` in
|
||||
`pnpm-workspace.yaml` ein — die Version war **vom selben Tag**. Das ist kein Formfehler, sondern
|
||||
genau die Warnung, vor der die Regel schützt. Gepinnt ist jetzt `10.0.10` vom 14. September, die
|
||||
Ausnahme ist weg, und die Konvention steht in `CLAUDE.md`. Aufgefallen beim Durchsehen des Diffs,
|
||||
nicht durch einen Test — eine Zeile in einer Datei, die man leicht übersieht.
|
||||
- **Drei Tests waren stillschweigend übersprungen.** `it.runIf(mailpitDa)` wird beim **Einlesen** der
|
||||
Datei ausgewertet; mein Flag stand in `beforeAll` und kam zu spät. Der erste Lauf meldete
|
||||
„3 skipped“, war grün — und hatte den echten SMTP-Dialog nie geprüft. Genau das Muster, das
|
||||
`ldap` in #019 schon einmal gekostet hat. `mailpit` steht jetzt im Tor vor dem Commit, und
|
||||
`pruefe:doku` erzwingt es; gegengeprobt.
|
||||
- **Die Wartezeiten waren um eins verschoben.** Nach dem ersten Fehlschlag wartete meine erste
|
||||
Fassung fünf Minuten statt einer. Der Test hat es sofort gezeigt — und die Umbenennung des
|
||||
Parameters auf `versucheGesamt` macht die Semantik jetzt lesbar.
|
||||
- **Zwei Knöpfe „Speichern“ auf einer Seite**, beim Durchklicken gefunden. Der neue heißt jetzt
|
||||
„Benachrichtigungen speichern“.
|
||||
- **Drei eigene Testfehler**, keine Produktfehler: die `Verzeichnisquelle` hat `url`, nicht
|
||||
`host`/`port`; `Lauf` mit Status `ABGESCHLOSSEN` braucht `abgeschlossenAm` (die Datenbank verlangt
|
||||
es); und ein fester Testzeitpunkt lag **vor** der echten Einreihung, sodass der Durchgang die
|
||||
Zeile gar nicht erst anfasste.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **M22 (#035)** Mitarbeiter-Selbstauskunft — dort kommt auch die Profilseite, auf der jeder seine
|
||||
eigenen Benachrichtigungen abschalten kann.
|
||||
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #034 M21 – Benachrichtigungen per Mail und Webhook
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend, infra
|
||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||
**Geschätzt:** 10 h **Tatsächlich:** 3 h 30 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -14,44 +14,93 @@ Ein zugewiesener Lauf fällt heute nur auf, wenn jemand die Übersicht öffnet.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner
|
||||
- [x] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner
|
||||
Benachrichtigungsbaukasten ist eine Phase für sich; vier Ereignisse decken ab, was im Betrieb
|
||||
wirklich fehlt.
|
||||
- [ ] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den
|
||||
- [x] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den
|
||||
Mailserver des Hauses können, und das ist SMTP. Zugangsdaten verschlüsselt wie das
|
||||
LDAP-Bind-Passwort.
|
||||
- [ ] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender
|
||||
- [x] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender
|
||||
Mailserver darf kein Abhaken blockieren. Dieselbe Sperre wie beim Abgleich
|
||||
(`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler,
|
||||
- [x] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler,
|
||||
Zustand; mit Index auf das, was der Zeitplan abholt
|
||||
- [ ] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort
|
||||
- [x] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort
|
||||
(verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“
|
||||
- [ ] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`,
|
||||
- [x] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`,
|
||||
dazu ein Zeitstempel gegen Wiedereinspielen
|
||||
- [ ] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar
|
||||
- [x] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar
|
||||
machen. Eine stille Warteschlange ist schlimmer als keine
|
||||
- [ ] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter
|
||||
- [x] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter
|
||||
Dienst
|
||||
- [ ] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
|
||||
- [ ] Admin: die letzten Zustellungen mit Zustand und Grund
|
||||
- [ ] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre
|
||||
- [x] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
|
||||
- [x] Admin: die letzten Zustellungen mit Zustand und Grund
|
||||
- [x] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre
|
||||
genau die Lücke, die das Projekt an jeder anderen Stelle geschlossen hat. Die Nachricht
|
||||
enthält Titel, Status und einen Link — **keine Schrittinhalte**
|
||||
- [ ] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
|
||||
- [ ] Anleitung Kapitel 07
|
||||
- [x] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
|
||||
- [x] Anleitung Kapitel 07
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig,
|
||||
- [x] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig,
|
||||
abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger
|
||||
- [ ] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit
|
||||
- [x] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit
|
||||
einem falschen nicht
|
||||
- [ ] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
|
||||
- [ ] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
|
||||
- [ ] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
|
||||
- [x] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
|
||||
- [x] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
|
||||
- [x] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
|
||||
in #012
|
||||
- [ ] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts
|
||||
- [x] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Die Sperre wurde aus dem Abgleich herausgezogen.** `mitAbgleichSperre` lag in
|
||||
`services/abgleich.ts`; die Warteschlange braucht dieselbe Mechanik, und zwei Fassungen davon
|
||||
wären die schlechteste aller Lösungen. Sie heißt jetzt `mitSperre` in `services/sperre.ts`. Die
|
||||
Tabelle behält den Namen `AbgleichSperre` — ein Umbenennen wäre eine Migration, die nichts
|
||||
verbessert, und der Schlüssel sagt ohnehin, wer sperrt.
|
||||
- **Niemand bekommt Post über die eigene Tat.** Steht nicht im Issue, ist aber der häufigste Grund,
|
||||
Benachrichtigungen ganz abzuschalten. Der Handelnde fällt immer aus der Empfängerliste.
|
||||
- **Die Mandantengrenze gilt auch für Empfänger.** Eine Meldung trägt Lauftitel und Gerätenamen;
|
||||
ohne diese Prüfung wäre die Benachrichtigung der eine Weg nach draußen, den `sichtbareKundenIds`
|
||||
nicht abdeckt. Ein Benutzer, der den Kunden des Laufs nicht sehen darf, bekommt keine Mail — mit
|
||||
eigenem Test.
|
||||
- **Die Einmaligkeit der Überfälligkeitsmeldung liegt in der Datenbank**, als partieller eindeutiger
|
||||
Index. Der Zeitplan sieht jede Minute nach; ohne ihn bekäme der Zuständige für einen Lauf, der
|
||||
über das Wochenende steht, **2880 Mails** — und jede einzelne davon wäre für sich korrekt, kein
|
||||
Test rot. Gegenprobe gefahren: ohne den Index werden zwei Tests rot.
|
||||
- **Fünf Versuche, nicht vier.** Die vier Wartezeiten aus dem Issue (1, 5, 30, 120 Minuten) liegen
|
||||
*zwischen* den Versuchen — der fünfte Fehlschlag gibt auf. Meine erste Fassung indizierte um eins
|
||||
verschoben und wartete nach dem ersten Fehlschlag fünf Minuten; der Test hat es sofort gezeigt.
|
||||
- **Ein eigener Zeitplan, nicht derselbe wie der Abgleich.** Der Mailversand darf nicht warten, weil
|
||||
gerade ein großes Verzeichnis gelesen wird. Zwei Takte sind billig.
|
||||
- **`mailpit` gehört ins Tor vor dem Commit.** Ohne den Container überspringt die Suite **3 Tests**
|
||||
(gemessen: 36 statt 33) — genau die, die den echten SMTP-Dialog prüfen. `CLAUDE.md`, das README
|
||||
und `scripts/pruefe-doku.mjs` nennen ihn jetzt, und die Prüfung ist gegengeprobt: ohne ihn im
|
||||
Block wird `pnpm pruefe:doku` rot.
|
||||
- **`it.runIf(mailpitDa)` wird beim Einlesen der Datei ausgewertet.** Das Flag stand in `beforeAll`
|
||||
und kam zu spät: der erste Lauf meldete „3 skipped“, war grün und hatte den Mailversand nie
|
||||
geprüft. Jetzt wird auf Modulebene geprüft, wie bei `datenbankVerfuegbar`.
|
||||
- **pnpm wollte eine Mindestalter-Sperre umgehen.** `pnpm add nodemailer` trug von sich aus
|
||||
`minimumReleaseAgeExclude: nodemailer@10.0.15` ein — die Version war **vom selben Tag**. Das ist
|
||||
kein Formfehler, sondern genau die Warnung, vor der die Regel schützt: ein frisches Release eines
|
||||
Mailpakets ungeprüft einzubauen. Gepinnt ist jetzt `10.0.10` vom 14. September, die Ausnahme ist
|
||||
weg, und die Konvention steht in `CLAUDE.md`.
|
||||
- **Zwei Knöpfe „Speichern“ auf einer Seite.** Beim Durchklicken aufgefallen: die Einstellungsseite
|
||||
hatte zwei gleich benannte. Der neue heißt jetzt „Benachrichtigungen speichern“ — für einen
|
||||
Menschen genauso verwirrend wie für einen Test.
|
||||
- **Die Testmail geht direkt hinaus, nicht über die Warteschlange.** Ein Knopf, der „eingereiht“
|
||||
meldet und den Fehler eine Minute später woanders zeigt, prüft nichts.
|
||||
- **Der Mail-Schalter gilt auch für das eigene Konto**, anders als „aktiv“: sich selbst zu
|
||||
deaktivieren wäre ein Eigentor, seine eigene Post abzustellen der häufigste Wunsch überhaupt. Eine
|
||||
**Profilseite**, auf der das jeder selbst könnte, fehlt noch — sie kommt mit der Selbstauskunft
|
||||
(#035). Bis dahin stellt ein Administrator es um, und so steht es in der Anleitung.
|
||||
- **Im gebauten Stand wirklich gefahren:** SMTP eingetragen, Testmail bei mailpit angekommen, ein
|
||||
überfälliger Lauf angelegt — und der Zeitplan hat die Meldung **von selbst** verschickt, ohne
|
||||
Zutun. Dazu geprüft: das Passwort steht nicht im Quelltext der Seite, Speichern ohne Passwortfeld
|
||||
behält es, die Zustellungsliste zeigt keinen Rumpf, und 390 px ohne waagerechtes Scrollen auf allen
|
||||
drei Seiten.
|
||||
|
||||
@@ -38,6 +38,6 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
|
||||
| | **Summe** | | | **229 h** | **50 h 05 min** |
|
||||
| | **Summe** | | | **229 h** | **53 h 35 min** |
|
||||
|
||||
Reference in New Issue
Block a user