# Postfächer und Absender — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** CluPilot verschickt aus mehreren gepflegten Postfächern (`no-reply`, `office`, `support`, `info`, `billing`) statt aus einem einzigen SMTP-Zugang, und in der Konsole ist einstellbar, welche Mail aus welchem Postfach geht. **Architecture:** Der SMTP-Server steht einmal in `app_settings`; jedes Postfach ist eine Zeile in `mailboxes` mit einem Passwort, das über denselben `SECRETS_KEY` verschlüsselt wird wie die Zugangsdaten. Eine feste Liste von *Zwecken* zeigt auf Postfächer; aufgelöst wird **beim Versenden** durch einen eigenen Symfony-Transport, nie beim Booten — sonst hielte ein langlaufender Queue-Worker für immer die Zugangsdaten, die bei seinem Start galten. **Tech Stack:** Laravel 13.8, Livewire 3 (klassenbasiert, kein Volt), Pest, MariaDB 11.4, Redis, wire-elements/modal, Tailwind v4. Docker-first. ## Global Constraints - **Alle Befehle laufen im Container:** `docker compose exec -T app `. Nie auf dem Host. - **R1/R2:** Seiten sind klassenbasierte Livewire-Vollseiten-Komponenten. Kein Volt. - **R13:** Pfade und Route-Namen englisch. Oberflächentexte deutsch, ausschließlich über `lang/de/*`. - **R18:** Icons neben dem Text, `size-4` in Buttons/Tabellen, `size-5` in der Navigation. Icons in `` gehören in ``. - **R19:** Jede angezeigte Zeit geht durch `->local()`. Gespeichert wird UTC. Nie `->timezone(config('app.timezone'))`. - **R20:** Bearbeiten mit Eingabefeldern passiert im Modal (`$dispatch('openModal', …)`), nie in der Tabellenzeile. Ausnahme: ein einzelnes `and($blade)->toContain('openModal'); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('address', 'neu@clupilot.com') ->call('save'); expect($box->fresh()->address)->toBe('neu@clupilot.com'); }); it('never hands a stored password back to the browser', function () { $box = Mailbox::factory()->create(['password' => 'streng-geheim']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->assertSet('password', '') ->assertDontSee('streng-geheim'); }); it('keeps the old password when the field is left empty', function () { $box = Mailbox::factory()->create(['password' => 'bleibt']); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(EditMailbox::class, ['uuid' => $box->uuid]) ->set('address', 'neu@clupilot.com') ->call('save'); expect($box->fresh()->password)->toBe('bleibt'); }); it('says so when SECRETS_KEY is missing, instead of crashing on the first password', function () { config()->set('admin_access.secrets_key', ''); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->assertOk() ->assertSet('usable', false) ->assertSee(__('mail_settings.no_key')); }); it('refuses to leave the system purpose empty, because it is the fallback', function () { Mailbox::factory()->create(['key' => 'no-reply']); Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); Livewire::actingAs(User::factory()->operator('Owner')->create()) ->test(MailPage::class) ->set('purposes.system', '') ->call('savePurposes') ->assertHasErrors('purposes.system'); expect(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('no-reply'); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `docker compose exec -T app php artisan test --filter=MailSettingsPageTest` Expected: FAIL — `Class "App\Livewire\Admin\Mail" not found` - [ ] **Step 3: Add the permission** ```php forgetCachedPermissions(); $permission = Permission::findOrCreate('mail.manage', 'web'); foreach (['Owner', 'Admin', 'Support'] as $role) { Role::findByName($role, 'web')->givePermissionTo($permission); } app(PermissionRegistrar::class)->forgetCachedPermissions(); } public function down(): void { app(PermissionRegistrar::class)->forgetCachedPermissions(); Permission::where('name', 'mail.manage')->where('guard_name', 'web')->delete(); app(PermissionRegistrar::class)->forgetCachedPermissions(); } }; ``` > **Note for the operator-identity plan:** this permission is created under > guard `web`. The identity migration moves every permission to guard > `operator`, and will carry this one along because it moves them all. - [ ] **Step 4: Write the page component** ```php purpose => mailbox key */ public array $purposes = []; public function mount(): void { $this->authorize('mail.manage'); $this->host = (string) Settings::get('mail.host', ''); $this->port = (int) Settings::get('mail.port', 587); $this->encryption = (string) Settings::get('mail.encryption', 'tls'); foreach (MailPurpose::ALL as $purpose) { $this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), ''); } } public function saveServer(): void { $this->authorize('mail.manage'); // Rules on the ACTION, not on the property: a #[Validate] attribute on // a Livewire property applies class-wide, and savePurposes() below // would drag these along. $this->validate([ 'host' => ['required', 'string', 'max:255'], 'port' => ['required', 'integer', 'min:1', 'max:65535'], 'encryption' => ['required', 'in:tls,ssl,none'], ]); Settings::set('mail.host', $this->host); Settings::set('mail.port', (int) $this->port); Settings::set('mail.encryption', $this->encryption); $this->dispatch('notify', message: __('mail_settings.server_saved')); } public function savePurposes(): void { $this->authorize('mail.manage'); // system is the fallback, so it is the one that may not be empty — // there is nothing left to fall back to. $this->validate( ['purposes.system' => ['required', 'string']], ['purposes.system.required' => __('mail_settings.system_required')], ); foreach (MailPurpose::ALL as $purpose) { Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? ''); } $this->dispatch('notify', message: __('mail_settings.purposes_saved')); } /** * Whether credentials can be stored at all on this installation. * * Public so the page can say it rather than throwing on the first password * a mailbox tries to decrypt — the same courtesy the secrets page already * extends. A missing SECRETS_KEY is a setup state, not an error. */ public bool $usable = true; public function render() { $this->usable = app(\App\Services\Secrets\SecretCipher::class)->isUsable(); return view('livewire.admin.mail', [ 'mailboxes' => Mailbox::query()->orderBy('key')->get(), 'purposeList' => MailPurpose::ALL, ]); } } ``` In the blade, directly under the page header — the same shape the secrets page uses: ```blade @if (! $usable) {{ __('mail_settings.no_key') }} @endif ``` And into both language files: ```php '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.', ``` - [ ] **Step 5: Write the modal component** ```php authorize('mail.manage'); $box = Mailbox::query()->where('uuid', $uuid)->firstOrFail(); $this->uuid = $box->uuid; $this->address = $box->address; $this->displayName = (string) $box->display_name; $this->username = (string) $box->username; $this->noReply = $box->no_reply; $this->active = $box->active; } public function save(): void { $this->authorize('mail.manage'); $this->validate([ 'address' => ['required', 'email', 'max:255'], 'displayName' => ['nullable', 'string', 'max:255'], 'username' => ['nullable', 'string', 'max:255'], 'password' => ['nullable', 'string', 'max:255'], ]); $box = Mailbox::query()->where('uuid', $this->uuid)->firstOrFail(); $box->address = $this->address; $box->display_name = $this->displayName ?: null; $box->username = $this->username ?: null; $box->no_reply = $this->noReply; $box->active = $this->active; // Empty means "leave it alone", not "delete it" — otherwise every edit // of an address would silently drop the password. if ($this->password !== '') { $box->password = $this->password; $box->last_verified_at = null; } $box->save(); $this->password = ''; $this->dispatch('notify', message: __('mail_settings.saved')); $this->dispatch('mailbox-saved'); $this->closeModal(); } } ``` - [ ] **Step 6: Write the views, route, navigation and language files** `resources/views/livewire/admin/mail.blade.php` — three cards in this order. Open `resources/views/livewire/admin/secrets.blade.php` alongside and copy its card and heading markup verbatim so the two pages cannot drift apart: There is **no** `x-ui.select` component and **no** page-header component — the console pages write both out by hand. The header below is copied from `secrets.blade.php` verbatim, `text-2xl` included: a mass edit once pushed 23 page titles to `sm:text-3xl` (40px) where the approved template has 30px, so do not "improve" this line. ```blade

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

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

{{-- 1. The server. One of it, so one card. --}}

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

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

@error('encryption')

{{ $message }}

@enderror
{{ __('mail_settings.save') }}
{{-- 2. The mailboxes. NO input field inside a — R20. --}}

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

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

@if ($testResult !== null) {{ $testResult['ok'] ? __('mail_settings.test_ok') : __('mail_settings.test_failed').' '.$testResult['error'] }} @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') }} {{-- Action column always visible, both buttons single-line (R18). --}} {{ __('mail_settings.test') }} {{-- the edit button below --}}
{{-- 3. The mapping. One @foreach ($mailboxes as $box) @endforeach
@endforeach @error('purposes.system')

{{ $message }}

@enderror {{ __('mail_settings.save') }} ``` Add `'save' => 'Speichern',` and `'encryption_none' => 'Keine',` to both language files. **Do not invent components.** The set is fixed: `alert, badge, button, card, chart, checkbox, icon, input, logo, metric, nav-item, otp-input, progress-stepper, ring, spark, stat-tile`. Anything else is written out by hand, copying the classes from a console page that already does it. Into the action column of the mailbox table, beside the test button: ```blade {{ __('mail_settings.edit') }} ``` The purpose table uses one `