diff --git a/docs/superpowers/specs/2026-08-02-versandtakt-design.md b/docs/superpowers/specs/2026-08-02-versandtakt-design.md new file mode 100644 index 0000000..6aa3617 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-versandtakt-design.md @@ -0,0 +1,191 @@ +# Der Versandtakt + +Entwurf vom 2. August 2026, beschlossen mit dem Betreiber. Erstes von fünf +Vorhaben rund um den Mailversand; die übrigen stehen am Ende unter „Was danach +kommt". + +## Warum + +Der Betreiber will nicht, dass ein großer Lauf — zweihundert Rechnungen an einem +Abend — als Schub bei einem einzelnen Empfängerserver ankommt und die +Absenderadresse dort als Spam eingestuft wird. Ein einmal beschädigter +Absenderruf ist schwer zu reparieren, und die Regel muss stehen, **bevor** der +erste große Lauf kommt. + +Zur Einordnung, damit niemand den Aufwand für dringlicher hält als er ist: heute +sind es **11 Kunden, 5 laufende Verträge, 2 Rechnungen**. Bei dieser Größe +braucht es keine Drosselung. Gebaut wird sie für den Zustand, in dem sie fehlen +würde. + +## Was heute steht + +- **Vierzehn Mailklassen**, ausnahmslos `ShouldQueue` — es geht heute schon alles + über die Warteschlange (Redis). +- **Ein Arbeiter** für alles: `php artisan queue:work redis --tries=3 --timeout=90`. +- **Eine zweite Schlange existiert bereits**: die Bereitstellung fährt über + `onQueue('provisioning')` mit eigenem Arbeiter. Das Muster ist also im Haus. +- **`sent_mails`** protokolliert, was hinausging (Empfänger, Betreff, Mailklasse, + Zeitpunkt) — und heißt bewusst nicht „delivered": angenommen ist nicht + zugestellt. +- **Keine Drosselung, kein Takt, keine Priorität.** + +## Die drei Spuren + +Rund um die Uhr, ohne Zeitfenster. Der Betreiber hat das ausdrücklich so +entschieden: ob eine Rechnung um 18 Uhr oder um 2 Uhr ankommt, ändert nichts — +sie wird abgelegt, und die Abbuchung macht Stripe von selbst. + +| Spur | Schlange | Takt | Mailklassen | +|---|---|---|---| +| **Direkt** | `mail-direkt` | ungedrosselt | ResetPassword, VerifyEmail, NewDeviceSignIn, SecurityBlock, ContactRequest, OrderConfirmation, OperatorMessage | +| **Wichtig** | `mail-wichtig` | 30 je 5 Minuten | MaintenanceAnnouncement, MaintenanceCancelled, CloudSuspended, CloudResumed | +| **Zeit lassen** | `mail-ruhig` | 20 je 10 Minuten | Invoice, DunningNotice, DormantAccountWarning | + +**Warum „Direkt" ungedrosselt ist:** Diese Mails entstehen **einzeln**, weil ein +einzelner Mensch gerade etwas getan hat und auf die Antwort wartet. Sie können +gar keinen Schub bilden. Drosseln nützt dort nichts und kostet einen Supportfall +je verzögertem Kennwort. + +`OperatorMessageMail` steht dort aus demselben Grund: der Betreiber hat gerade +auf „senden" geklickt und sieht zu. **Das ist meine Zuordnung, nicht seine** — +sie gehört gegengelesen. + +**Warum die Mahnung unten steht:** Sie hat eine Frist von Tagen, nicht von +Minuten, und geht an mehrere gleichzeitig. Bei 120 Mails je Stunde ist sie +spätestens nach einer Stunde draußen, lange vor jeder Frist. + +**Warum eine Ausfallmeldung oben steht, obwohl sie an alle geht:** Massenversand +und Dringlichkeit sind zwei verschiedene Achsen. Eine Wartungsankündigung ist +beides — deshalb bekommt sie ein höheres Kontingent statt gar keines. + +## Die Zuordnung ist änderbar, aber nicht überall + +Die Vorgaben stehen im Code. Der Betreiber kann eine Mailklasse in der Konsole +verschieben; die Einstellung liegt darüber. + +**Die sieben Direkt-Mails lassen sich nicht nach unten schieben.** Ein +Kennwort-Zurücksetzen, das zwanzig Minuten liegt, ist ein Supportfall und kein +gespartes Ansehen. Die Sperre sitzt in der Zuordnung selbst, nicht nur im +Formular. + +**Eine neue Mailklasse ohne Eintrag landet in „Zeit lassen".** Das ist die +vorsichtige Richtung: eine zu langsam verschickte Mail ist ein kleinerer Fehler +als ein ungedrosselter Schub, den niemand vorhergesehen hat. + +## Wie gedrosselt wird + +**Eine Zwischenschicht an der Mail selbst.** Alle vierzehn Klassen sind bereits +`ShouldQueue`; sie bekommen eine gemeinsame Zwischenschicht, die vor dem Versand +das Kontingent ihrer Spur prüft. Ist es erschöpft, geht die Mail mit Verzögerung +zurück in die Schlange. + +**Kein einziger Aufrufer ändert sich.** `Mail::to(...)->queue(...)` bleibt an +allen rund zwanzig Absendestellen stehen. Das ist der Grund für diesen Weg: der +Betreiber hat ausdrücklich verlangt, nichts kaputtzumachen, und dieser Weg fasst +die Absendestellen nicht an. + +Verworfen wurden: + +- **Eine eigene Ausgangstabelle**, aus der ein Befehl im Takt schöpft. Volle + Sicht und von Hand steuerbar — aber alle Absendestellen müssten umgebaut + werden, und es gäbe zwei Warteschlangen nebeneinander, die auseinanderlaufen + können. +- **Drei Arbeiter mit eigenem Takt** im Container. Am wenigsten Code — aber die + Zahlen stünden im Container statt in der Konsole, und niemand sähe, was wartet. + +## Der Arbeiter muss die Spuren kennen + +Drei Schlangen nützen nichts, wenn niemand sie liest. Der vorhandene Arbeiter +fährt heute `queue:work redis` ohne Angabe und bedient damit nur `default`. + +Er bekommt die Spuren in dieser Reihenfolge: + +``` +queue:work redis --queue=mail-direkt,mail-wichtig,mail-ruhig,default --tries=3 --timeout=90 +``` + +Die Reihenfolge **ist** die Priorität: der Arbeiter sieht erst nach, ob in +`mail-direkt` etwas liegt, und geht erst weiter, wenn dort nichts ist. Deshalb +steht ein Kennwort-Zurücksetzen nie hinter zweihundert Rechnungen — nicht wegen +einer Sortierung, sondern weil es in einer anderen Schlange liegt. + +`default` bleibt am Ende stehen: dort läuft alles, was keine Mail ist, und es +darf durch diese Änderung nicht verhungern. Der eigene Arbeiter der +Bereitstellung (`provisioning`) bleibt unberührt. + +**Das ist eine Änderung an `docker-compose.yml`** und damit am Deployment. Sie +muss zusammen mit dem Code ausgerollt werden: ein Arbeiter, der die neuen +Schlangen nicht liest, sammelt Mails an, die niemand versendet — still, ohne +Fehlermeldung. + +## Die Falle, an der dieser Bau scheitern kann + +Der Arbeiter läuft mit `--tries=3`. **Eine zurückgelegte Mail zählt als +Versuch.** Ohne Vorkehrung wäre jede Rechnung nach dem dritten Drosseln +endgültig gescheitert statt verschickt — still, im Fehlerprotokoll, ohne dass +jemand etwas merkt. + +Eine gedrosselte Rückstellung darf den Versuchszähler deshalb **nicht** erhöhen. +Das ist der erste Test dieses Vorhabens, vor der ersten Zeile Drossel: fünfzig +Mails in eine Spur mit Kontingent zwanzig, und danach müssen fünfzig verschickt +sein und null gescheitert. + +## Der Notschalter + +Ein Schalter in der Konsole macht die Drossel wirkungslos — dann fährt alles wie +heute, ungedrosselt über die drei Schlangen. Wenn die Drossel je klemmt, muss der +Ausweg ein Klick sein und kein Deployment. + +## Was sichtbar wird + +Je Spur: wie viele Mails warten (Redis kennt die Länge), welcher Takt eingestellt +ist, und ob die Drossel an ist. Darunter das vorhandene Versandprotokoll. + +Die Bereitschaftsseite schlägt an, wenn in einer Spur etwas liegt, das **älter +als eine Stunde** ist. Die Zahl ist so gewählt, dass sie beim langsamsten Takt +nicht von selbst anschlägt: 20 je 10 Minuten sind 120 in der Stunde, und ein Lauf +dieser Größe steht heute nicht an. Schlägt sie trotzdem an, klemmt etwas — und +das ist die Lage, die sonst niemand bemerkt. + +## Tests + +- **Der Versuchszähler.** Fünfzig Mails, Kontingent zwanzig: fünfzig verschickt, + null gescheitert. Der wichtigste Test dieses Vorhabens. +- **Die Spurzuordnung.** Jede der vierzehn Mailklassen landet in der Schlange + ihrer Vorgabe; eine unbekannte Klasse landet in „Zeit lassen". +- **Die Sperre.** Eine Direkt-Mail lässt sich nicht in eine gedrosselte Spur + verschieben — auch nicht an der Oberfläche vorbei. +- **Der Takt.** In einem Fenster gehen höchstens so viele Mails hinaus, wie das + Kontingent erlaubt; im nächsten wieder. +- **Direkt ist ungedrosselt.** Hundert Direkt-Mails hintereinander werden nicht + zurückgelegt. +- **Der Notschalter.** Ausgeschaltet fährt alles ohne Verzögerung. +- **Die Reihenfolge.** Eine Direkt-Mail, die nach zweihundert Rechnungen + eingereiht wird, geht vor ihnen hinaus. + +## Was NICHT dazugehört + +- **Ein Zeitfenster.** Ausdrücklich nicht gewollt: rund um die Uhr. +- **Umsortieren innerhalb einer Spur.** Die Trennung in drei Schlangen leistet, + was die Priorität leisten soll. +- **Der Zeitplan** (wann welche Mail ausgelöst wird), **der Vorlauf** (Rechnung + einige Tage vor der Abbuchung), **die Vorlagen für Systemmails samt Editor** + und **die Aufteilung der Mailseite**. Vier eigene Vorhaben. + +## Was danach kommt + +In dieser Reihenfolge, jedes mit eigenem Entwurf: + +1. **Die Mailseite aufteilen** — Zustellung, Postfächer, Spuren, Protokoll. +2. **Vorlagen für Systemmails** mit Editor und Vorschau. Heikel: eine + Rechnungsmail trägt Pflichtangaben, frei bearbeitbar heißt auch frei + kaputtmachbar. +3. **Vorlauf** — die Rechnung einige Tage vor der Abbuchung statt danach. Braucht + Stripes Vorschau auf die nächste Abbuchung. +4. **Eigene Mails mit eigener Auslöseregel.** Die eigentliche Frage dort ist + nicht die Vorlage, sondern: wann wird sie verschickt. + +Siehe `docs/superpowers/specs/2026-07-31-zahlungsmittel-und-mahnlauf-design.md` +für das Mahnwesen, das heute täglich um 09:00 läuft — mit derselben Begründung, +aus der die Spuren rund um die Uhr fahren dürfen und die Mahnung trotzdem unten +steht.