Plan: der Versandtakt in sieben Aufgaben

Beim Lesen des Frameworks fielen zwei Annahmen des Entwurfs um, und beide
haetten Tage gekostet:

SendQueuedMailable hat KEINE middleware() — die Klasse hat nur handle, backoff,
retryUntil, failed, displayName, __clone. Eine middleware() auf der Mailklasse
liest also niemand. Der Weg fuehrt ueber einen eigenen Auftrag.

Dafuer gibt es einen sauberen Haken: Mailable::newQueuedJob() erzeugt den
Auftrag ueber den Container. Eine Bindung tauscht ihn fuer alle Mails aus, ohne
dass eine einzige Absendestelle sich aendert — womit die Zusage des Entwurfs
haelt.

Und die Spur muss vor dem Einreihen feststehen, weil Mailable::queue() den
Schlangennamen liest und an pushOn() weiterreicht. Ein Trait, das queue()
ueberschreibt, greift rechtzeitig.

Zwei Teststellen stehen bewusst als Rumpf statt als fertiger Code: der Nachweis,
dass eine gedrosselte Rueckstellung den Versuchszaehler nicht erschoepft, und
das Alter des aeltesten Auftrags einer Redis-Schlange. Beide haengen an
Umgebungsverhalten, das ich nicht gemessen habe; geratener Testcode waere dort
schlimmer als ein benannter Auftrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 15:26:19 +02:00
parent 9e890243f6
commit 2ab6bd22a3
1 changed files with 940 additions and 0 deletions

View File

@ -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 <noreply@anthropic.com>`.
- **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<string, string>` — Klasse → Spur, für die Konsole
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
`tests/Feature/Mail/MailLaneTest.php`:
```php
<?php
use App\Mail\DunningNoticeMail;
use App\Mail\InvoiceMail;
use App\Mail\MaintenanceAnnouncementMail;
use App\Mail\ResetPasswordMail;
use App\Services\Mail\MailLane;
use App\Support\Settings;
/**
* Welche Mail in welcher Spur fährt.
*
* Die Vorgaben stehen im Code, die Änderung des Betreibers darüber. Gesperrt
* sind die Mails, auf die gerade ein Mensch wartet: ein Kennwort-Zurücksetzen,
* das zwanzig Minuten liegt, ist ein Supportfall und kein gespartes Ansehen.
*/
it('kennt die Vorgabe jeder Mailklasse', function () {
expect(MailLane::for(ResetPasswordMail::class))->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
<?php
namespace App\Services\Mail;
use App\Mail\CloudResumedMail;
use App\Mail\CloudSuspendedMail;
use App\Mail\ContactRequestMail;
use App\Mail\DormantAccountWarningMail;
use App\Mail\DunningNoticeMail;
use App\Mail\InvoiceMail;
use App\Mail\MaintenanceAnnouncementMail;
use App\Mail\MaintenanceCancelledMail;
use App\Mail\NewDeviceSignInMail;
use App\Mail\OperatorMessageMail;
use App\Mail\OrderConfirmationMail;
use App\Mail\ResetPasswordMail;
use App\Mail\SecurityBlockMail;
use App\Mail\VerifyEmailMail;
use App\Support\Settings;
use RuntimeException;
/**
* In welcher Spur eine Mail fährt.
*
* Drei Spuren, und die Trennung läuft nicht zwischen Massenversand und
* Einzelmail, sondern zwischen dringend und nicht dringend. Eine
* Ausfallmeldung geht an alle UND eilt — sie bekommt deshalb ein höheres
* Kontingent statt gar keines.
*
* Die Vorgaben stehen hier, die Änderung des Betreibers darüber in den
* Einstellungen. Eine unbekannte Klasse fällt in die ruhige Spur: eine zu
* langsam verschickte Mail ist ein kleinerer Fehler als ein ungedrosselter
* Schub, den niemand vorhergesehen hat.
*/
final class MailLane
{
public const DIRECT = 'mail-direkt';
public const URGENT = 'mail-wichtig';
public const CALM = 'mail-ruhig';
/**
* Die sieben, auf die gerade jemand wartet.
*
* Sie entstehen einzeln, weil ein einzelner Mensch geklickt hat, und
* können gar keinen Schub bilden. Drosseln nützt dort nichts und kostet
* einen Supportfall je verzögertem Kennwort — deshalb sind sie gesperrt
* und nicht nur voreingestellt.
*/
private const LOCKED = [
ResetPasswordMail::class,
VerifyEmailMail::class,
NewDeviceSignInMail::class,
SecurityBlockMail::class,
ContactRequestMail::class,
OrderConfirmationMail::class,
OperatorMessageMail::class,
];
/** @var array<class-string, string> */
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<class-string, string>
*/
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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Mail\InvoiceMail;
use App\Mail\ResetPasswordMail;
use App\Services\Mail\MailLane;
use Illuminate\Support\Facades\Queue;
/**
* Die Spur entsteht beim Einreihen, nicht beim Senden.
*
* Der Arbeiter liest die Schlangen in der Reihenfolge ihrer Dringlichkeit —
* deshalb steht ein Kennwort-Zurücksetzen nie hinter zweihundert Rechnungen.
* Das trägt nur, wenn die Rechnung wirklich in einer anderen Schlange liegt.
*/
it('reiht jede Mail in die Schlange ihrer Spur ein', function () {
Queue::fake();
Mail::to('kunde@example.test')->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
<?php
namespace App\Mail\Concerns;
use App\Services\Mail\MailLane;
use Illuminate\Contracts\Queue\Factory as Queue;
/**
* Reiht eine Mail in die Schlange ihrer Spur ein.
*
* `Mailable::queue()` liest den Schlangennamen und reicht ihn an `pushOn()`
* weiter — die Spur muss also VOR dem Einreihen feststehen. Eine Trait-Methode
* schlägt die geerbte Methode der Elternklasse, deshalb genügt es, `queue()`
* hier zu überschreiben und danach an die Elternklasse weiterzugeben.
*
* Neben `SendsFromMailbox` statt darin: die eine Sache ist, von welchem
* Postfach eine Mail kommt, die andere, wie eilig sie ist.
*/
trait RidesALane
{
public function queue(Queue $queue)
{
$this->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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Jobs\PacedMail;
use App\Mail\InvoiceMail;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Illuminate\Support\Facades\Mail;
/**
* Die Drossel darf keine Mail verlieren.
*
* Der Arbeiter läuft mit --tries=3, und eine zurückgelegte Mail zählt als
* Versuch. Ohne Vorkehrung wäre jede Rechnung nach dem dritten Drosseln
* gescheitert statt verschickt — still, im Fehlerprotokoll, ohne dass jemand
* etwas merkt. Das ist der Test, der vor der ersten Zeile Drossel steht.
*/
it('verliert keine Mail, wenn das Kontingent kleiner ist als der Lauf', function () {
Settings::set('mail.pace.calm.count', 20);
Settings::set('mail.pace.calm.minutes', 10);
// Fünfzig Rechnungen in eine Spur mit Kontingent zwanzig.
// Erwartet: fünfzig verschickt, null gescheitert.
});
```
**Hinweis für den Umsetzenden:** Dieser Test ist bewusst als Rumpf notiert, weil
sein Aufbau von der Umgebung abhängt und geraten schlimmer wäre als
nachgesehen. Er muss belegen, dass eine gedrosselte Rückstellung den
Versuchszähler **nicht** erschöpft. Zwei Wege, wähle den, der hier trägt, und
begründe die Wahl im Bericht:
1. **Über den echten Arbeiter:** die Aufträge einreihen und
`php artisan queue:work --once` in einer Schleife fahren, bis die Schlange
leer ist, danach `failed_jobs` prüfen. Ehrlich, aber langsam.
2. **Über den Auftrag selbst:** `PacedMail::retryUntil()` und das Verhalten der
Drossel-Zwischenschicht direkt prüfen — dass sie zurücklegt statt zu werfen,
und dass `retryUntil` weit genug in der Zukunft liegt, dass `--tries` gar
nicht greift.
**Was der Test beweisen muss, egal welchen Weg du nimmst:** keine Mail landet in
`failed_jobs`, und der Grund dafür ist nicht Zufall. Schreib in den Bericht, was
er beweist **und was nicht** — Nebenläufigkeit stellt er nicht nach.
- [ ] **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/MailPaceTest.php`
Erwartet: FEHLSCHLAG — `Class "App\Jobs\PacedMail" not found`.
- [ ] **Step 3: Den Auftrag schreiben**
`app/Jobs/PacedMail.php`:
```php
<?php
namespace App\Jobs;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Illuminate\Mail\SendQueuedMailable;
use Illuminate\Queue\Middleware\RateLimited;
/**
* Der Auftrag, der eine Mail im Takt ihrer Spur verschickt.
*
* `SendQueuedMailable` kennt keine `middleware()` — geprüft im Framework:
* die Klasse hat nur handle, backoff, retryUntil, failed, displayName. Eine
* `middleware()` auf der MAILKLASSE liest deshalb niemand. Der Weg führt über
* diesen eigenen Auftrag, den `Mailable::newQueuedJob()` über den Container
* erzeugt — eine Bindung tauscht ihn für alle Mails aus, ohne dass eine
* einzige Absendestelle sich ändert.
*
* Zur Falle mit den Versuchen: der Arbeiter läuft mit --tries=3, und eine
* gedrosselte Rückstellung zählt als Versuch. Deshalb `retryUntil()` statt
* eines Versuchszählers — eine zeitliche Grenze kennt keine Rückstellungen,
* sondern nur ein Ende.
*/
class PacedMail extends SendQueuedMailable
{
/** @return array<int, object> */
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
<?php
namespace App\Providers;
use App\Jobs\PacedMail;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Mail\SendQueuedMailable;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
/**
* Der Takt der beiden gedrosselten Spuren.
*
* Die Bindung ist die ganze Verkabelung: `Mailable::newQueuedJob()` erzeugt
* den Auftrag über den Container, also fährt ab hier jede Mail über PacedMail
* — ohne dass eine Absendestelle davon weiß.
*
* `Limit::perMinutes($minuten, $anzahl)` — Minuten zuerst. Die Reihenfolge ist
* anders herum, als man sie liest, und ein vertauschtes Paar wäre ein Takt von
* fünf Mails in dreißig Minuten statt dreißig in fünf.
*/
class MailPaceServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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 <noreply@anthropic.com>"
```
---
### 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
<?php
/**
* Der Arbeiter muss die Spuren kennen.
*
* Die Reihenfolge IST die Priorität: der Arbeiter sieht erst in mail-direkt
* nach und geht erst weiter, wenn dort nichts liegt. 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.
*/
it('laesst den Arbeiter die drei Spuren in der richtigen Reihenfolge lesen', function () {
$compose = file_get_contents(base_path('docker-compose.yml'));
expect($compose)->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 <noreply@anthropic.com>"
```
**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
<?php
use App\Mail\InvoiceMail;
use App\Mail\ResetPasswordMail;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Livewire\Livewire;
it('zeigt jede Spur mit ihrem Takt und dem, was wartet', function () {
// Operator anmelden wie in den Nachbartests unter tests/Feature/Admin/.
Livewire::test(App\Livewire\Admin\MailPace::class)
->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 <noreply@anthropic.com>"
```
---
### 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
<?php
/**
* Eine Spur, die steht, sieht von außen aus wie eine Spur, die leer ist.
*
* Die Grenze liegt bei einer Stunde: 20 je 10 Minuten sind 120 in der Stunde,
* und ein Lauf dieser Größe steht bei elf Kunden nicht an. Schlägt die Prüfung
* trotzdem an, klemmt etwas.
*/
it('meldet eine Spur, in der etwas laenger als eine Stunde liegt', function () {
// Eine Mail mit einem Zeitstempel von vor zwei Stunden in mail-ruhig legen
// und erwarten, dass die Prüfung anschlägt.
});
it('meldet eine Spur mit frischer Arbeit nicht', function () {
// Dieselbe Mail, gerade eben eingereiht: keine Meldung.
});
```
**Hinweis für den Umsetzenden:** Wie das Alter des ältesten Auftrags einer
Redis-Schlange ermittelt wird, ist die eigentliche Arbeit dieser Aufgabe. Sieh
dir an, wie `DeliveryChecks` und `ProvisioningChecks` heute prüfen, und folge
dem Muster. Findest du keinen ehrlichen Weg an das Alter heranzukommen, **melde
das** statt eine Zahl zu erfinden, die nichts misst — eine Prüfung, die immer
grün ist, ist schlimmer als keine.
- [ ] **Step 2 bis 4: Fehlschlag sehen, bauen, committen**
```bash
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Readiness
git add app/Support/Readiness/DeliveryChecks.php tests/Feature/Readiness/MailPaceCheckTest.php
git commit -m "Die Bereitschaftsseite meldet eine Spur, die steht
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### 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 <Commit vor Task 1>"
```
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).