diff --git a/docs/superpowers/plans/2026-08-02-versandtakt.md b/docs/superpowers/plans/2026-08-02-versandtakt.md new file mode 100644 index 0000000..9cee824 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-versandtakt.md @@ -0,0 +1,940 @@ +# Der Versandtakt — Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Ziel:** Ausgehende Mails laufen in drei Spuren mit eigenem Takt, damit ein +großer Lauf nie als Schub bei einem Empfängerserver ankommt — und ein +Kennwort-Zurücksetzen trotzdem sofort hinausgeht. + +**Architektur:** Alle vierzehn Mailklassen sind bereits `ShouldQueue`. Ein Trait +schickt jede in die Schlange ihrer Spur; ein eigener Warteschlangen-Auftrag +drosselt zwei der drei Spuren; der Arbeiter liest die Spuren in der Reihenfolge +ihrer Dringlichkeit. **Keine Absendestelle ändert sich** — `Mail::to(...)->queue(...)` +bleibt überall stehen. + +**Tech-Stack:** Laravel 13.8, Redis-Warteschlange, Livewire 3, Pest, Docker. + +**Entwurf:** `docs/superpowers/specs/2026-08-02-versandtakt-design.md` + +## Global Constraints + +- **Tests laufen so:** `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test` + (einzelne Datei durch Anhängen des Pfads). +- **Die drei Spuren, wörtlich:** + | Spur | Schlange | Takt | + |---|---|---| + | Direkt | `mail-direkt` | ungedrosselt | + | Wichtig | `mail-wichtig` | 30 je 5 Minuten | + | Zeit lassen | `mail-ruhig` | 20 je 10 Minuten | +- **Die Zuordnung, wörtlich:** + - `mail-direkt`: `ResetPasswordMail`, `VerifyEmailMail`, `NewDeviceSignInMail`, + `SecurityBlockMail`, `ContactRequestMail`, `OrderConfirmationMail`, + `OperatorMessageMail` + - `mail-wichtig`: `MaintenanceAnnouncementMail`, `MaintenanceCancelledMail`, + `CloudSuspendedMail`, `CloudResumedMail` + - `mail-ruhig`: `InvoiceMail`, `DunningNoticeMail`, `DormantAccountWarningMail` +- **Kein Zeitfenster.** Rund um die Uhr, ausdrücklich so entschieden. +- **Die sieben Direkt-Mails lassen sich nicht in eine gedrosselte Spur + verschieben** — die Sperre sitzt in der Zuordnung, nicht im Formular. +- **Eine unbekannte Mailklasse landet in `mail-ruhig`** (die vorsichtige Richtung). +- Regeln aus `CLAUDE.md` gelten: R22 (Aufwand nach Aufgabe), R23 (Bestätigung im + eigenen Modal), R19 (Zeiten über `->local()` anzeigen). +- **Commit-Nachrichten auf Deutsch**, mit `Co-Authored-By: Claude Opus 5 `. +- **Nicht pushen.** + +## Was beim Lesen des Frameworks herauskam + +Zwei Annahmen aus dem Entwurf halten der Prüfung **nicht** stand. Wer das nicht +weiß, baut zwei Tage am falschen Ende: + +1. **`SendQueuedMailable` hat keine `middleware()`-Methode** (geprüft in + `vendor/laravel/framework/src/Illuminate/Mail/SendQueuedMailable.php`: nur + `handle`, `backoff`, `retryUntil`, `failed`, `displayName`, `__clone`). Eine + `middleware()` **auf der Mailklasse** wird also von niemandem gelesen. Der + Weg führt über einen eigenen Auftrag. +2. **Der Auftrag wird über den Container erzeugt.** `Mailable::newQueuedJob()` + ruft `Container::getInstance()->make(SendQueuedMailable::class, ['mailable' => $this])`. + Eine Bindung im Dienstanbieter tauscht die Klasse also für **alle** Mails aus, + ohne eine einzige Absendestelle anzufassen. +3. **Die Spur muss vor dem Einreihen feststehen.** `Mailable::queue()` liest den + Schlangennamen und reicht ihn an `pushOn()` weiter. Ein Trait, das `queue()` + überschreibt, greift rechtzeitig — eine Trait-Methode schlägt die geerbte + Methode der Elternklasse. +4. **`Limit::perMinutes($decayMinutes, $maxAttempts)`** — die Reihenfolge ist + Minuten zuerst. `Limit::perMinutes(5, 30)` heißt „30 in 5 Minuten". +5. **Alle vierzehn Mailklassen benutzen bereits `SendsFromMailbox`.** Der Trait + für die Spur kommt daneben, nicht hinein: die eine Sache ist der Absender, + die andere die Spur. + +## Dateien + +| Datei | Verantwortung | +|---|---| +| `app/Services/Mail/MailLane.php` | Welche Spur gehört zu welcher Mailklasse, und was ist gesperrt | +| `app/Mail/Concerns/RidesALane.php` | Reiht eine Mail in die Schlange ihrer Spur ein | +| `app/Jobs/PacedMail.php` | Der Auftrag, der drosselt statt sofort zu senden | +| `app/Providers/MailPaceServiceProvider.php` | Kontingente anmelden, Auftrag binden | +| `app/Support/Readiness/DeliveryChecks.php` | Warnung, wenn eine Spur steht | +| `app/Livewire/Admin/MailPace.php` + Blade | Sicht und Schalter | +| `docker-compose.yml` | Der Arbeiter liest die drei Spuren | + +--- + +### Task 1: Die Spuren und ihre Zuordnung + +**Files:** +- Create: `app/Services/Mail/MailLane.php` +- Test: `tests/Feature/Mail/MailLaneTest.php` + +**Interfaces:** +- Produces: `MailLane::DIRECT = 'mail-direkt'`, `MailLane::URGENT = 'mail-wichtig'`, + `MailLane::CALM = 'mail-ruhig'` +- Produces: `MailLane::for(string $mailableClass): string` — die Schlange +- Produces: `MailLane::isLocked(string $mailableClass): bool` — Direkt-Mail? +- Produces: `MailLane::assign(string $mailableClass, string $lane): void` — wirft + bei einer gesperrten Klasse +- Produces: `MailLane::all(): array` — Klasse → Spur, für die Konsole + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +`tests/Feature/Mail/MailLaneTest.php`: + +```php +toBe(MailLane::DIRECT) + ->and(MailLane::for(MaintenanceAnnouncementMail::class))->toBe(MailLane::URGENT) + ->and(MailLane::for(InvoiceMail::class))->toBe(MailLane::CALM) + ->and(MailLane::for(DunningNoticeMail::class))->toBe(MailLane::CALM); +}); + +it('schickt eine unbekannte Mailklasse in die ruhige Spur', function () { + expect(MailLane::for('App\\Mail\\GibtEsNichtMail'))->toBe(MailLane::CALM); +}); + +it('lässt den Betreiber eine Mail verschieben', function () { + MailLane::assign(InvoiceMail::class, MailLane::URGENT); + + expect(MailLane::for(InvoiceMail::class))->toBe(MailLane::URGENT); +}); + +it('sperrt die Mails, auf die jemand wartet', function () { + expect(MailLane::isLocked(ResetPasswordMail::class))->toBeTrue() + ->and(MailLane::isLocked(InvoiceMail::class))->toBeFalse(); + + expect(fn () => MailLane::assign(ResetPasswordMail::class, MailLane::CALM)) + ->toThrow(RuntimeException::class); + + expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT); +}); + +it('lässt eine gespeicherte Zuordnung eine gesperrte Klasse nicht überschreiben', function () { + // Nicht über assign(), sondern direkt in die Einstellung geschrieben — so + // sähe es aus, wenn jemand an der Prüfung vorbei schreibt. + Settings::set('mail.lanes', [ResetPasswordMail::class => MailLane::CALM]); + + expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT); +}); +``` + +- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail/MailLaneTest.php` +Erwartet: FEHLSCHLAG — `Class "App\Services\Mail\MailLane" not found`. + +- [ ] **Step 3: Die Zuordnung schreiben** + +`app/Services/Mail/MailLane.php`: + +```php + */ + private const DEFAULTS = [ + MaintenanceAnnouncementMail::class => self::URGENT, + MaintenanceCancelledMail::class => self::URGENT, + CloudSuspendedMail::class => self::URGENT, + CloudResumedMail::class => self::URGENT, + InvoiceMail::class => self::CALM, + DunningNoticeMail::class => self::CALM, + DormantAccountWarningMail::class => self::CALM, + ]; + + /** Die Schlange, in die diese Mailklasse gehört. */ + public static function for(string $mailableClass): string + { + if (self::isLocked($mailableClass)) { + return self::DIRECT; + } + + $stored = (array) Settings::get('mail.lanes', []); + $lane = $stored[$mailableClass] ?? self::DEFAULTS[$mailableClass] ?? self::CALM; + + return in_array($lane, [self::DIRECT, self::URGENT, self::CALM], true) ? $lane : self::CALM; + } + + public static function isLocked(string $mailableClass): bool + { + return in_array($mailableClass, self::LOCKED, true); + } + + /** Verschiebt eine Mailklasse. Wirft, wenn sie gesperrt ist. */ + public static function assign(string $mailableClass, string $lane): void + { + if (self::isLocked($mailableClass)) { + throw new RuntimeException( + "{$mailableClass} fährt immer direkt: darauf wartet gerade jemand." + ); + } + + $stored = (array) Settings::get('mail.lanes', []); + $stored[$mailableClass] = $lane; + Settings::set('mail.lanes', $stored); + } + + /** + * Jede bekannte Mailklasse mit ihrer Spur, für die Konsole. + * + * @return array + */ + public static function all(): array + { + $classes = array_merge(self::LOCKED, array_keys(self::DEFAULTS)); + + return collect($classes) + ->mapWithKeys(fn (string $class) => [$class => self::for($class)]) + ->all(); + } +} +``` + +- [ ] **Step 4: Test laufen lassen** + +Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail/MailLaneTest.php` +Erwartet: BESTANDEN. + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/Mail/MailLane.php tests/Feature/Mail/MailLaneTest.php +git commit -m "Drei Spuren, und welche Mail in welche gehoert + +Die Trennung laeuft zwischen dringend und nicht dringend, nicht zwischen +Massenversand und Einzelmail: eine Ausfallmeldung geht an alle UND eilt. + +Die sieben Mails, auf die jemand wartet, sind gesperrt statt nur +voreingestellt — auch eine von Hand geschriebene Einstellung verschiebt sie +nicht. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: Jede Mail fährt in ihrer Spur + +**Files:** +- Create: `app/Mail/Concerns/RidesALane.php` +- Modify: alle vierzehn Klassen in `app/Mail/` (je ein Wort in der `use`-Zeile) +- Test: `tests/Feature/Mail/MailLaneRoutingTest.php` + +**Interfaces:** +- Consumes: `MailLane::for(string $mailableClass): string` aus Task 1 +- Produces: jede Mailklasse wird auf die Schlange ihrer Spur eingereiht + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +`tests/Feature/Mail/MailLaneRoutingTest.php`: + +```php +queue(new InvoiceMail(invoiceForTest(), 'Muster')); + + Queue::assertPushedOn(MailLane::CALM, Illuminate\Mail\SendQueuedMailable::class); +}); + +it('reiht eine Direkt-Mail in die Direkt-Spur ein', function () { + Queue::fake(); + + Mail::to('kunde@example.test')->queue(new ResetPasswordMail('https://example.test/reset', 'Muster')); + + Queue::assertPushedOn(MailLane::DIRECT, Illuminate\Mail\SendQueuedMailable::class); +}); + +it('folgt einer verschobenen Zuordnung', function () { + Queue::fake(); + MailLane::assign(InvoiceMail::class, MailLane::URGENT); + + Mail::to('kunde@example.test')->queue(new InvoiceMail(invoiceForTest(), 'Muster')); + + Queue::assertPushedOn(MailLane::URGENT, Illuminate\Mail\SendQueuedMailable::class); +}); +``` + +**Hinweis für den Umsetzenden:** `invoiceForTest()` gibt es nicht — bau eine +Hilfsfunktion in dieser Datei, die eine Rechnung anlegt, nach dem Muster der +Nachbardateien in `tests/Feature/Billing/`. Prüfe die echten +Konstruktor-Signaturen von `InvoiceMail` und `ResetPasswordMail`, bevor du sie +abtippst. Prüfe außerdem, ob `Queue::assertPushedOn` mit `SendQueuedMailable` +den Auftrag wirklich trifft — falls `Mail::fake()` hier das passendere Werkzeug +ist, nimm es und begründe es im Bericht. + +- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail/MailLaneRoutingTest.php` +Erwartet: FEHLSCHLAG — die Mail landet auf `default` statt auf `mail-ruhig`. + +- [ ] **Step 3: Den Trait schreiben** + +`app/Mail/Concerns/RidesALane.php`: + +```php +onQueue(MailLane::for(static::class)); + + return parent::queue($queue); + } +} +``` + +- [ ] **Step 4: Den Trait in alle vierzehn Klassen aufnehmen** + +In jeder Datei unter `app/Mail/` die `use`-Zeile im Klassenrumpf ergänzen, in +alphabetischer Ordnung: + +```php +use Queueable, RidesALane, SendsFromMailbox, SerializesModels; +``` + +und den Import `use App\Mail\Concerns\RidesALane;` setzen. Die vierzehn Klassen: +`CloudResumedMail`, `CloudSuspendedMail`, `ContactRequestMail`, +`DormantAccountWarningMail`, `DunningNoticeMail`, `InvoiceMail`, +`MaintenanceAnnouncementMail`, `MaintenanceCancelledMail`, +`NewDeviceSignInMail`, `OperatorMessageMail`, `OrderConfirmationMail`, +`ResetPasswordMail`, `SecurityBlockMail`, `VerifyEmailMail`. + +- [ ] **Step 5: Ein Test, der keine vergisst** + +In derselben Testdatei ergänzen: + +```php +it('lässt keine Mailklasse ohne Spur', function () { + $missing = collect(glob(app_path('Mail/*.php'))) + ->map(fn (string $path) => 'App\\Mail\\'.basename($path, '.php')) + ->reject(fn (string $class) => in_array(App\Mail\Concerns\RidesALane::class, class_uses_recursive($class), true)) + ->values() + ->all(); + + expect($missing)->toBe([]); +}); +``` + +Dieser Test ist der eigentliche Wächter: er schlägt an, wenn jemand später eine +fünfzehnte Mailklasse anlegt und den Trait vergisst. + +- [ ] **Step 6: Tests laufen lassen und committen** + +```bash +docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail +git add app/Mail tests/Feature/Mail/MailLaneRoutingTest.php +git commit -m "Jede Mail faehrt in der Schlange ihrer Spur + +Die Spur muss vor dem Einreihen feststehen: Mailable::queue() liest den +Schlangennamen und reicht ihn an pushOn() weiter. Ein Trait, das queue() +ueberschreibt, greift rechtzeitig — eine Trait-Methode schlaegt die geerbte +Methode der Elternklasse. + +Der Waechter-Test schlaegt an, wenn spaeter jemand eine Mailklasse anlegt und +den Trait vergisst. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: Der gedrosselte Auftrag — und die Falle mit den Versuchen + +**Das ist die Aufgabe, an der dieses Vorhaben scheitern kann.** Der Arbeiter +läuft mit `--tries=3`, und eine gedrosselte Rückstellung zählt als Versuch. Ohne +Vorkehrung wäre jede Rechnung nach dem dritten Drosseln **endgültig gescheitert** +statt verschickt — still, im Fehlerprotokoll. + +**Files:** +- Create: `app/Jobs/PacedMail.php` +- Create: `app/Providers/MailPaceServiceProvider.php` +- Modify: `bootstrap/providers.php` +- Test: `tests/Feature/Mail/MailPaceTest.php` + +**Interfaces:** +- Consumes: `MailLane::DIRECT|URGENT|CALM` aus Task 1 +- Produces: Kontingente `mail-wichtig` (30 je 5 Min) und `mail-ruhig` (20 je 10 Min) +- Produces: Einstellungen `mail.pace.urgent.count`, `mail.pace.urgent.minutes`, + `mail.pace.calm.count`, `mail.pace.calm.minutes` + +- [ ] **Step 1: Den wichtigsten Test dieses Vorhabens schreiben** + +`tests/Feature/Mail/MailPaceTest.php`: + +```php + */ + public function middleware(): array + { + if (! Settings::bool('mail.pace.enabled', true)) { + return []; + } + + return match ($this->mailable->queue) { + MailLane::URGENT => [new RateLimited(MailLane::URGENT)], + MailLane::CALM => [new RateLimited(MailLane::CALM)], + default => [], + }; + } + + /** + * Sechs Stunden statt eines Versuchszählers. + * + * Weit genug, dass auch ein Lauf über die ganze Nacht durchkommt, und eng + * genug, dass eine Mail, die nach sechs Stunden noch nicht draußen ist, + * nicht am nächsten Tag zwischen den neuen auftaucht. + */ + public function retryUntil(): \DateTimeInterface + { + return now()->addHours(6); + } +} +``` + +**Wichtig:** Prüfe, ob `$this->mailable->queue` an dieser Stelle den +Schlangennamen trägt (Task 2 setzt ihn über `onQueue()`). Falls nicht, nimm +`MailLane::for($this->mailable::class)` — dasselbe Ergebnis aus derselben +Quelle. Schreib in den Bericht, welchen Weg du genommen hast und warum. + +- [ ] **Step 4: Kontingente anmelden und den Auftrag binden** + +`app/Providers/MailPaceServiceProvider.php`: + +```php +app->bind(SendQueuedMailable::class, PacedMail::class); + } + + public function boot(): void + { + RateLimiter::for(MailLane::URGENT, fn () => Limit::perMinutes( + (int) Settings::get('mail.pace.urgent.minutes', 5), + (int) Settings::get('mail.pace.urgent.count', 30), + )); + + RateLimiter::for(MailLane::CALM, fn () => Limit::perMinutes( + (int) Settings::get('mail.pace.calm.minutes', 10), + (int) Settings::get('mail.pace.calm.count', 20), + )); + } +} +``` + +In `bootstrap/providers.php` eintragen (Reihenfolge wie die vorhandenen). + +- [ ] **Step 5: Tests laufen lassen** + +Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail` +Erwartet: BESTANDEN. + +- [ ] **Step 6: Die ganze Suite, dann committen** + +Die Bindung greift für **jede** Mail im Projekt — deshalb hier einmal alles: + +```bash +docker compose exec -T -e HOME=/tmp -u www-data app php artisan test +git add app/Jobs/PacedMail.php app/Providers/MailPaceServiceProvider.php bootstrap/providers.php tests/Feature/Mail/MailPaceTest.php +git commit -m "Der Takt, und die Falle mit den Versuchen + +SendQueuedMailable kennt keine middleware() — geprueft im Framework. Der Weg +fuehrt ueber einen eigenen Auftrag, den Mailable::newQueuedJob() ueber den +Container erzeugt; eine Bindung tauscht ihn fuer alle Mails aus, ohne dass eine +Absendestelle sich aendert. + +Der Arbeiter laeuft mit --tries=3, und eine gedrosselte Rueckstellung zaehlt +als Versuch. Ohne retryUntil() waere jede Rechnung nach dem dritten Drosseln +gescheitert statt verschickt. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: Der Arbeiter liest die drei Spuren + +Drei Schlangen nützen nichts, wenn niemand sie abholt. Der Arbeiter fährt heute +`queue:work redis` ohne Angabe und bedient damit **nur `default`** — nach Task 2 +und 3 läge jede Mail unverschickt in einer Schlange, die niemand liest. + +**Files:** +- Modify: `docker-compose.yml` (Dienst `queue`) +- Test: `tests/Feature/Mail/MailWorkerReadsLanesTest.php` + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +toContain('--queue=mail-direkt,mail-wichtig,mail-ruhig,default'); +}); +``` + +Das Muster — eine Zusicherung gegen `docker-compose.yml` — gibt es im Repo +bereits (`tests/Feature/DeploymentRunsAsTheAppUserTest.php`); sieh es dir an und +folge ihm. + +- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +Erwartet: FEHLSCHLAG — die Zeichenkette steht nicht in der Datei. + +- [ ] **Step 3: Den Arbeiter umstellen** + +In `docker-compose.yml`, Dienst `queue`, Zeile 70: + +```yaml + command: php artisan queue:work redis --queue=mail-direkt,mail-wichtig,mail-ruhig,default --tries=3 --timeout=90 +``` + +Mit einem Kommentar darüber, warum die Reihenfolge zählt und warum `default` +hinten stehen bleibt. + +- [ ] **Step 4: Test laufen lassen, Arbeiter neu starten, committen** + +```bash +docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Mail +docker compose up -d queue +git add docker-compose.yml tests/Feature/Mail/MailWorkerReadsLanesTest.php +git commit -m "Der Arbeiter liest die drei Spuren, und die Reihenfolge ist die Prioritaet + +Ohne diese Zeile laege nach den beiden vorigen Aufgaben jede Mail unverschickt +in einer Schlange, die niemand abholt — still, ohne Fehlermeldung. Code und +Compose gehoeren deshalb zusammen ausgerollt. + +Co-Authored-By: Claude Opus 5 " +``` + +**Für die Auslieferung vermerken:** Diese Änderung wirkt erst, wenn der +Arbeiter-Container neu gestartet wurde. + +--- + +### Task 5: Der Notschalter und die Sicht in der Konsole + +**Files:** +- Create: `app/Livewire/Admin/MailPace.php` +- Create: `resources/views/livewire/admin/mail-pace.blade.php` +- Modify: `routes/web.php` (Route im Konsolenbereich), `lang/de/*`, `lang/en/*` +- Test: `tests/Feature/Admin/MailPacePageTest.php` + +**Interfaces:** +- Consumes: `MailLane::all()`, `MailLane::assign()`, `MailLane::isLocked()` aus Task 1 +- Consumes: die Einstellungen aus Task 3 + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +assertSee(MailLane::DIRECT) + ->assertSee(MailLane::URGENT) + ->assertSee(MailLane::CALM); +}); + +it('laesst den Betreiber eine Mail verschieben', function () { + Livewire::test(App\Livewire\Admin\MailPace::class) + ->call('move', InvoiceMail::class, MailLane::URGENT); + + expect(MailLane::for(InvoiceMail::class))->toBe(MailLane::URGENT); +}); + +it('verschiebt eine gesperrte Mail nicht, auch nicht ueber die Komponente', function () { + Livewire::test(App\Livewire\Admin\MailPace::class) + ->call('move', ResetPasswordMail::class, MailLane::CALM); + + expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT); +}); + +it('schaltet die Drossel ab und wieder an', function () { + Livewire::test(App\Livewire\Admin\MailPace::class) + ->call('togglePace'); + + expect(Settings::bool('mail.pace.enabled', true))->toBeFalse(); +}); +``` + +**Hinweis:** Prüfe an den Nachbardateien in `tests/Feature/Admin/`, wie ein +Operator angemeldet und welche Fähigkeit verlangt wird — nimm dieselbe, die die +übrigen Maileinstellungen verlangen, und rate sie nicht. + +- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +Erwartet: FEHLSCHLAG — die Komponente gibt es nicht. + +- [ ] **Step 3: Komponente und Ansicht bauen** + +Die Komponente zeigt je Spur: Name, eingestellten Takt, Zahl der wartenden +Mails (`Illuminate\Support\Facades\Queue::size('mail-ruhig')`), und darunter die +Zuordnung als Liste mit einem Auswahlfeld je Mailklasse. Gesperrte Klassen +zeigen ein Schloss statt eines Auswahlfelds — und der Server lehnt sie ohnehin +ab (Task 1), das Schloss ist nur die Höflichkeit davor. + +Der Notschalter ist ein Umschalter nach dem Muster von `toggleSales()` in +`app/Livewire/Admin/Plans.php`: eine Methode, eine Rückmeldung, kein +Bestätigungsmodal — er ist umkehrbar. + +Gestaltung nach dem, was die Konsole schon benutzt: `x-ui.panel`, `lbl`, +`x-ui.switch`, `x-ui.badge`. **Keine eigenen Klassen erfinden.** + +- [ ] **Step 4: Tests laufen lassen und committen** + +```bash +docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Admin/MailPacePageTest.php tests/Feature/Mail +git add app/Livewire/Admin/MailPace.php resources/views/livewire/admin/mail-pace.blade.php routes/web.php lang tests/Feature/Admin/MailPacePageTest.php +git commit -m "Sicht auf die Spuren, und ein Schalter, der die Drossel abstellt + +Wenn die Drossel je klemmt, muss der Ausweg ein Klick sein und kein Deployment. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 6: Die Bereitschaftsprüfung + +**Files:** +- Modify: `app/Support/Readiness/DeliveryChecks.php` +- Test: `tests/Feature/Readiness/MailPaceCheckTest.php` + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +" +``` + +--- + +### Task 7: Abschluss + +- [ ] **Step 1: Ganze Suite** + +```bash +docker compose exec -T -e HOME=/tmp -u www-data app php artisan test +``` + +- [ ] **Step 2: Codex-Durchsicht (R15)** + +```bash +export PATH="$HOME/.local/bin:$PATH" +export CLAUDE_PLUGIN_ROOT=/home/nexxo/.claude/remote/plugins/e3a3a3c04ad124ae +node "$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs" review "--scope branch --base " +``` + +Befunde einarbeiten. Nach R22: höchstens zwei Runden ohne P1. + +- [ ] **Step 3: Ein Lauf im Trockenen** + +Fünfzig Mails in `mail-ruhig` einreihen und zusehen, dass sie im Takt +hinausgehen und keine in `failed_jobs` landet. **Am echten Arbeiter, nicht im +Test** — das ist der Beweis, den kein Test ersetzt. + +- [ ] **Step 4: Version, Tag, Übergabe** + +`VERSION` erhöhen, Release-Commit, `v*`-Tag — jeder Schritt einzeln, nie mit +`&&` verkettet, und mit Blick auf `git branch --show-current` davor. **Der +Arbeiter-Container muss beim Ausrollen neu gestartet werden**, sonst liest er +die neuen Spuren nicht. + +--- + +## Selbstprüfung des Plans + +**Abdeckung des Entwurfs:** Drei Spuren und Zuordnung → Task 1. Einreihen → +Task 2. Takt, Bindung, Falle mit den Versuchen → Task 3. Notschalter → Task 3 +(Prüfung) und Task 5 (Schalter). Arbeiter liest die Spuren → Task 4. Sicht → +Task 5. Bereitschaftsprüfung → Task 6. Kein Zeitfenster → nirgends gebaut, +richtig so. + +**Bewusst als Rumpf notiert, nicht als fertiger Code:** der Versuchszähler-Test +(Task 3, Schritt 1) und die Alters­prüfung einer Schlange (Task 6). Beide hängen +an Umgebungsverhalten, das ich nicht gemessen habe — geratener Testcode wäre +dort schlimmer als ein benannter Auftrag. Beide Stellen sagen ausdrücklich, was +bewiesen werden muss. + +**Vermutete Namen, die der Umsetzende prüfen muss:** die Konstruktoren von +`InvoiceMail` und `ResetPasswordMail` (Task 2), ob `$this->mailable->queue` den +Schlangennamen trägt (Task 3), die Fähigkeit für die Konsolenseite (Task 5), die +Zeilennummer des `queue`-Dienstes in `docker-compose.yml` (Task 4).