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.

Eigene Seite (admin/mail-pace) statt eines fünften Abschnitts auf admin/mail:
die dortige Seite ist bereits "alles in einer Wurst", und diese hier
beobachtet laufenden Betrieb statt etwas einzurichten — deshalb auch in der
Navigation unter "Betrieb", nicht unter "System".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 17:44:29 +02:00
parent c78866e360
commit 1886bf2076
10 changed files with 454 additions and 1 deletions

View File

@ -0,0 +1,168 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Illuminate\Support\Facades\Queue;
use Livewire\Attributes\Layout;
use Livewire\Component;
use RuntimeException;
/**
* Der Notschalter und die Sicht auf die drei Versandspuren.
*
* Eigene Seite statt eines fünften Abschnitts auf admin/mail (App\Livewire\
* Admin\Mail): dort liegen Server, Postfächer, Zwecke und Wegwahl bereits
* „alles in einer Wurst" — eine weitere Scheibe wäre keine Verbesserung. Es
* ist auch inhaltlich eine andere Art Seite: admin/mail RICHTET EIN (einmal,
* und es gilt), diese hier BEOBACHTET UND GREIFT EIN (laufend, im Ernstfall
* mit einem Klick) deshalb steht sie in der Navigation unter „Betrieb", wo
* schon Provisioning, Wartungen und Störungen stehen, nicht unter „System".
*
* Dieselbe Fähigkeit wie admin/mail (`mail.manage`): wer die Postfächer und
* den Server sehen und ändern darf, darf auch die Spuren sehen und die
* Drossel abstellen.
*/
#[Layout('layouts.admin')]
class MailPace extends Component
{
public bool $enabled = true;
public int $urgentCount = 30;
public int $urgentMinutes = 5;
public int $calmCount = 20;
public int $calmMinutes = 10;
public function mount(): void
{
$this->authorize('mail.manage');
$this->enabled = Settings::bool('mail.pace.enabled', true);
$this->urgentCount = (int) Settings::get('mail.pace.urgent.count', 30);
$this->urgentMinutes = (int) Settings::get('mail.pace.urgent.minutes', 5);
$this->calmCount = (int) Settings::get('mail.pace.calm.count', 20);
$this->calmMinutes = (int) Settings::get('mail.pace.calm.minutes', 10);
}
/**
* Der Notschalter: nach dem Muster von Admin\Plans::toggleSales() eine
* Methode, eine Rückmeldung, kein Bestätigungsmodal. Er ist umkehrbar,
* derselbe Klick legt ihn zurück.
*/
public function togglePace(): void
{
$this->authorize('mail.manage');
$this->enabled = ! $this->enabled;
Settings::set('mail.pace.enabled', $this->enabled);
$this->dispatch('notify', message: __($this->enabled ? 'mail_pace.enabled_on_notice' : 'mail_pace.enabled_off_notice'));
}
/**
* Die beiden Kontingente speichern.
*
* `min:1` auf allen vier Feldern ist die zweite Hälfte der Untergrenze aus
* MailPaceServiceProvider::mindestensEins() die liest nur ab, was hier
* gar nicht erst gespeichert werden darf. Ohne diese Prüfung käme eine
* eingetippte 0 ungeprüft bis in die Einstellung durch und flöge erst beim
* nächsten Auftrag an der Lesestelle auf, nachdem der Betreiber schon
* „gespeichert" gesehen hat — ein Kontingent von 0 heißt dort: jede Mail
* wird endlos zurückgelegt, ein Fenster von 0 Minuten schaltet die Drossel
* still ab.
*/
public function savePace(): void
{
$this->authorize('mail.manage');
$data = $this->validate([
'urgentCount' => ['required', 'integer', 'min:1'],
'urgentMinutes' => ['required', 'integer', 'min:1'],
'calmCount' => ['required', 'integer', 'min:1'],
'calmMinutes' => ['required', 'integer', 'min:1'],
]);
Settings::set('mail.pace.urgent.count', $data['urgentCount']);
Settings::set('mail.pace.urgent.minutes', $data['urgentMinutes']);
Settings::set('mail.pace.calm.count', $data['calmCount']);
Settings::set('mail.pace.calm.minutes', $data['calmMinutes']);
$this->dispatch('notify', message: __('mail_pace.saved'));
}
/**
* Eine Mailklasse in eine andere Spur verschieben.
*
* Ein Ziel außerhalb der drei bekannten Spuren kommt nur über einen
* manuellen Aufruf zustande kein <select> im DOM bietet eines an und
* wird verworfen statt in die Einstellungen zu wandern, wo `MailLane::
* for()` es beim nächsten Lesen ohnehin still auf „ruhig" zurückfallen
* ließe, ohne dass der Betreiber sähe, dass sein Klick nichts bewirkt hat.
*/
public function move(string $mailableClass, string $lane): void
{
$this->authorize('mail.manage');
if (! in_array($lane, [MailLane::DIRECT, MailLane::URGENT, MailLane::CALM], true)) {
return;
}
try {
MailLane::assign($mailableClass, $lane);
} catch (RuntimeException) {
// Gesperrt: der Server lehnt so oder so ab (Task 1), das Schloss in
// der Ansicht ist nur die Höflichkeit davor. Wer trotzdem hier
// ankommt — kein <select> bietet einer gesperrten Klasse eines an,
// also nur ein manueller Aufruf — bekommt eine Meldung statt eines
// Serverfehlers.
$this->dispatch('notify', message: __('mail_pace.locked_notice'));
return;
}
$this->dispatch('notify', message: __('mail_pace.moved'));
}
public function render()
{
// Je Spur: die Warteschlange, die App\Mail\Concerns\RidesALane
// tatsächlich befüllt (Task 2) — derselbe Name, den auch der Arbeiter
// in docker-compose.yml abhört (Task 4). Im echten Betrieb (Redis)
// eine ehrliche Zahl aus der tatsächlichen Liste; unter der
// Sync-Warteschlange, die die Testsuite erzwingt, immer 0, weil dort
// nichts je wartet statt verschickt zu werden — dort ist die Zahl
// ebenso ehrlich, nur bedeutungslos für einen Testfall, der einen
// Rückstand nachstellen wollte.
$lanes = [
[
'key' => MailLane::DIRECT,
'throttled' => false,
'waiting' => Queue::size(MailLane::DIRECT),
],
[
'key' => MailLane::URGENT,
'throttled' => true,
'waiting' => Queue::size(MailLane::URGENT),
'countField' => 'urgentCount',
'minutesField' => 'urgentMinutes',
],
[
'key' => MailLane::CALM,
'throttled' => true,
'waiting' => Queue::size(MailLane::CALM),
'countField' => 'calmCount',
'minutesField' => 'calmMinutes',
],
];
return view('livewire.admin.mail-pace', [
'lanes' => $lanes,
'laneOptions' => [MailLane::DIRECT, MailLane::URGENT, MailLane::CALM],
'assignments' => MailLane::all(),
]);
}
}

View File

@ -81,6 +81,9 @@ final class Navigation
['admin.provisioning', 'activity', 'provisioning', null],
['admin.maintenance', 'alert-triangle', 'maintenance', null],
['admin.incidents', 'bell', 'incidents', null],
// Läuft, statt eingerichtet zu werden — deshalb hier und nicht
// neben admin.mail unter System.
['admin.mail-pace', 'gauge', 'mail_pace', 'mail.manage'],
]],
// Alles, wo Geld drinsteht.
['label' => __('admin.nav_group.billing'), 'items' => [

View File

@ -37,6 +37,7 @@ return [
'payment_problems' => 'Zahlungsprobleme',
'revenue' => 'Umsatz',
'mail' => 'E-Mail',
'mail_pace' => 'Versandtakt',
'integrations' => 'Integrationen',
'readiness' => 'Bereitschaft',
'settings' => 'Einstellungen',

38
lang/de/mail_pace.php Normal file
View File

@ -0,0 +1,38 @@
<?php
// Der Notschalter und die Sicht auf die drei Versandspuren. Eigene Datei statt
// eines Abschnitts in mail_settings.php: eigene Seite, eigene Berechtigung an
// derselben Stelle geprüft, aber ein eigenständiges Thema (laufender Betrieb,
// nicht Einrichtung).
return [
'eyebrow' => 'Betrieb',
'title' => 'Versandtakt',
'subtitle' => 'Wie schnell die drei Spuren fahren, was gerade wartet, und der Schalter, der die Drossel im Ernstfall sofort abstellt.',
'enabled' => 'Drossel',
'enabled_hint' => 'Ein Umlegen wirkt erst für neu eingereihte Mails voll: bereits wartende Aufträge behalten das Zeitfenster oder die Drossel, mit der sie eingereiht wurden.',
'enabled_on' => 'Aktiv',
'enabled_off' => 'Aus',
'enabled_on_notice' => 'Drossel wieder eingeschaltet.',
'enabled_off_notice' => 'Drossel abgeschaltet — jede Mail fährt jetzt ungebremst.',
'lanes_title' => 'Die drei Spuren',
'lanes_sub' => 'Direkt fährt immer ungedrosselt — dort wartet gerade ein Mensch. Wichtig und ruhig haben ein eigenes Kontingent; beide Zahlen müssen mindestens eins sein, sonst wird entweder jede Mail endlos zurückgelegt oder gar nicht mehr gedrosselt.',
'lane' => [
'mail-direkt' => 'Direkt',
'mail-wichtig' => 'Wichtig',
'mail-ruhig' => 'Ruhig',
],
'unthrottled' => 'ungedrosselt',
'count' => 'Kontingent',
'minutes' => 'Minuten',
'waiting' => ':count wartend',
'save' => 'Speichern',
'saved' => 'Takt gespeichert.',
'assignments_title' => 'Zuordnung',
'assignments_sub' => 'Welche Mailklasse in welcher Spur fährt. Gesperrte Klassen fahren immer direkt — darauf wartet gerade jemand, drosseln würde dort nur schaden.',
'locked_hint' => 'Immer direkt (gesperrt)',
'locked_notice' => 'Diese Mail fährt immer direkt: darauf wartet gerade jemand.',
'moved' => 'Verschoben.',
];

View File

@ -37,6 +37,7 @@ return [
'payment_problems' => 'Payment problems',
'revenue' => 'Revenue',
'mail' => 'Email',
'mail_pace' => 'Send pace',
'integrations' => 'Integrations',
'readiness' => 'Readiness',
'settings' => 'Settings',

37
lang/en/mail_pace.php Normal file
View File

@ -0,0 +1,37 @@
<?php
// The kill switch and the view onto the three send lanes. Its own file rather
// than a section of mail_settings.php: its own page, the same capability
// check, but a different subject (running operation, not one-time setup).
return [
'eyebrow' => 'Operations',
'title' => 'Send pace',
'subtitle' => 'How fast the three lanes move, what is waiting right now, and the switch that stops the throttle immediately if it jams.',
'enabled' => 'Throttle',
'enabled_hint' => 'Flipping this only fully applies to mail queued from now on: jobs already waiting keep whichever time window or throttle they were queued with.',
'enabled_on' => 'On',
'enabled_off' => 'Off',
'enabled_on_notice' => 'Throttle switched back on.',
'enabled_off_notice' => 'Throttle switched off — every mail now sends unthrottled.',
'lanes_title' => 'The three lanes',
'lanes_sub' => 'Direct always runs unthrottled — a human is waiting there right now. Urgent and calm each have their own allowance; both numbers must be at least one, or mail is either delayed forever or not throttled at all.',
'lane' => [
'mail-direkt' => 'Direct',
'mail-wichtig' => 'Urgent',
'mail-ruhig' => 'Calm',
],
'unthrottled' => 'unthrottled',
'count' => 'Allowance',
'minutes' => 'Minutes',
'waiting' => ':count waiting',
'save' => 'Save',
'saved' => 'Pace saved.',
'assignments_title' => 'Assignment',
'assignments_sub' => 'Which mail class rides in which lane. Locked classes always ride direct — someone is waiting on them right now, and throttling would only hurt.',
'locked_hint' => 'Always direct (locked)',
'locked_notice' => 'This mail always rides direct: someone is waiting on it right now.',
'moved' => 'Moved.',
];

View File

@ -0,0 +1,93 @@
<div class="space-y-6">
<header class="animate-rise">
<p class="lbl">{{ __('mail_pace.eyebrow') }}</p>
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
{{ __('mail_pace.title') }}
</h1>
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('mail_pace.subtitle') }}</p>
</header>
{{-- Der Notschalter, ganz oben und in einem eigenen Panel: das ist der
Griff, den jemand im Ernstfall zuerst sucht, nicht der letzte von
mehreren. Kein Bestätigungsmodal (R23 verlangt eines nur dort, wo ein
Vorgang Folgen hat, die man nicht zurückdrehen kann) dieser Schalter
ist umkehrbar, derselbe Klick legt ihn zurück, genau wie
Admin\Plans::toggleSales(). --}}
<x-ui.panel class="animate-rise [animation-delay:20ms]">
<div class="px-6 py-4">
<x-ui.switch name="pace_enabled" wire:click="togglePace" :checked="$enabled"
:label="__('mail_pace.enabled')"
:hint="__('mail_pace.enabled_hint')"
:on="__('mail_pace.enabled_on')"
:off="__('mail_pace.enabled_off')" />
</div>
</x-ui.panel>
{{-- Die drei Spuren: Name, Takt, was gerade wartet. --}}
<form wire:submit="savePace" class="animate-rise [animation-delay:40ms]">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('mail_pace.lanes_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('mail_pace.lanes_sub') }}</p>
<x-ui.panel class="mt-4">
@foreach ($lanes as $lane)
<x-ui.row :label="__('mail_pace.lane.'.$lane['key'])" :for="$lane['throttled'] ? $lane['countField'] : null">
<div class="flex flex-wrap items-end gap-3">
<span class="font-mono text-xs text-muted">{{ $lane['key'] }}</span>
@if ($lane['throttled'])
<div class="w-24">
<x-ui.input :name="$lane['countField']" type="number" min="1"
wire:model="{{ $lane['countField'] }}"
:label="__('mail_pace.count')" />
</div>
<div class="w-28">
<x-ui.input :name="$lane['minutesField']" type="number" min="1"
wire:model="{{ $lane['minutesField'] }}"
:label="__('mail_pace.minutes')" />
</div>
@else
<x-ui.badge status="info">{{ __('mail_pace.unthrottled') }}</x-ui.badge>
@endif
<x-ui.badge status="info">{{ __('mail_pace.waiting', ['count' => $lane['waiting']]) }}</x-ui.badge>
</div>
{{-- Kein eigenes @error hier: x-ui.input zeigt die Meldung
zum jeweiligen Feld schon selbst unter sich an. --}}
</x-ui.row>
@endforeach
</x-ui.panel>
<div class="mt-4 flex justify-end">
<x-ui.button variant="primary" type="submit">{{ __('mail_pace.save') }}</x-ui.button>
</div>
</form>
{{-- Die Zuordnung: welche Mailklasse in welcher Spur fährt. Ein <select>
pro Zeile, R20's ausdrückliche Ausnahme dieselbe Zeile, kein
Höhensprung. Gesperrte Klassen zeigen ein Schloss statt der Auswahl:
der Server lehnt sie ohnehin ab (Task 1, MailLane::assign()), das
Schloss ist nur die Höflichkeit davor. --}}
<section class="animate-rise [animation-delay:60ms]">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('mail_pace.assignments_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('mail_pace.assignments_sub') }}</p>
<x-ui.panel class="mt-4">
@foreach ($assignments as $class => $lane)
<x-ui.row :label="class_basename($class)">
@if (\App\Services\Mail\MailLane::isLocked($class))
<span class="inline-flex items-center gap-1.5 text-sm text-muted">
<x-ui.icon name="lock" class="size-4" />{{ __('mail_pace.locked_hint') }}
</span>
@else
<select wire:change="move('{{ $class }}', $event.target.value)"
class="w-48 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@foreach ($laneOptions as $option)
<option value="{{ $option }}" @selected($lane === $option)>{{ __('mail_pace.lane.'.$option) }}</option>
@endforeach
</select>
@endif
</x-ui.row>
@endforeach
</x-ui.panel>
</section>
</div>

View File

@ -123,6 +123,10 @@ Route::get('/invoices/{uuid}/pdf', function (string $uuid) {
);
})->name('invoices.pdf');
Route::get('/mail', Admin\Mail::class)->name('mail');
// Der Notschalter und die Sicht auf die drei Versandspuren — eigene Route statt
// eines weiteren Abschnitts auf admin/mail (siehe deren Kommentar oben), weil
// diese Seite den laufenden Betrieb beobachtet statt etwas einzurichten.
Route::get('/mail-pace', Admin\MailPace::class)->name('mail-pace');
Route::get('/integrations', Admin\Integrations::class)->name('integrations');
// The former admin.secrets and admin.infrastructure pages, merged into the
// one above — grouped by what each value configures, not by which of the two

View File

@ -43,7 +43,8 @@ it('verliert beim Umsortieren keinen Eintrag und legt keinen doppelt an', functi
// Die Zahl steht hier bewusst als Zahl: sinkt sie, ist beim Umsortieren
// ein Eintrag unter den Tisch gefallen, und genau das sähe niemand.
expect($routes)->toHaveCount(27);
// 28 statt 27 seit admin.mail-pace (Versandtakt) dazukam.
expect($routes)->toHaveCount(28);
});
it('führt keinen Eintrag, dessen Route es nicht gibt', function () {

View File

@ -0,0 +1,107 @@
<?php
use App\Livewire\Admin\MailPace;
use App\Mail\InvoiceMail;
use App\Mail\ResetPasswordMail;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Livewire\Livewire;
/**
* Die Sicht auf die drei Spuren, und der Notschalter davor.
*
* `mail.manage` ist dieselbe Fähigkeit wie auf admin/mail (App\Livewire\
* Admin\Mail) dort liegen die anderen Maileinstellungen, und wer die lesen
* und ändern darf, darf auch sehen, was in den Spuren wartet, und die Drossel
* abstellen.
*/
it('zeigt jede Spur mit ihrem Takt und dem, was wartet', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailPace::class)
->assertSee(MailLane::DIRECT)
->assertSee(MailLane::URGENT)
->assertSee(MailLane::CALM);
});
it('laesst den Betreiber eine Mail verschieben', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(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::actingAs(operator('Owner'), 'operator')
->test(MailPace::class)
->call('move', ResetPasswordMail::class, MailLane::CALM);
expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT);
});
it('zeigt eine gesperrte Klasse als gesperrt statt mit einem Auswahlfeld', function () {
// Das Schloss ist nur die Höflichkeit vor der serverseitigen Ablehnung im
// Test darüber — aber eine Auswahl, die eine gesperrte Klasse anbietet,
// wäre die Einladung zu einem Klick, der ohnehin nichts bewirkt.
$html = Livewire::actingAs(operator('Owner'), 'operator')
->test(MailPace::class)
->assertSee(class_basename(ResetPasswordMail::class))
->html();
expect($html)->not->toContain("move('".ResetPasswordMail::class."'");
});
it('schaltet die Drossel ab und wieder an', function () {
$component = Livewire::actingAs(operator('Owner'), 'operator')
->test(MailPace::class)
->call('togglePace');
expect(Settings::bool('mail.pace.enabled', true))->toBeFalse();
$component->call('togglePace');
expect(Settings::bool('mail.pace.enabled', true))->toBeTrue();
});
/**
* Die zweite Hälfte der Untergrenze aus MailPaceServiceProvider::
* mindestensEins() die dortige liest nur ab, was hier gar nicht erst
* gespeichert werden darf. Ohne diese Prüfung käme eine eingetippte 0 durch
* bis zur Einstellung und würde erst beim nächsten Auftrag an der Lesestelle
* abgefangen, nachdem der Betreiber schon „gespeichert" gesehen hat.
*/
it('verweigert ein Kontingent von null oder weniger', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailPace::class)
->set('urgentCount', 0)
->call('savePace')
->assertHasErrors(['urgentCount' => 'min']);
expect(Settings::get('mail.pace.urgent.count'))->toBeNull();
});
it('verweigert ein Fenster von null Minuten', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailPace::class)
->set('calmMinutes', 0)
->call('savePace')
->assertHasErrors(['calmMinutes' => 'min']);
expect(Settings::get('mail.pace.calm.minutes'))->toBeNull();
});
it('speichert einen gueltigen Takt fuer beide gedrosselten Spuren', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailPace::class)
->set('urgentCount', 12)
->set('urgentMinutes', 3)
->set('calmCount', 7)
->set('calmMinutes', 9)
->call('savePace')
->assertHasNoErrors();
expect(Settings::get('mail.pace.urgent.count'))->toBe(12)
->and(Settings::get('mail.pace.urgent.minutes'))->toBe(3)
->and(Settings::get('mail.pace.calm.count'))->toBe(7)
->and(Settings::get('mail.pace.calm.minutes'))->toBe(9);
});