From 4477a7699eded40cc3ada89d41de569f8e42564c Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 22:06:50 +0200 Subject: [PATCH] Die Mailseite fragt drei Fragen statt eine Wand zu zeigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Karten in einer schmalen Spalte, und die letzten beiden allein zweiundzwanzig gleich aussehende Zeilen: fuenf Zwecke, siebzehn Mailarten, jede „Beschriftung, Auswahlfeld", darunter zweimal derselbe Speichern-Knopf. Wer die Zuordnung EINER Mailart aendern wollte, scrollte an allem anderen vorbei und fand am Ende eine Wand. Der Betreiber hat die Seite als unlesbar gemeldet. Jetzt drei Reiter nach dem Muster von Admin\Integrations, das aus demselben Grund umgebaut wurde — geteilt nach der Frage, die man mitbringt: WOMIT gesendet wird, WER sendet, WAS von wo rausgeht. Der offene Reiter steht in der Adresszeile, ein Neuladen landet dort, wo man war. Die eigentliche Wand war die Zuordnung, und zwar nicht wegen ihrer Laenge: dass die Wegwahl je Mailart auf den Zweck zurueckfaellt, stand nur als Satz in der leeren Auswahl — siebzehnmal derselbe Satz neben siebzehn Zeilen, die alle gleich aussahen. Jetzt steht jede Mailart unter ihrem Zweck: oben das Postfach des Zwecks, darunter eingerueckt, was davon abweichen darf. Die Regel ist die Form geworden. Die leere Auswahl nennt die Adresse, die stattdessen greift — und zwar die, die WIRKLICH greift. Ein Codex-Befund am eigenen Umbau: ein abgeschaltetes Postfach faellt in MailboxResolver::for() auf „System" zurueck, und writePurposes() erlaubt genau das. Das Etikett haette eine Adresse genannt, die nichts verschickt, an der Stelle, an der man nachsieht, um es richtig zu machen. Es fragt deshalb dieselbe Aufloesung, die beim Senden entscheidet. Ein Knopf statt zwei: Zwecke und Wegwahl sind fuer den Betreiber ein Vorgang. Die Reihenfolge ist die Zusicherung — die Zwecke werden geprueft und werfen, BEVOR eine einzige Wegwahl geschrieben ist, sonst hinterliesse eine abgelehnte Eingabe die halbe Zuordnung. savePurposes() und saveRoutes() bleiben, was sie waren. Beide Warnungen stehen ueber den Reitern: sie gelten fuer die ganze Seite, und wer nachsieht, warum keine Mail ankam, soll nicht erst den richtigen Reiter suchen. Vier bestehende Tests zeigen jetzt auf den Postfach-Reiter, fuenf neue kamen dazu — darunter der Regressionstest zum Codex-Befund, der gegen die alte Fassung faellt. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Mail.php | 146 ++++++- lang/de/mail_settings.php | 17 +- lang/en/mail_settings.php | 17 +- resources/views/livewire/admin/mail.blade.php | 413 ++++++++++-------- tests/Feature/Mail/MailSettingsPageTest.php | 90 ++++ 5 files changed, 486 insertions(+), 197 deletions(-) diff --git a/app/Livewire/Admin/Mail.php b/app/Livewire/Admin/Mail.php index bb28da4..23413c0 100644 --- a/app/Livewire/Admin/Mail.php +++ b/app/Livewire/Admin/Mail.php @@ -4,6 +4,7 @@ namespace App\Livewire\Admin; use App\Livewire\Concerns\ConfirmsPassword; use App\Models\Mailbox; +use App\Services\Mail\MailboxResolver; use App\Services\Mail\MailboxTester; use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; @@ -12,22 +13,44 @@ use App\Services\Secrets\SecretCipher; use App\Support\MailDelivery; use App\Support\Settings; use Livewire\Attributes\Layout; +use Livewire\Attributes\Url; use Livewire\Component; /** * The sending addresses, and which kind of mail leaves from which. * - * The server sits at the top because there is one of it; the mailboxes are a - * list because there are several; the mapping is last because it only makes - * sense once both exist. The test-send button lives with the mailboxes: it - * proves one specific mailbox can actually send, which is the only thing - * that makes the rest of this page more than a form. + * The server sits first because there is one of it; the mailboxes come next + * because there are several; the mapping is last because it only makes sense + * once both exist. The test-send button lives with the mailboxes: it proves + * one specific mailbox can actually send, which is the only thing that makes + * the rest of this page more than a form. + * + * Diese Reihenfolge waren vier Karten in einer schmalen Spalte, und die + * letzten beiden allein zweiundzwanzig gleich aussehende Zeilen — fünf Zwecke, + * siebzehn Mailarten, jede „Beschriftung, Auswahlfeld", darunter zweimal + * derselbe Speichern-Knopf. Wer die Zuordnung einer einzelnen Mailart ändern + * wollte, scrollte an allem anderen vorbei und fand am Ende eine Wand. + * + * Jetzt drei Reiter nach dem Muster von Admin\Integrations, das aus demselben + * Grund umgebaut wurde: WOMIT gesendet wird, WER sendet, und WAS von wo + * rausgeht. Der offene Reiter steht in der Adresszeile, damit ein Neuladen + * oder ein Lesezeichen dort landet, wo der Betreiber war. */ #[Layout('layouts.admin')] class Mail extends Component { use ConfirmsPassword; + /** + * Die Reiter, in dieser Reihenfolge. Die Liste IST das Schema: sie prüft + * die Adresszeile, baut die Leiste und entscheidet, was gerendert wird. + */ + public const TABS = ['versand', 'postfaecher', 'zuordnung']; + + /** Welcher Reiter offen ist — mit `history: true` ein Schritt zurück. */ + #[Url(history: true)] + public string $tab = 'versand'; + public string $host = ''; public int|string $port = 587; @@ -155,7 +178,17 @@ class Mail extends Component $this->dispatch('notify', message: __('mail_settings.server_saved')); } - public function savePurposes(): void + /** + * Prüft und schreibt die Zwecke — ohne Meldung. + * + * Getrennt vom öffentlichen savePurposes(), weil die Seite die Zwecke und + * die Wegwahl jetzt in EINEM Block zeigt und mit EINEM Knopf speichert: + * eine Zuordnung, die man an zwei Stellen bestätigen muss, ist der Grund, + * warum unter der alten Seite zwei gleich aussehende Knöpfe standen. Die + * beiden öffentlichen Methoden bleiben, was sie waren — sie sind der + * Einstieg, den die Tests und ein direkter Aufruf kennen. + */ + private function writePurposes(): void { $this->authorize('mail.manage'); @@ -203,6 +236,11 @@ class Mail extends Component foreach (MailPurpose::ALL as $purpose) { Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? ''); } + } + + public function savePurposes(): void + { + $this->writePurposes(); $this->dispatch('notify', message: __('mail_settings.purposes_saved')); } @@ -215,13 +253,34 @@ class Mail extends Component * auf den Zweck zurückfallen lässt. Eine zweite Prüfung derselben * Sicherung wäre doppelte Arbeit ohne eigenen Wert. */ - public function saveRoutes(): void + private function writeRoutes(): void { $this->authorize('mail.manage'); foreach (MailCatalogue::all() as $key => $entry) { Settings::set(MailRoute::settingKey($key), $this->routes[$key] ?? ''); } + } + + public function saveRoutes(): void + { + $this->writeRoutes(); + + $this->dispatch('notify', message: __('mail_settings.purposes_saved')); + } + + /** + * Der eine Knopf unter dem Reiter „Zuordnung". + * + * Zwecke zuerst: writePurposes() prüft und wirft, bevor irgendetwas + * geschrieben ist — eine abgelehnte Eingabe darf nicht die halbe Zuordnung + * hinterlassen. Eine Meldung für beides, weil es für den Betreiber ein + * Vorgang ist. + */ + public function saveAssignments(): void + { + $this->writePurposes(); + $this->writeRoutes(); $this->dispatch('notify', message: __('mail_settings.purposes_saved')); } @@ -245,11 +304,80 @@ class Mail extends Component { $this->usable = app(SecretCipher::class)->isUsable(); + // Ein Reitername aus der Adresszeile ist eine Zeichenkette, die ein + // Fremder getippt hat. + if (! in_array($this->tab, self::TABS, true)) { + $this->tab = self::TABS[0]; + } + + $gruppen = $this->catalogueByPurpose(); + return view('livewire.admin.mail', [ 'mailboxes' => Mailbox::query()->orderBy('key')->get(), - 'purposeList' => MailPurpose::ALL, - 'mailCatalogue' => MailCatalogue::all(), + 'catalogueByPurpose' => $gruppen, + 'inheritedAddress' => $this->inheritedAddresses(array_keys($gruppen)), 'passwordConfirmed' => $this->passwordRecentlyConfirmed(), ]); } + + /** + * Die Adresse, die eine Mailart ohne eigene Wahl WIRKLICH benutzt. + * + * Nicht das zugeordnete Postfach. MailboxResolver::for() lässt ein + * abgeschaltetes Postfach auf „System" zurückfallen, und writePurposes() + * erlaubt genau das — aktiv sein muss nur „System" selbst. Ein Etikett, + * das dann die abgeschaltete Adresse nennt, sagt dem Betreiber etwas + * Falsches über seinen eigenen Versand, und zwar an der Stelle, an der er + * nachsieht, um es richtig zu machen. + * + * Also dieselbe Funktion, die beim Senden entscheidet, statt einer zweiten + * Meinung darüber. Sie liest den GESPEICHERTEN Stand: was im Formular noch + * nicht gespeichert ist, gilt auf dieser Seite nirgends. + * + * @param array $purposes + * @return array + */ + private function inheritedAddresses(array $purposes): array + { + $adressen = []; + + foreach ($purposes as $purpose) { + // catalogueByPurpose() lässt eine Mailart mit unbekanntem Zweck + // stehen, statt sie lautlos fallen zu lassen; for() würde für die + // werfen. Kein Zweck, keine geerbte Adresse. + $adressen[$purpose] = in_array($purpose, MailPurpose::ALL, true) + ? app(MailboxResolver::class)->for($purpose)?->address + : null; + } + + return $adressen; + } + + /** + * Die Mailarten unter ihrem Zweck, in der Reihenfolge der Zwecke. + * + * Die Wegwahl je Mailart fällt auf den Zweck zurück, wenn sie leer ist — + * das stand bisher nur als Text in der Auswahlbeschriftung („wie der + * Zweck") und war damit siebzehnmal derselbe Satz neben siebzehn Zeilen, + * die alle gleich aussahen. Unter ihrem Zweck einsortiert zeigt die Liste + * dieselbe Regel als Form: das Postfach des Zwecks steht oben, was davon + * abweicht, steht darunter. + * + * Ein Zweck ohne eigene Mailart bleibt trotzdem stehen — sein Postfach ist + * die Rückfalllinie und muss auch dann einstellbar sein. Eine Mailart mit + * einem Zweck außerhalb von MailPurpose::ALL bekommt einen eigenen Block + * am Ende, statt lautlos aus der Seite zu fallen. + * + * @return array> + */ + private function catalogueByPurpose(): array + { + $gruppen = array_fill_keys(MailPurpose::ALL, []); + + foreach (MailCatalogue::all() as $key => $entry) { + $gruppen[$entry['purpose']][$key] = $entry; + } + + return $gruppen; + } } diff --git a/lang/de/mail_settings.php b/lang/de/mail_settings.php index ff73ab8..cc50457 100644 --- a/lang/de/mail_settings.php +++ b/lang/de/mail_settings.php @@ -4,6 +4,12 @@ return [ 'title' => 'E-Mail', 'subtitle' => 'Absenderadressen und der Server, über den sie verschickt werden.', + 'tab' => [ + 'versand' => 'Versand', + 'postfaecher' => 'Postfächer', + 'zuordnung' => 'Zuordnung', + ], + 'no_key' => 'SECRETS_KEY ist auf diesem Server nicht gesetzt. Ohne eigenen Schlüssel werden hier keine Postfach-Passwörter gespeichert — bewusst, denn APP_KEY wird routinemäßig gewechselt.', 'server_title' => 'Mailserver', @@ -42,7 +48,12 @@ return [ 'confirm_button' => 'Bestätigen', 'purposes_title' => 'Wer verschickt was', - 'purposes_hint' => 'Ein Zweck ohne Postfach verschickt über „System".', + 'assignments_hint' => 'Jeder Zweck hat ein Postfach — ein Zweck ohne eines verschickt über „System". Eine einzelne Mailart folgt ihrem Zweck, solange sie kein eigenes bekommt.', + 'purpose_mailbox' => 'Postfach dieses Zwecks', + 'route_exceptions' => 'Abweichend davon', + 'route_inherit' => 'wie der Zweck (:address)', + 'route_inherit_unset' => 'wie der Zweck', + 'purpose_no_types' => 'Keine eigene Mailart — dieser Zweck fängt nur auf, was sonst nirgends hingehört.', 'purpose' => [ 'maintenance' => 'Wartungsankündigungen', 'provisioning' => 'Bereitstellung und Bestellbestätigung', @@ -55,10 +66,6 @@ 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 b74ac19..0c19579 100644 --- a/lang/en/mail_settings.php +++ b/lang/en/mail_settings.php @@ -4,6 +4,12 @@ return [ 'title' => 'Email', 'subtitle' => 'Sending addresses, and the server they go out through.', + 'tab' => [ + 'versand' => 'Sending', + 'postfaecher' => 'Mailboxes', + 'zuordnung' => 'Assignment', + ], + 'no_key' => 'SECRETS_KEY is not set on this server. Without a key of its own, no mailbox passwords are stored here — deliberately, because APP_KEY is rotated as routine maintenance.', 'server_title' => 'Mail server', @@ -42,7 +48,6 @@ return [ 'confirm_button' => 'Confirm', 'purposes_title' => 'Who sends what', - 'purposes_hint' => 'A purpose with no mailbox sends through "System".', 'purpose' => [ 'maintenance' => 'Maintenance announcements', 'provisioning' => 'Provisioning and order confirmation', @@ -50,15 +55,17 @@ return [ 'billing' => 'Invoices and payment reminders', 'system' => 'System (fallback for everything else)', ], + 'assignments_hint' => 'Every purpose has a mailbox — a purpose without one sends through "System". A single mail type follows its purpose until it is given one of its own.', + 'purpose_mailbox' => 'Mailbox for this purpose', + 'route_exceptions' => 'Differing from that', + 'route_inherit' => 'same as the purpose (:address)', + 'route_inherit_unset' => 'same as the purpose', + 'purpose_no_types' => 'No mail type of its own — this purpose only catches what belongs nowhere else.', 'purposes_saved' => 'Mapping saved.', 'system_required' => '"System" must have a mailbox — it is the fallback for all the others.', '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 f930fc5..d7b82f7 100644 --- a/resources/views/livewire/admin/mail.blade.php +++ b/resources/views/livewire/admin/mail.blade.php @@ -1,199 +1,256 @@ -
+

{{ __('mail_settings.title') }}

-

{{ __('mail_settings.subtitle') }}

+

{{ __('mail_settings.subtitle') }}

- {{-- The one thing that makes every mailbox on this page decorative. Said - here as well as on the front page, because this is where somebody comes - to find out why no mail arrived — and the test button below reports - success regardless, since MailboxTester builds its own transport on - purpose (a check that honoured MAIL_MAILER=log would report success - while writing to a file). --}} + {{-- ── Die Reiter ──────────────────────────────────────────────────── + Das waren vier Karten untereinander in einer schmalen Spalte, und die + letzten beiden allein zweiundzwanzig gleich aussehende Zeilen mit zwei + gleich aussehenden Speichern-Knöpfen darunter. Geteilt nach der Frage, + die man an die Seite mitbringt: WOMIT wird gesendet, WER sendet, und + WAS geht von wo raus — dieselbe Achse, nach der Admin\Integrations aus + demselben Grund umgebaut wurde. + + Der offene Reiter steht in der Adresszeile, damit ein Neuladen oder + ein Lesezeichen dort landet, wo der Betreiber war. Die Bestätigung des + Passworts überlebt einen Wechsel: sie gehört zur Sitzung, nicht zum + Reiter. --}} +
+ @foreach (\App\Livewire\Admin\Mail::TABS as $name) + + @endforeach +
+ + {{-- Beide Hinweise stehen ÜBER den Reitern, nicht in einem davon: sie + gelten für die ganze Seite. Der erste ist das eine, was jedes Postfach + hier zur Zierde macht — und er gehört genau dorthin, wo jemand + nachsieht, warum keine Mail ankam; der Postfach-Test darunter meldet + nämlich trotzdem Erfolg, weil MailboxTester absichtlich seinen eigenen + Weg baut. --}} @if (! \App\Support\MailDelivery::delivers()) {{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (\App\Support\MailDelivery::transport() ?? 'null')]) }} @endif - @if (! $usable) {{ __('mail_settings.no_key') }} @endif - {{-- 1. The server. One of it, so one card. --}} - -

{{ __('mail_settings.server_title') }}

-

{{ __('mail_settings.server_hint') }}

+ {{-- ── 1. Womit gesendet wird ─────────────────────────────────────── + Ein Server, eine Karte. Der Hauptschalter steht ÜBER den Serverfeldern + und in derselben Karte: er entscheidet, ob die überhaupt benutzt + werden, und derselbe Knopf speichert beides. --}} + @if ($tab === 'versand') + +

{{ __('mail_settings.server_title') }}

+

{{ __('mail_settings.server_hint') }}

- {{-- Der Hauptschalter, und er steht ÜBER den Serverfeldern: er - entscheidet, ob die überhaupt benutzt werden. Bis hierher stand er - nur als MAIL_MAILER in der .env — die Bereitschaftsseite meldete - ihn als blockierend, ohne sagen zu können, worauf man ihn stellt. - Zwei Zustände, keine Treiberliste: was tatsächlich sendet, ist - MailboxTransport mit den Feldern darunter. --}} -
- -
- -
- - -
- - - @error('encryption')

{{ $message }}

@enderror +
+
-
- @if (! $passwordConfirmed) - {{-- The second gate. $host is the platform's outbound relay for - every purpose at once — being signed in is not enough to - repoint it, the same reasoning as the credentials page. --}} -
-

{{ __('mail_settings.confirm_first') }}

-
-
- -
- - {{ __('mail_settings.confirm_button') }} - +
+ + +
+ + + @error('encryption')

{{ $message }}

@enderror
- - @else - +
+ + @if (! $passwordConfirmed) + {{-- The second gate. $host is the platform's outbound relay for + every purpose at once — being signed in is not enough to + repoint it, the same reasoning as the credentials page. --}} +
+

{{ __('mail_settings.confirm_first') }}

+
+
+ +
+ + {{ __('mail_settings.confirm_button') }} + +
+
+ @else + + {{ __('mail_settings.save') }} + + @endif + + @endif + + {{-- ── 2. Wer sendet ──────────────────────────────────────────────── + Kein Eingabefeld in einem — R20. Das Empfängerfeld für den + Testversand steht ÜBER der Tabelle, eines für alle Zeilen. --}} + @if ($tab === 'postfaecher') + +

{{ __('mail_settings.boxes_title') }}

+ +
+ +

{{ __('mail_settings.test_hint') }}

+
+ + @if ($testResult) + + {{ $testedKey }}: + @if ($testResult['ok']) + {{ __('mail_settings.test_ok') }} + @else + {{ __('mail_settings.test_failed') }} {{ $testResult['error'] }} + @endif + + @endif + + + + + + + + + + + @foreach ($mailboxes as $box) + + + + + + @endforeach + +
{{ __('mail_settings.address') }}{{ __('mail_settings.last_verified') }}
+ {{ $box->address }} + {{ $box->key }} + + {{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }} + + {{-- R18: icon beside its text, single line, size-4. + Two spans swapped by wire:loading, same + pattern as admin/host-create.blade.php's save + button — the disabled attribute alone left an + operator staring at a greyed-out button with + no sign anything was happening for up to the + full connect timeout. --}} + + + {{ __('mail_settings.test') }} + {{ __('mail_settings.testing') }} + + + {{ __('mail_settings.edit') }} + +
+
+ @endif + + {{-- ── 3. Was von wo rausgeht ─────────────────────────────────────── + Zwei Listen waren es: fünf Zwecke, darunter siebzehn Mailarten, beide + als „Beschriftung, Auswahlfeld" und optisch nicht auseinanderzuhalten. + Dass die zweite Liste auf die erste zurückfällt, stand nur als Satz in + der leeren Auswahl — siebzehnmal derselbe Satz. + + Jetzt steht jede Mailart unter ihrem Zweck: oben das Postfach, aus dem + der Zweck verschickt, darunter eingerückt die Mailarten, die davon + abweichen dürfen. Die Regel ist damit die Form, und die leere Auswahl + nennt die Adresse, die stattdessen greift, statt nur den Zwecknamen. + + Ein + + @foreach ($mailboxes as $box) + + @endforeach + +
+ + @error('purposes.'.$purpose) +

{{ $message }}

+ @enderror + + @if ($entries === []) +

{{ __('mail_settings.purpose_no_types') }}

+ @else +

{{ __('mail_settings.route_exceptions') }}

+
+ @foreach ($entries as $key => $entry) +
+ {{ $entry['label'] }} + +
+ @endforeach +
+ @endif + + @endforeach +
+ + {{ __('mail_settings.save') }} - @endif -
- - {{-- 2. The mailboxes. NO input field inside a — R20. --}} - -

{{ __('mail_settings.boxes_title') }}

- - {{-- One shared recipient field for every row's test button below — a - field ABOVE the table, not one growing inside a (R20). --}} -
- -

{{ __('mail_settings.test_hint') }}

-
- - @if ($testResult) - - {{ $testedKey }}: - @if ($testResult['ok']) - {{ __('mail_settings.test_ok') }} - @else - {{ __('mail_settings.test_failed') }} {{ $testResult['error'] }} - @endif - - @endif - - - - - - - - - - - @foreach ($mailboxes as $box) - - - - - - @endforeach - -
{{ __('mail_settings.address') }}{{ __('mail_settings.last_verified') }}
- {{ $box->address }} - {{ $box->key }} - - {{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }} - - {{-- R18: icon beside its text, single line, size-4. - Two spans swapped by wire:loading, same - pattern as admin/host-create.blade.php's save - button — the disabled attribute alone left an - operator staring at a greyed-out button with - no sign anything was happening for up to the - full connect timeout. --}} - - - {{ __('mail_settings.test') }} - {{ __('mail_settings.testing') }} - - - {{ __('mail_settings.edit') }} - -
-
- - {{-- 3. The mapping. One - - @foreach ($mailboxes as $box) - - @endforeach - -
- @endforeach -
- - @error('purposes.system')

{{ $message }}

@enderror - - - {{ __('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') }} - - + + @endif diff --git a/tests/Feature/Mail/MailSettingsPageTest.php b/tests/Feature/Mail/MailSettingsPageTest.php index 401d485..55eb80a 100644 --- a/tests/Feature/Mail/MailSettingsPageTest.php +++ b/tests/Feature/Mail/MailSettingsPageTest.php @@ -4,7 +4,9 @@ use App\Livewire\Admin\Mail as MailPage; use App\Livewire\EditMailbox; use App\Models\Mailbox; use App\Models\Operator; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; +use App\Services\Mail\MailRoute; use App\Support\Settings; use Livewire\Livewire; @@ -224,6 +226,9 @@ it('lists every mailbox on the page', function () { Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) + // Die Postfächer haben seit dem Umbau einen eigenen Reiter — vier + // Karten in einer Spalte waren der Grund dafür. + ->set('tab', 'postfaecher') ->assertSee('support@clupilot.com') ->assertSee('support'); }); @@ -369,6 +374,7 @@ it('shows "not yet verified" instead of a blank cell once the server change clea ->call('confirmPassword') ->set('host', 'new-relay.example.com') ->call('saveServer') + ->set('tab', 'postfaecher') ->assertSee(__('mail_settings.never_verified')); }); @@ -877,6 +883,7 @@ it('shows the result against the mailbox it actually tested', function () { Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) + ->set('tab', 'postfaecher') ->set('testRecipient', 'ziel@example.com') ->call('test', $box->uuid) ->assertSee('support:') @@ -946,5 +953,88 @@ it('offers a per-row test-send button, not only edit', function () { it('tells the operator the test send really goes out, even though ordinary mail only logs here', function () { Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') ->test(MailPage::class) + ->set('tab', 'postfaecher') ->assertSee(__('mail_settings.test_hint')); }); + +/** + * ── Der Umbau auf Reiter ────────────────────────────────────────────────── + * + * Vier Karten in einer schmalen Spalte, davon zwei mit zusammen + * zweiundzwanzig gleich aussehenden Zeilen und zwei gleich aussehenden + * Speichern-Knöpfen: der Betreiber hat die Seite als unlesbar gemeldet. Die + * Tests hier halten fest, was der Umbau zusichert — nicht, wie er aussieht. + */ +it('falls back to the first tab when the address bar names one that does not exist', function () { + // Ein Reitername aus der Adresszeile ist eine Zeichenkette, die ein + // Fremder getippt hat. + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') + ->test(MailPage::class) + ->set('tab', 'gibt-es-nicht') + ->assertSet('tab', 'versand'); +}); + +it('shows every mail type under its purpose, naming the address it would inherit', function () { + Mailbox::factory()->create(['key' => 'rechnung', 'address' => 'rechnung@clupilot.com', 'active' => true]); + Settings::set(MailPurpose::settingKey(MailPurpose::BILLING), 'rechnung'); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') + ->test(MailPage::class) + ->set('tab', 'zuordnung') + // Die Mailart steht unter ihrem Zweck … + ->assertSee(MailCatalogue::all()['invoice']['label']) + // … und die leere Auswahl nennt die Adresse, die sonst greift, statt + // wie bisher nur den Namen des Zwecks. + ->assertSee(__('mail_settings.route_inherit', ['address' => 'rechnung@clupilot.com'])); +}); + +it('saves purposes and routing with the one button under the assignment tab', function () { + Mailbox::factory()->create(['key' => 'system', 'active' => true]); + Mailbox::factory()->create(['key' => 'rechnung', 'active' => true]); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') + ->test(MailPage::class) + ->set('purposes.system', 'system') + ->set('routes.invoice', 'rechnung') + ->call('saveAssignments') + ->assertHasNoErrors(); + + expect(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('system') + ->and(Settings::get(MailRoute::settingKey('invoice')))->toBe('rechnung'); +}); + +it('writes no routing at all when the purposes it depends on are refused', function () { + // Die Reihenfolge im einen Knopf ist die Zusicherung: die Zwecke werden + // zuerst geprüft und werfen, BEVOR eine einzige Wegwahl geschrieben ist. + // Andernfalls hinterliesse eine abgelehnte Eingabe die halbe Zuordnung — + // Wegwahlen, die auf einen Zweck zeigen, den es so nicht gibt. + Mailbox::factory()->create(['key' => 'rechnung', 'active' => true]); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') + ->test(MailPage::class) + ->set('purposes.system', '') // „System" ist der Rückfall und darf nicht leer sein + ->set('routes.invoice', 'rechnung') + ->call('saveAssignments') + ->assertHasErrors('purposes.system'); + + expect(Settings::get(MailRoute::settingKey('invoice')))->toBeNull(); +}); + +it('names the mailbox that would really carry the mail, not one that is switched off', function () { + // Codex R15, P2 am Umbau: das Etikett „wie der Zweck (…)" nannte das + // ZUGEORDNETE Postfach. MailboxResolver::for() lässt ein abgeschaltetes + // aber auf „System" zurückfallen, und writePurposes() erlaubt genau das — + // aktiv sein muss nur „System" selbst. Die Seite hätte dem Betreiber damit + // eine Adresse genannt, die nichts verschickt, und zwar an der Stelle, an + // der er nachsieht, um es richtig zu machen. + Mailbox::factory()->create(['key' => 'system', 'address' => 'noreply@clupilot.com', 'active' => true]); + Mailbox::factory()->create(['key' => 'rechnung', 'address' => 'rechnung@clupilot.com', 'active' => false]); + Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'system'); + Settings::set(MailPurpose::settingKey(MailPurpose::BILLING), 'rechnung'); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(['password' => 'password']), 'operator') + ->test(MailPage::class) + ->set('tab', 'zuordnung') + ->assertSee(__('mail_settings.route_inherit', ['address' => 'noreply@clupilot.com'])) + ->assertDontSee(__('mail_settings.route_inherit', ['address' => 'rechnung@clupilot.com'])); +});