From 576cfcafac4947ac5067171ee1bc8abb17c90b15 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 21:03:13 +0200 Subject: [PATCH] Plan the mailbox work as eight testable steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review against the spec turned up four gaps and one latent bug in what already ships. The bug: Laravel 13's MailManager reads only 'scheme' when building an SMTP transport (MailManager.php:196) — the old 'encryption' fallback is gone — and .env carries MAIL_SCHEME=tls, which Symfony does not accept; it knows smtp and smtps. Nothing has failed because MAIL_MAILER=log means no connection has ever been opened. The plan stores a human-facing tls/ssl choice and translates it where it is used, and never copies MAIL_SCHEME through. The gaps: the cancellation mail and the provisioning notification were edited but not tested, secrets.manage was never proven insufficient for the new page, and a missing SECRETS_KEY had no task making the page say so rather than throw. Co-Authored-By: Claude Opus 5 --- .../superpowers/plans/2026-07-27-mailboxes.md | 2154 +++++++++++++++++ 1 file changed, 2154 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-mailboxes.md diff --git a/docs/superpowers/plans/2026-07-27-mailboxes.md b/docs/superpowers/plans/2026-07-27-mailboxes.md new file mode 100644 index 0000000..09690f3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-mailboxes.md @@ -0,0 +1,2154 @@ +# 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 `