Wegwahl je Mailart: welche Mail aus welchem Postfach geht

MailCatalogue haelt die eine Liste aller sechzehn Mailarten (Schluessel,
Beschriftung, Vorgabe-Zweck), aus MailPreviews herausgezogen, damit es
nur noch eine Stelle gibt, die beim naechsten Mailtyp vergessen werden
kann. MailRoute sitzt darueber: ein Eintrag ist eine Ausnahme fuer GENAU
diese eine Mailart, keine zweite Zuordnungsebene — ohne Eintrag oder bei
abgeschaltetem Zielpostfach faellt sie unveraendert auf den Zweck
zurueck, den MailboxResolver schon kennt.

SendsFromMailbox bekommt dafuer einen optionalen $mailKey; alle
bestehenden Aufrufer (inklusive ContactRequestMail, das mailboxAddresses
selbst zusammensetzt) bleiben bei null und damit beim alten Verhalten.
Jede Mailklasse und die CloudReady-Benachrichtigung nennen jetzt ihren
Katalog-Schluessel. Die Konsole bekommt eine vierte Karte unter der
Zweck-Zuordnung: eine Zeile je Mailart, ein <select> mit den aktiven
Postfaechern und "wie der Zweck (...)" als Vorgabe.

Der wichtigste Test schickt eine Mail ohne jeden Eintrag und prueft,
dass sie exakt beim bisherigen Postfach landet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 12:43:31 +02:00
parent e3f2e9a88b
commit 5dcfee9957
22 changed files with 276 additions and 35 deletions

View File

@ -5,7 +5,9 @@ namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailboxTester;
use App\Services\Mail\MailCatalogue;
use App\Services\Mail\MailPurpose;
use App\Services\Mail\MailRoute;
use App\Services\Secrets\SecretCipher;
use App\Support\MailDelivery;
use App\Support\Settings;
@ -43,6 +45,17 @@ class Mail extends Component
/** @var array<string, string> purpose => mailbox key */
public array $purposes = [];
/**
* Die Wegwahl je Mailart leer heißt „wie der Zweck".
*
* Eine Ebene unter $purposes, nicht daneben: dieselbe Seite, dieselbe
* Berechtigung, aber eine eigene Tabelle, weil hier sechzehn Zeilen statt
* fünf stehen.
*
* @var array<string, string> Katalog-Schlüssel => Postfach-Schlüssel
*/
public array $routes = [];
public string $testRecipient = '';
/** @var array{ok: bool, error: ?string}|null */
@ -76,6 +89,10 @@ class Mail extends Component
foreach (MailPurpose::ALL as $purpose) {
$this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
}
foreach (MailCatalogue::all() as $key => $entry) {
$this->routes[$key] = (string) Settings::get(MailRoute::settingKey($key), '');
}
}
public function saveServer(): void
@ -190,6 +207,25 @@ class Mail extends Component
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
}
/**
* Die Wegwahl je Mailart. Nach dem Muster von savePurposes() dieselbe
* Berechtigung, dieselbe Meldung aber ohne dessen Regelwerk: ein
* Eintrag, der auf kein oder ein abgeschaltetes Postfach zeigt, ist hier
* kein Fehler, weil MailRoute::purposeOrMailbox() genau diesen Fall schon
* auf den Zweck zurückfallen lässt. Eine zweite Prüfung derselben
* Sicherung wäre doppelte Arbeit ohne eigenen Wert.
*/
public function saveRoutes(): void
{
$this->authorize('mail.manage');
foreach (MailCatalogue::all() as $key => $entry) {
Settings::set(MailRoute::settingKey($key), $this->routes[$key] ?? '');
}
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
}
public function test(string $uuid): void
{
$this->authorize('mail.manage');
@ -212,6 +248,7 @@ class Mail extends Component
return view('livewire.admin.mail', [
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
'purposeList' => MailPurpose::ALL,
'mailCatalogue' => MailCatalogue::all(),
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}

View File

@ -33,7 +33,7 @@ class CloudResumedMail extends Mailable implements ShouldQueue
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::BILLING, __('dunning_mail.subject_resumed'));
return $this->mailboxEnvelope(MailPurpose::BILLING, __('dunning_mail.subject_resumed'), 'cloud-resumed');
}
public function content(): Content

View File

@ -33,7 +33,7 @@ class CloudSuspendedMail extends Mailable implements ShouldQueue
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::BILLING, __('dunning_mail.subject_suspended'));
return $this->mailboxEnvelope(MailPurpose::BILLING, __('dunning_mail.subject_suspended'), 'cloud-suspended');
}
public function content(): Content

View File

@ -3,11 +3,13 @@
namespace App\Mail\Concerns;
use App\Services\Mail\MailboxResolver;
use App\Services\Mail\MailRoute;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Envelope;
/**
* From and Reply-To, taken from the mailbox behind a purpose.
* From and Reply-To, taken from the mailbox behind a purpose or, with a
* $mailKey, from whatever MailRoute has that ONE mail type pointed at instead.
*
* Reply-To is the whole reason sending alone is enough: a customer answering a
* support reply lands in the support mailbox, read in an ordinary mail client,
@ -18,9 +20,9 @@ use Illuminate\Mail\Mailables\Envelope;
*/
trait SendsFromMailbox
{
protected function mailboxEnvelope(string $purpose, string $subject): Envelope
protected function mailboxEnvelope(string $purpose, string $subject, ?string $mailKey = null): Envelope
{
[$from, $replyTo] = $this->mailboxAddresses($purpose);
[$from, $replyTo] = $this->mailboxAddresses($purpose, $mailKey);
return new Envelope(
from: $from,
@ -39,11 +41,19 @@ trait SendsFromMailbox
* Two independent copies of this same decision is exactly the shape of
* gap Task 4 found and fixed in MailboxTransport::resolution().
*
* $mailKey names this ONE mail type in MailCatalogue and lets MailRoute
* override the purpose's mailbox for it alone. Left null (every caller
* before this parameter existed, and every one that still has no reason
* to be overridden individually), behaviour is exactly what it was: the
* purpose alone decides.
*
* @return array{0: ?Address, 1: ?Address} [from, replyTo]
*/
protected function mailboxAddresses(string $purpose): array
protected function mailboxAddresses(string $purpose, ?string $mailKey = null): array
{
$box = app(MailboxResolver::class)->for($purpose);
$box = $mailKey !== null
? MailRoute::purposeOrMailbox($mailKey, $purpose)
: app(MailboxResolver::class)->for($purpose);
if ($box === null) {
// No mailbox configured yet — both null, so a caller falls back

View File

@ -34,7 +34,7 @@ class DormantAccountWarningMail extends Mailable implements ShouldQueue
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('dormant_mail.subject'));
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('dormant_mail.subject'), 'dormant-warning');
}
public function content(): Content

View File

@ -52,6 +52,7 @@ class DunningNoticeMail extends Mailable implements ShouldQueue
return $this->mailboxEnvelope(
MailPurpose::BILLING,
__('dunning_mail.subject_'.$this->level),
'dunning-'.$this->level,
);
}

View File

@ -45,6 +45,7 @@ class InvoiceMail extends Mailable implements ShouldQueue
return $this->mailboxEnvelope(
MailPurpose::BILLING,
__('invoice_mail.subject', ['number' => $this->invoice->number]),
'invoice',
);
}

View File

@ -40,6 +40,7 @@ class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue
return $this->mailboxEnvelope(
MailPurpose::MAINTENANCE,
__('maintenance.mail_subject', ['title' => $this->window->title]),
'maintenance-announcement',
);
}

View File

@ -38,6 +38,7 @@ class MaintenanceCancelledMail extends Mailable implements ShouldQueue
return $this->mailboxEnvelope(
MailPurpose::MAINTENANCE,
__('maintenance.mail_cancel_subject', ['title' => $this->window->title]),
'maintenance-cancelled',
);
}

View File

@ -39,6 +39,7 @@ class NewDeviceSignInMail extends Mailable implements ShouldQueue
return $this->mailboxEnvelope(
MailPurpose::SYSTEM,
__('devices.mail_subject'),
'new-device',
);
}

View File

@ -38,7 +38,7 @@ class OperatorMessageMail extends Mailable implements ShouldQueue
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::SUPPORT, $this->subjectLine);
return $this->mailboxEnvelope(MailPurpose::SUPPORT, $this->subjectLine, 'operator-message');
}
public function content(): Content

View File

@ -47,6 +47,7 @@ class OrderConfirmationMail extends Mailable implements ShouldQueue
return $this->mailboxEnvelope(
MailPurpose::BILLING,
__('orders.mail_subject', ['number' => $this->order->uuid ?? $this->order->id]),
'order-confirmation',
);
}

View File

@ -33,7 +33,7 @@ class ResetPasswordMail extends Mailable implements ShouldQueue
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('reset_password.subject'));
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('reset_password.subject'), 'reset-password');
}
public function content(): Content

View File

@ -35,7 +35,7 @@ class VerifyEmailMail extends Mailable implements ShouldQueue
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('verify_email.subject'));
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('verify_email.subject'), 'verify-email');
}
public function content(): Content

View File

@ -46,7 +46,7 @@ class CloudReady extends Notification
{
$url = 'https://'.$this->instance->subdomain.'.'.ProvisioningSettings::dnsZone();
[$from, $replyTo] = $this->mailboxAddresses(MailPurpose::PROVISIONING);
[$from, $replyTo] = $this->mailboxAddresses(MailPurpose::PROVISIONING, 'cloud-ready');
$message = (new MailMessage)->mailer('cp_'.MailPurpose::PROVISIONING);

View File

@ -0,0 +1,58 @@
<?php
namespace App\Services\Mail;
/**
* Die eine Liste aller Mailarten, die dieses Haus verschickt.
*
* Es gab sie schon als Array in MailPreviews, nur für die Vorschau. Mit der
* Wegwahl je Mailart bräuchte es eine zweite, und die zweite Liste ist die, die
* beim siebzehnten Mail vergessen wird. Also eine, aus der beide lesen.
*
* `purpose` ist die VORGABE: das Postfach, aus dem diese Mailart geht, solange
* niemand etwas anderes einstellt. Sie muss dem entsprechen, was die Mailklasse
* heute an mailboxEnvelope() übergibt sonst ändert dieser Umbau still das
* Verhalten.
*
* Reihenfolge wie in der Vorschau: Anmeldung, Kauf, Betrieb, Geld, Sicherheit.
*/
final class MailCatalogue
{
/** @return array<string, array{label: string, purpose: string}> */
public static function all(): array
{
return [
'verify-email' => ['label' => 'E-Mail bestätigen (Registrierung)', 'purpose' => MailPurpose::SYSTEM],
'reset-password' => ['label' => 'Passwort zurücksetzen', 'purpose' => MailPurpose::SYSTEM],
'order-confirmation' => ['label' => 'Bestellbestätigung', 'purpose' => MailPurpose::BILLING],
'cloud-ready' => ['label' => 'Cloud ist bereit (Zugangsdaten)', 'purpose' => MailPurpose::PROVISIONING],
'maintenance-announcement' => ['label' => 'Wartungsfenster angekündigt', 'purpose' => MailPurpose::MAINTENANCE],
'maintenance-cancelled' => ['label' => 'Wartungsfenster abgesagt', 'purpose' => MailPurpose::MAINTENANCE],
'invoice' => ['label' => 'Rechnung', 'purpose' => MailPurpose::BILLING],
'new-device' => ['label' => 'Anmeldung von einem neuen Gerät', 'purpose' => MailPurpose::SYSTEM],
'dormant-warning' => ['label' => 'Konto ohne Paket wird gelöscht', 'purpose' => MailPurpose::SYSTEM],
'operator-message' => ['label' => 'Nachricht aus der Konsole', 'purpose' => MailPurpose::SUPPORT],
// Die sechs Texte des Mahnlaufs. Vorschaubar, weil sie die
// unangenehmsten sind, die dieses Haus verschickt — und die
// einzigen, die ein Kunde bekommt, wenn ohnehin gerade etwas
// schiefläuft. Alle sechs aus dem BILLING-Postfach: eine Mahnung
// ist immer eine Frage übers Geld.
'dunning-0' => ['label' => 'Zahlung fehlgeschlagen (Hinweis)', 'purpose' => MailPurpose::BILLING],
'dunning-1' => ['label' => 'Zahlungserinnerung (1. Mahnung)', 'purpose' => MailPurpose::BILLING],
'dunning-2' => ['label' => '2. Mahnung (mit Mahnspesen)', 'purpose' => MailPurpose::BILLING],
'dunning-3' => ['label' => '3. Mahnung (letzte vor Abschaltung)', 'purpose' => MailPurpose::BILLING],
'cloud-suspended' => ['label' => 'Cloud abgeschaltet', 'purpose' => MailPurpose::BILLING],
'cloud-resumed' => ['label' => 'Cloud läuft wieder', 'purpose' => MailPurpose::BILLING],
];
}
public static function label(string $key): string
{
return self::all()[$key]['label'] ?? $key;
}
public static function purpose(string $key): string
{
return self::all()[$key]['purpose'] ?? MailPurpose::SYSTEM;
}
}

View File

@ -54,32 +54,15 @@ class MailPreviews
* The list, in the order somebody would read them: sign-up, purchase,
* operation, money, security.
*
* Read from MailCatalogue rather than kept as a second list that WAS
* this list, before MailRoute needed a default purpose per mail type
* too. Two lists is the shape of bug that forgets the seventeenth mail.
*
* @return array<string, string> key => label
*/
public function all(): array
{
return [
'verify-email' => 'E-Mail bestätigen (Registrierung)',
'reset-password' => 'Passwort zurücksetzen',
'order-confirmation' => 'Bestellbestätigung',
'cloud-ready' => 'Cloud ist bereit (Zugangsdaten)',
'maintenance-announcement' => 'Wartungsfenster angekündigt',
'maintenance-cancelled' => 'Wartungsfenster abgesagt',
'invoice' => 'Rechnung',
'new-device' => 'Anmeldung von einem neuen Gerät',
'dormant-warning' => 'Konto ohne Paket wird gelöscht',
'operator-message' => 'Nachricht aus der Konsole',
// Die sechs Texte des Mahnlaufs. Vorschaubar, weil sie die
// unangenehmsten sind, die dieses Haus verschickt — und die
// einzigen, die ein Kunde bekommt, wenn ohnehin gerade etwas
// schiefläuft.
'dunning-0' => 'Zahlung fehlgeschlagen (Hinweis)',
'dunning-1' => 'Zahlungserinnerung (1. Mahnung)',
'dunning-2' => '2. Mahnung (mit Mahnspesen)',
'dunning-3' => '3. Mahnung (letzte vor Abschaltung)',
'cloud-suspended' => 'Cloud abgeschaltet',
'cloud-resumed' => 'Cloud läuft wieder',
];
return array_map(fn (array $entry) => $entry['label'], MailCatalogue::all());
}
public function has(string $key): bool
@ -232,7 +215,7 @@ class MailPreviews
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::PROVISIONING, __('provisioning.mail.ready_subject'));
return $this->mailboxEnvelope(MailPurpose::PROVISIONING, __('provisioning.mail.ready_subject'), 'cloud-ready');
}
public function content(): Content

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services\Mail;
use App\Models\Mailbox;
use App\Support\Settings;
/**
* Welches Postfach trägt diese Mailart.
*
* Eine Ebene ÜBER den Zwecken, kein Ersatz für sie. Ohne Eintrag entscheidet
* weiter der Zweck der Umbau ändert also am heutigen Verhalten nichts, und
* genau das ist die Bedingung dafür, dass man ihn gefahrlos ausrollen kann.
*/
final class MailRoute
{
public static function settingKey(string $mailKey): string
{
return 'mail.route.'.$mailKey;
}
/**
* Das Postfach für diese Mailart der Eintrag, sonst der Zweck.
*
* Ein Eintrag, der auf ein gelöschtes oder abgeschaltetes Postfach zeigt,
* fällt auf den Zweck zurück. Eine Mail, die nicht rausgeht, ist schlimmer
* als eine aus der zweitbesten Adresse.
*/
public static function purposeOrMailbox(string $mailKey, string $purpose): ?Mailbox
{
$key = (string) Settings::get(self::settingKey($mailKey), '');
if ($key !== '') {
$box = Mailbox::findByKey($key);
if ($box !== null && $box->active) {
return $box;
}
}
return app(MailboxResolver::class)->for($purpose);
}
}

View File

@ -55,6 +55,10 @@ return [
'system_inactive' => '„System" braucht ein aktives Postfach — es ist der Rückfall, auf den alles andere angewiesen ist.',
'purpose_unknown_mailbox' => 'Dieses Postfach existiert nicht mehr. Bitte eines aus der Liste wählen.',
'routes_title' => 'Wegwahl je Mailart',
'routes_hint' => 'Ohne Eintrag entscheidet der Zweck oben. Nur für die eine Mailart, die anders soll — der Nachbar bleibt, wo er war.',
'route_default' => 'wie der Zweck (:purpose)',
'test' => 'Testmail senden',
'testing' => 'Wird gesendet…',
'test_recipient' => 'Testempfänger',

View File

@ -55,6 +55,10 @@ return [
'system_inactive' => '"System" needs a mailbox that is active — it is the fallback everything else depends on.',
'purpose_unknown_mailbox' => 'That mailbox no longer exists. Please choose one from the list.',
'routes_title' => 'Routing by mail type',
'routes_hint' => 'With no entry, the purpose above decides. Only for the one mail type that should differ — its neighbours stay where they were.',
'route_default' => 'same as the purpose (:purpose)',
'test' => 'Send test mail',
'testing' => 'Sending…',
'test_recipient' => 'Test recipient',

View File

@ -169,4 +169,31 @@
{{ __('mail_settings.save') }}
</x-ui.button>
</x-ui.card>
{{-- 4. Die Wegwahl je Mailart. Eine Ebene ÜBER den Zwecken oben, kein
Ersatz leer heißt „wie der Zweck". Ein <select> pro Zeile, R20's
stated exception, genau wie die Zuordnung darüber. --}}
<x-ui.card>
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.routes_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.routes_hint') }}</p>
<div class="mt-5 space-y-3">
@foreach ($mailCatalogue as $key => $entry)
<div class="flex items-center justify-between gap-6 border-b border-line py-2">
<span class="text-sm text-ink">{{ $entry['label'] }}</span>
<select wire:model="routes.{{ $key }}"
class="w-64 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('mail_settings.route_default', ['purpose' => __('mail_settings.purpose.'.$entry['purpose'])]) }}</option>
@foreach ($mailboxes->where('active', true) as $box)
<option value="{{ $box->key }}">{{ $box->address }}</option>
@endforeach
</select>
</div>
@endforeach
</div>
<x-ui.button wire:click="saveRoutes" variant="primary" size="md" class="mt-5">
{{ __('mail_settings.save') }}
</x-ui.button>
</x-ui.card>
</div>

View File

@ -0,0 +1,69 @@
<?php // tests/Feature/Admin/MailRoutingTest.php
use App\Models\Mailbox;
use App\Services\Mail\MailCatalogue;
use App\Services\Mail\MailPurpose;
use App\Services\Mail\MailRoute;
use App\Support\Settings;
// Blank slate statt der migrationseigenen fuenf (no-reply, support, billing,
// office, info): drei der Tests unten legen selbst ein Postfach mit dem
// Schluessel 'no-reply' an, und das kollidiert sonst mit mailboxes.key's
// Unique-Constraint gegen die von RefreshDatabase stehen gelassene Saat. Der
// Helfer existiert schon in tests/Pest.php, genau fuer diesen Fall (siehe
// tests/Feature/Mail/MailSettingsPageTest.php).
beforeEach(fn () => clearMailboxSeed());
it('schickt ohne Eintrag genau dorthin, wo die Mail vorher hinging', function () {
// Der wichtigste Test dieser Aufgabe: der Umbau darf am heutigen Verhalten
// NICHTS ändern, solange niemand etwas einstellt. Sonst wandern beim
// Ausrollen still die Rechnungen in ein anderes Postfach.
$system = Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]);
Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
expect(MailRoute::purposeOrMailbox('new-device', MailPurpose::SYSTEM)?->id)
->toBe($system->id);
});
it('legt eine einzelne Mailart auf ein anderes Postfach', function () {
Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]);
$info = Mailbox::factory()->create(['key' => 'info', 'active' => true]);
Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
Settings::set(MailRoute::settingKey('new-device'), 'info');
// Und nur diese eine: der Nachbar bleibt, wo er war. Mit einem EIGENEN
// Postfach fuer Abrechnung — ohne das waere die Zusicherung leer, weil der
// Resolver dann null liefert und null nun einmal nicht 'info' ist.
$billing = Mailbox::factory()->create(['key' => 'billing', 'active' => true]);
Settings::set(MailPurpose::settingKey(MailPurpose::BILLING), 'billing');
expect(MailRoute::purposeOrMailbox('new-device', MailPurpose::SYSTEM)?->id)->toBe($info->id)
->and(MailRoute::purposeOrMailbox('invoice', MailPurpose::BILLING)?->id)->toBe($billing->id);
});
it('faellt auf den Zweck zurueck, wenn das eingetragene Postfach abgeschaltet ist', function () {
// Eine Mail, die nicht rausgeht, ist schlimmer als eine aus der zweitbesten
// Adresse.
Mailbox::factory()->create(['key' => 'no-reply', 'active' => true]);
Mailbox::factory()->create(['key' => 'info', 'active' => false]);
Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
Settings::set(MailRoute::settingKey('new-device'), 'info');
expect(MailRoute::purposeOrMailbox('new-device', MailPurpose::SYSTEM)?->key)->toBe('no-reply');
});
it('kennt zu jeder Mailart einen Vorgabe-Zweck', function () {
// Ohne Vorgabe stünde eine Mailart ohne Postfach da, sobald jemand die
// Wegwahl leert.
foreach (MailCatalogue::all() as $key => $eintrag) {
expect($eintrag['purpose'])->toBeIn(MailPurpose::ALL, "[{$key}] hat keinen gueltigen Vorgabe-Zweck.");
expect($eintrag['label'])->not->toBe('');
}
});
it('fuehrt die Liste der Mailarten nur an EINER Stelle', function () {
// Zwei Listen bedeuten, dass die zweite beim siebzehnten Mail vergessen
// wird. Die Vorschau muss aus dem Katalog lesen.
expect(array_keys(app(\App\Services\Mail\MailPreviews::class)->all()))
->toEqualCanonicalizing(array_keys(MailCatalogue::all()));
});