# 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).
