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>
feat/versandtakt
nexxo 2026-08-03 15:31:31 +02:00
parent 2ab6bd22a3
commit 4db1957f06
2 changed files with 167 additions and 0 deletions

View File

@ -0,0 +1,117 @@
<?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();
}
}

View File

@ -0,0 +1,50 @@
<?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);
});