Die Mailseite fragt drei Fragen statt eine Wand zu zeigen

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 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 22:06:50 +02:00
parent 0e3a76a7cb
commit 4477a7699e
5 changed files with 486 additions and 197 deletions

View File

@ -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<int, string> $purposes
* @return array<string, ?string>
*/
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<string, array<string, array{label: string, purpose: string}>>
*/
private function catalogueByPurpose(): array
{
$gruppen = array_fill_keys(MailPurpose::ALL, []);
foreach (MailCatalogue::all() as $key => $entry) {
$gruppen[$entry['purpose']][$key] = $entry;
}
return $gruppen;
}
}

View File

@ -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',

View File

@ -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',

View File

@ -1,199 +1,256 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="mx-auto max-w-[1120px] space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('mail_settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.subtitle') }}</p>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('mail_settings.subtitle') }}</p>
</div>
{{-- 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. --}}
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
@foreach (\App\Livewire\Admin\Mail::TABS as $name)
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
@class([
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
'border-accent-active text-ink' => $tab === $name,
'border-transparent text-muted hover:text-ink' => $tab !== $name,
])>
<x-ui.icon :name="['versand' => 'server', 'postfaecher' => 'mail', 'zuordnung' => 'send'][$name] ?? 'settings'"
class="size-4" />{{ __('mail_settings.tab.'.$name) }}
</button>
@endforeach
</div>
{{-- 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())
<x-ui.alert variant="warning">
{{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (\App\Support\MailDelivery::transport() ?? 'null')]) }}
</x-ui.alert>
@endif
@if (! $usable)
<x-ui.alert variant="warning">{{ __('mail_settings.no_key') }}</x-ui.alert>
@endif
{{-- 1. The server. One of it, so one card. --}}
<x-ui.card>
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.server_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.server_hint') }}</p>
{{-- ── 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')
<x-ui.card class="animate-rise [animation-delay:80ms]">
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.server_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('mail_settings.server_hint') }}</p>
{{-- 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. --}}
<div class="mt-5 rounded-lg border border-line bg-surface-2 px-4 py-3.5">
<x-ui.switch name="deliver" wire:model.live="deliver"
:label="__('mail_settings.deliver')"
:hint="__('mail_settings.deliver_hint')"
:on="__('mail_settings.deliver_on')"
:off="__('mail_settings.deliver_off')" />
</div>
<div class="mt-5 grid gap-4 sm:grid-cols-3">
<x-ui.input name="host" wire:model="host" :label="__('mail_settings.host')" />
<x-ui.input name="port" type="number" wire:model="port" :label="__('mail_settings.port')" />
<div>
<label for="encryption" class="text-sm font-medium text-ink">{{ __('mail_settings.encryption') }}</label>
<select id="encryption" wire:model="encryption"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
<option value="none">{{ __('mail_settings.encryption_none') }}</option>
</select>
@error('encryption')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
<div class="mt-5 rounded-lg border border-line bg-surface-2 px-4 py-3.5">
<x-ui.switch name="deliver" wire:model.live="deliver"
:label="__('mail_settings.deliver')"
:hint="__('mail_settings.deliver_hint')"
:on="__('mail_settings.deliver_on')"
:off="__('mail_settings.deliver_off')" />
</div>
</div>
@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. --}}
<form wire:submit="confirmPassword" class="mt-5 rounded-lg border border-line bg-surface-2 p-4">
<p class="text-sm text-body">{{ __('mail_settings.confirm_first') }}</p>
<div class="mt-3 flex flex-wrap items-start gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
</div>
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
{{ __('mail_settings.confirm_button') }}
</x-ui.button>
<div class="mt-5 grid gap-4 sm:grid-cols-3">
<x-ui.input name="host" wire:model="host" :label="__('mail_settings.host')" />
<x-ui.input name="port" type="number" wire:model="port" :label="__('mail_settings.port')" />
<div>
<label for="encryption" class="text-sm font-medium text-ink">{{ __('mail_settings.encryption') }}</label>
<select id="encryption" wire:model="encryption"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
<option value="none">{{ __('mail_settings.encryption_none') }}</option>
</select>
@error('encryption')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</form>
@else
<x-ui.button wire:click="saveServer" variant="primary" size="md" class="mt-5">
</div>
@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. --}}
<form wire:submit="confirmPassword" class="mt-5 rounded-lg border border-line bg-surface-2 p-4">
<p class="text-sm text-body">{{ __('mail_settings.confirm_first') }}</p>
<div class="mt-3 flex flex-wrap items-start gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
</div>
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
{{ __('mail_settings.confirm_button') }}
</x-ui.button>
</div>
</form>
@else
<x-ui.button wire:click="saveServer" variant="primary" size="md" class="mt-5">
{{ __('mail_settings.save') }}
</x-ui.button>
@endif
</x-ui.card>
@endif
{{-- ── 2. Wer sendet ────────────────────────────────────────────────
Kein Eingabefeld in einem <td> R20. Das Empfängerfeld für den
Testversand steht ÜBER der Tabelle, eines für alle Zeilen. --}}
@if ($tab === 'postfaecher')
<x-ui.card class="animate-rise [animation-delay:80ms]">
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.boxes_title') }}</h2>
<div class="mt-4 max-w-sm">
<x-ui.input name="testRecipient" type="email" wire:model="testRecipient" :label="__('mail_settings.test_recipient')" />
<p class="mt-2 text-sm text-muted">{{ __('mail_settings.test_hint') }}</p>
</div>
@if ($testResult)
<x-ui.alert :variant="$testResult['ok'] ? 'success' : 'danger'" class="mt-4">
<span class="font-medium">{{ $testedKey }}:</span>
@if ($testResult['ok'])
{{ __('mail_settings.test_ok') }}
@else
{{ __('mail_settings.test_failed') }} {{ $testResult['error'] }}
@endif
</x-ui.alert>
@endif
<table class="mt-5 w-full text-sm">
<thead>
<tr class="border-b border-line text-left">
<th class="lbl pb-2">{{ __('mail_settings.address') }}</th>
<th class="lbl pb-2">{{ __('mail_settings.last_verified') }}</th>
<th class="lbl pb-2"></th>
</tr>
</thead>
<tbody>
@foreach ($mailboxes as $box)
<tr wire:key="mailbox-{{ $box->uuid }}" class="border-b border-line last:border-0">
<td class="py-3">
<span class="font-medium text-ink">{{ $box->address }}</span>
<span class="ml-2 text-muted">{{ $box->key }}</span>
</td>
<td class="py-3 text-muted">
{{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }}
</td>
<td class="py-3 text-right space-x-2">
{{-- 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. --}}
<x-ui.button size="sm" variant="ghost" wire:click="test('{{ $box->uuid }}')"
wire:loading.attr="disabled" wire:target="test('{{ $box->uuid }}')">
<x-ui.icon name="send" class="size-4" />
<span wire:loading.remove wire:target="test('{{ $box->uuid }}')">{{ __('mail_settings.test') }}</span>
<span wire:loading wire:target="test('{{ $box->uuid }}')">{{ __('mail_settings.testing') }}</span>
</x-ui.button>
<x-ui.button size="sm" variant="ghost"
x-on:click="$dispatch('openModal', { component: 'edit-mailbox', arguments: { uuid: '{{ $box->uuid }}' } })">
<x-ui.icon name="pencil" class="size-4" />{{ __('mail_settings.edit') }}
</x-ui.button>
</td>
</tr>
@endforeach
</tbody>
</table>
</x-ui.card>
@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 <select> je Zeile ist R20's ausdrückliche Ausnahme: ein Klick, ein
Wert, keine Höhenänderung. --}}
@if ($tab === 'zuordnung')
<x-ui.card class="animate-rise [animation-delay:80ms]">
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.purposes_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('mail_settings.assignments_hint') }}</p>
<div class="mt-6 space-y-5">
@foreach ($catalogueByPurpose as $purpose => $entries)
@php
// Die Adresse, die WIRKLICH greift, wenn eine Mailart
// nichts Eigenes hat — vom Bauteil über dieselbe
// Auflösung geholt, die beim Senden entscheidet. Das
// hier zugeordnete Postfach ist nicht dieselbe Antwort:
// ein abgeschaltetes fällt auf „System" zurück.
$inherited = $inheritedAddress[$purpose] ?? null;
@endphp
<section wire:key="purpose-{{ $purpose }}" class="rounded-lg border border-line">
<div class="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 rounded-t-lg bg-surface-2 px-4 py-3">
<div>
<h3 class="text-sm font-semibold text-ink">{{ __('mail_settings.purpose.'.$purpose) }}</h3>
<p class="mt-0.5 text-xs text-muted">{{ __('mail_settings.purpose_mailbox') }}</p>
</div>
<select wire:model="purposes.{{ $purpose }}"
aria-label="{{ __('mail_settings.purpose_mailbox') }}"
class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body sm:w-72">
<option value=""></option>
@foreach ($mailboxes as $box)
<option value="{{ $box->key }}">{{ $box->address }}</option>
@endforeach
</select>
</div>
@error('purposes.'.$purpose)
<p class="border-t border-line px-4 py-2 text-xs text-danger">{{ $message }}</p>
@enderror
@if ($entries === [])
<p class="px-4 py-3 text-sm text-muted">{{ __('mail_settings.purpose_no_types') }}</p>
@else
<p class="lbl border-t border-line px-4 pt-3">{{ __('mail_settings.route_exceptions') }}</p>
<div class="divide-y divide-line px-4 pb-2">
@foreach ($entries as $key => $entry)
<div wire:key="route-{{ $key }}"
class="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-2.5">
<span class="text-sm text-body">{{ $entry['label'] }}</span>
<select wire:model="routes.{{ $key }}"
aria-label="{{ $entry['label'] }}"
class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body sm:w-72">
<option value="">
{{ $inherited === null
? __('mail_settings.route_inherit_unset')
: __('mail_settings.route_inherit', ['address' => $inherited]) }}
</option>
@foreach ($mailboxes->where('active', true) as $box)
<option value="{{ $box->key }}">{{ $box->address }}</option>
@endforeach
</select>
</div>
@endforeach
</div>
@endif
</section>
@endforeach
</div>
<x-ui.button wire:click="saveAssignments" variant="primary" size="md" class="mt-6">
{{ __('mail_settings.save') }}
</x-ui.button>
@endif
</x-ui.card>
{{-- 2. The mailboxes. NO input field inside a <td> R20. --}}
<x-ui.card>
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.boxes_title') }}</h2>
{{-- One shared recipient field for every row's test button below a
field ABOVE the table, not one growing inside a <td> (R20). --}}
<div class="mt-4 max-w-sm">
<x-ui.input name="testRecipient" type="email" wire:model="testRecipient" :label="__('mail_settings.test_recipient')" />
<p class="mt-2 text-sm text-muted">{{ __('mail_settings.test_hint') }}</p>
</div>
@if ($testResult)
<x-ui.alert :variant="$testResult['ok'] ? 'success' : 'danger'" class="mt-4">
<span class="font-medium">{{ $testedKey }}:</span>
@if ($testResult['ok'])
{{ __('mail_settings.test_ok') }}
@else
{{ __('mail_settings.test_failed') }} {{ $testResult['error'] }}
@endif
</x-ui.alert>
@endif
<table class="mt-5 w-full text-sm">
<thead>
<tr class="border-b border-line text-left">
<th class="lbl pb-2">{{ __('mail_settings.address') }}</th>
<th class="lbl pb-2">{{ __('mail_settings.last_verified') }}</th>
<th class="lbl pb-2"></th>
</tr>
</thead>
<tbody>
@foreach ($mailboxes as $box)
<tr wire:key="mailbox-{{ $box->uuid }}" class="border-b border-line last:border-0">
<td class="py-3">
<span class="font-medium text-ink">{{ $box->address }}</span>
<span class="ml-2 text-muted">{{ $box->key }}</span>
</td>
<td class="py-3 text-muted">
{{ $box->last_verified_at?->local()->isoFormat('DD.MM.YYYY HH:mm') ?? __('mail_settings.never_verified') }}
</td>
<td class="py-3 text-right space-x-2">
{{-- 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. --}}
<x-ui.button size="sm" variant="ghost" wire:click="test('{{ $box->uuid }}')"
wire:loading.attr="disabled" wire:target="test('{{ $box->uuid }}')">
<x-ui.icon name="send" class="size-4" />
<span wire:loading.remove wire:target="test('{{ $box->uuid }}')">{{ __('mail_settings.test') }}</span>
<span wire:loading wire:target="test('{{ $box->uuid }}')">{{ __('mail_settings.testing') }}</span>
</x-ui.button>
<x-ui.button size="sm" variant="ghost"
x-on:click="$dispatch('openModal', { component: 'edit-mailbox', arguments: { uuid: '{{ $box->uuid }}' } })">
<x-ui.icon name="pencil" class="size-4" />{{ __('mail_settings.edit') }}
</x-ui.button>
</td>
</tr>
@endforeach
</tbody>
</table>
</x-ui.card>
{{-- 3. The mapping. One <select> per row R20's stated exception. --}}
<x-ui.card>
<h2 class="text-lg font-semibold text-ink">{{ __('mail_settings.purposes_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('mail_settings.purposes_hint') }}</p>
<div class="mt-5 space-y-3">
@foreach ($purposeList as $purpose)
<div class="flex items-center justify-between gap-6 border-b border-line py-2">
<span class="text-sm text-ink">{{ __('mail_settings.purpose.'.$purpose) }}</span>
<select wire:model="purposes.{{ $purpose }}"
class="w-64 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value=""></option>
@foreach ($mailboxes as $box)
<option value="{{ $box->key }}">{{ $box->address }}</option>
@endforeach
</select>
</div>
@endforeach
</div>
@error('purposes.system')<p class="mt-3 text-xs text-danger">{{ $message }}</p>@enderror
<x-ui.button wire:click="savePurposes" variant="primary" size="md" class="mt-5">
{{ __('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>
</x-ui.card>
@endif
</div>

View File

@ -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']));
});