34 KiB
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-direktungedrosselt Wichtig mail-wichtig30 je 5 Minuten Zeit lassen mail-ruhig20 je 10 Minuten - Die Zuordnung, wörtlich:
mail-direkt:ResetPasswordMail,VerifyEmailMail,NewDeviceSignInMail,SecurityBlockMail,ContactRequestMail,OrderConfirmationMail,OperatorMessageMailmail-wichtig:MaintenanceAnnouncementMail,MaintenanceCancelledMail,CloudSuspendedMail,CloudResumedMailmail-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.mdgelten: 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:
SendQueuedMailablehat keinemiddleware()-Methode (geprüft invendor/laravel/framework/src/Illuminate/Mail/SendQueuedMailable.php: nurhandle,backoff,retryUntil,failed,displayName,__clone). Einemiddleware()auf der Mailklasse wird also von niemandem gelesen. Der Weg führt über einen eigenen Auftrag.- Der Auftrag wird über den Container erzeugt.
Mailable::newQueuedJob()ruftContainer::getInstance()->make(SendQueuedMailable::class, ['mailable' => $this]). Eine Bindung im Dienstanbieter tauscht die Klasse also für alle Mails aus, ohne eine einzige Absendestelle anzufassen. - Die Spur muss vor dem Einreihen feststehen.
Mailable::queue()liest den Schlangennamen und reicht ihn anpushOn()weiter. Ein Trait, dasqueue()überschreibt, greift rechtzeitig — eine Trait-Methode schlägt die geerbte Methode der Elternklasse. Limit::perMinutes($decayMinutes, $maxAttempts)— die Reihenfolge ist Minuten zuerst.Limit::perMinutes(5, 30)heißt „30 in 5 Minuten".- 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
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
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
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 deruse-Zeile) - Test:
tests/Feature/Mail/MailLaneRoutingTest.php
Interfaces:
-
Consumes:
MailLane::for(string $mailableClass): stringaus Task 1 -
Produces: jede Mailklasse wird auf die Schlange ihrer Spur eingereiht
-
Step 1: Den fehlschlagenden Test schreiben
tests/Feature/Mail/MailLaneRoutingTest.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
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:
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:
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
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|CALMaus Task 1 -
Produces: Kontingente
mail-wichtig(30 je 5 Min) undmail-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
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:
- Über den echten Arbeiter: die Aufträge einreihen und
php artisan queue:work --oncein einer Schleife fahren, bis die Schlange leer ist, danachfailed_jobsprüfen. Ehrlich, aber langsam. - Über den Auftrag selbst:
PacedMail::retryUntil()und das Verhalten der Drossel-Zwischenschicht direkt prüfen — dass sie zurücklegt statt zu werfen, und dassretryUntilweit genug in der Zukunft liegt, dass--triesgar 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
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
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:
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(Dienstqueue) -
Test:
tests/Feature/Mail/MailWorkerReadsLanesTest.php -
Step 1: Den fehlschlagenden Test schreiben
<?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:
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
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
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
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
/**
* 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
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
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test
- Step 2: Codex-Durchsicht (R15)
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 Altersprü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).