diff --git a/app/Livewire/Admin/Mail.php b/app/Livewire/Admin/Mail.php index 96a3a64..bb28da4 100644 --- a/app/Livewire/Admin/Mail.php +++ b/app/Livewire/Admin/Mail.php @@ -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 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 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(), ]); } diff --git a/app/Mail/CloudResumedMail.php b/app/Mail/CloudResumedMail.php index dfbb8d8..57537a3 100644 --- a/app/Mail/CloudResumedMail.php +++ b/app/Mail/CloudResumedMail.php @@ -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 diff --git a/app/Mail/CloudSuspendedMail.php b/app/Mail/CloudSuspendedMail.php index 0a68420..5849d8d 100644 --- a/app/Mail/CloudSuspendedMail.php +++ b/app/Mail/CloudSuspendedMail.php @@ -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 diff --git a/app/Mail/Concerns/SendsFromMailbox.php b/app/Mail/Concerns/SendsFromMailbox.php index 7cb3ace..6d1f83d 100644 --- a/app/Mail/Concerns/SendsFromMailbox.php +++ b/app/Mail/Concerns/SendsFromMailbox.php @@ -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 diff --git a/app/Mail/DormantAccountWarningMail.php b/app/Mail/DormantAccountWarningMail.php index 9f5ea31..537a150 100644 --- a/app/Mail/DormantAccountWarningMail.php +++ b/app/Mail/DormantAccountWarningMail.php @@ -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 diff --git a/app/Mail/DunningNoticeMail.php b/app/Mail/DunningNoticeMail.php index f7d746e..bdcfe53 100644 --- a/app/Mail/DunningNoticeMail.php +++ b/app/Mail/DunningNoticeMail.php @@ -52,6 +52,7 @@ class DunningNoticeMail extends Mailable implements ShouldQueue return $this->mailboxEnvelope( MailPurpose::BILLING, __('dunning_mail.subject_'.$this->level), + 'dunning-'.$this->level, ); } diff --git a/app/Mail/InvoiceMail.php b/app/Mail/InvoiceMail.php index 189933e..fbeba21 100644 --- a/app/Mail/InvoiceMail.php +++ b/app/Mail/InvoiceMail.php @@ -45,6 +45,7 @@ class InvoiceMail extends Mailable implements ShouldQueue return $this->mailboxEnvelope( MailPurpose::BILLING, __('invoice_mail.subject', ['number' => $this->invoice->number]), + 'invoice', ); } diff --git a/app/Mail/MaintenanceAnnouncementMail.php b/app/Mail/MaintenanceAnnouncementMail.php index 13d2bdf..8dbc7bb 100644 --- a/app/Mail/MaintenanceAnnouncementMail.php +++ b/app/Mail/MaintenanceAnnouncementMail.php @@ -40,6 +40,7 @@ class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue return $this->mailboxEnvelope( MailPurpose::MAINTENANCE, __('maintenance.mail_subject', ['title' => $this->window->title]), + 'maintenance-announcement', ); } diff --git a/app/Mail/MaintenanceCancelledMail.php b/app/Mail/MaintenanceCancelledMail.php index f0ba4b3..05ac6ff 100644 --- a/app/Mail/MaintenanceCancelledMail.php +++ b/app/Mail/MaintenanceCancelledMail.php @@ -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', ); } diff --git a/app/Mail/NewDeviceSignInMail.php b/app/Mail/NewDeviceSignInMail.php index aa5f9ee..2fb26e7 100644 --- a/app/Mail/NewDeviceSignInMail.php +++ b/app/Mail/NewDeviceSignInMail.php @@ -39,6 +39,7 @@ class NewDeviceSignInMail extends Mailable implements ShouldQueue return $this->mailboxEnvelope( MailPurpose::SYSTEM, __('devices.mail_subject'), + 'new-device', ); } diff --git a/app/Mail/OperatorMessageMail.php b/app/Mail/OperatorMessageMail.php index b103038..d8df2f1 100644 --- a/app/Mail/OperatorMessageMail.php +++ b/app/Mail/OperatorMessageMail.php @@ -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 diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php index 7d64f5b..1a6882e 100644 --- a/app/Mail/OrderConfirmationMail.php +++ b/app/Mail/OrderConfirmationMail.php @@ -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', ); } diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php index cdfe636..d4137b8 100644 --- a/app/Mail/ResetPasswordMail.php +++ b/app/Mail/ResetPasswordMail.php @@ -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 diff --git a/app/Mail/VerifyEmailMail.php b/app/Mail/VerifyEmailMail.php index 04fd718..f623ded 100644 --- a/app/Mail/VerifyEmailMail.php +++ b/app/Mail/VerifyEmailMail.php @@ -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 diff --git a/app/Notifications/CloudReady.php b/app/Notifications/CloudReady.php index 534471e..d2eb3d9 100644 --- a/app/Notifications/CloudReady.php +++ b/app/Notifications/CloudReady.php @@ -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); diff --git a/app/Services/Mail/MailCatalogue.php b/app/Services/Mail/MailCatalogue.php new file mode 100644 index 0000000..5b7ebe1 --- /dev/null +++ b/app/Services/Mail/MailCatalogue.php @@ -0,0 +1,58 @@ + */ + 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; + } +} diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index 984bbea..4dbf439 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -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 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 diff --git a/app/Services/Mail/MailRoute.php b/app/Services/Mail/MailRoute.php new file mode 100644 index 0000000..1387478 --- /dev/null +++ b/app/Services/Mail/MailRoute.php @@ -0,0 +1,43 @@ +active) { + return $box; + } + } + + return app(MailboxResolver::class)->for($purpose); + } +} diff --git a/lang/de/mail_settings.php b/lang/de/mail_settings.php index 641855e..ff73ab8 100644 --- a/lang/de/mail_settings.php +++ b/lang/de/mail_settings.php @@ -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', diff --git a/lang/en/mail_settings.php b/lang/en/mail_settings.php index bc8befe..b74ac19 100644 --- a/lang/en/mail_settings.php +++ b/lang/en/mail_settings.php @@ -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', diff --git a/resources/views/livewire/admin/mail.blade.php b/resources/views/livewire/admin/mail.blade.php index b9f7d1f..f930fc5 100644 --- a/resources/views/livewire/admin/mail.blade.php +++ b/resources/views/livewire/admin/mail.blade.php @@ -169,4 +169,31 @@ {{ __('mail_settings.save') }} + + {{-- 4. Die Wegwahl je Mailart. Eine Ebene ÜBER den Zwecken oben, kein + Ersatz — leer heißt „wie der Zweck". Ein + + @foreach ($mailboxes->where('active', true) as $box) + + @endforeach + + + @endforeach + + + + {{ __('mail_settings.save') }} + + diff --git a/tests/Feature/Admin/MailRoutingTest.php b/tests/Feature/Admin/MailRoutingTest.php new file mode 100644 index 0000000..5007c3a --- /dev/null +++ b/tests/Feature/Admin/MailRoutingTest.php @@ -0,0 +1,69 @@ + 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())); +});