From 4211f3dfab418b03538b3391dec6d9d559b51a3a Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 01:57:11 +0200 Subject: [PATCH] Give the mailboxes a page, and the support sender its own capability --- app/Livewire/Admin/Mail.php | 104 +++++++ app/Livewire/EditMailbox.php | 87 ++++++ app/Support/Navigation.php | 1 + ...7_28_110000_add_mail_manage_permission.php | 36 +++ lang/de/admin.php | 1 + lang/de/mail_settings.php | 44 +++ lang/en/admin.php | 1 + lang/en/mail_settings.php | 44 +++ resources/views/components/ui/icon.blade.php | 1 + resources/views/livewire/admin/mail.blade.php | 100 +++++++ .../views/livewire/edit-mailbox.blade.php | 27 ++ routes/admin.php | 1 + tests/Feature/Mail/MailSettingsPageTest.php | 280 ++++++++++++++++++ 13 files changed, 727 insertions(+) create mode 100644 app/Livewire/Admin/Mail.php create mode 100644 app/Livewire/EditMailbox.php create mode 100644 database/migrations/2026_07_28_110000_add_mail_manage_permission.php create mode 100644 lang/de/mail_settings.php create mode 100644 lang/en/mail_settings.php create mode 100644 resources/views/livewire/admin/mail.blade.php create mode 100644 resources/views/livewire/edit-mailbox.blade.php create mode 100644 tests/Feature/Mail/MailSettingsPageTest.php diff --git a/app/Livewire/Admin/Mail.php b/app/Livewire/Admin/Mail.php new file mode 100644 index 0000000..da8c2f8 --- /dev/null +++ b/app/Livewire/Admin/Mail.php @@ -0,0 +1,104 @@ + purpose => mailbox key */ + public array $purposes = []; + + /** + * 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 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')); + } + + public function render() + { + $this->usable = app(SecretCipher::class)->isUsable(); + + return view('livewire.admin.mail', [ + 'mailboxes' => Mailbox::query()->orderBy('key')->get(), + 'purposeList' => MailPurpose::ALL, + ]); + } +} diff --git a/app/Livewire/EditMailbox.php b/app/Livewire/EditMailbox.php new file mode 100644 index 0000000..71f8557 --- /dev/null +++ b/app/Livewire/EditMailbox.php @@ -0,0 +1,87 @@ +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(); + } + + public function render() + { + return view('livewire.edit-mailbox'); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 7b5ea8f..93b136b 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -52,6 +52,7 @@ final class Navigation ['admin.revenue', 'trending-up', 'revenue', null], ]], ['label' => __('admin.nav_group.system'), 'items' => [ + ['admin.mail', 'mail', 'mail', 'mail.manage'], ['admin.secrets', 'lock', 'secrets', 'secrets.manage'], ['admin.settings', 'settings', 'settings', null], ]], diff --git a/database/migrations/2026_07_28_110000_add_mail_manage_permission.php b/database/migrations/2026_07_28_110000_add_mail_manage_permission.php new file mode 100644 index 0000000..c5c417d --- /dev/null +++ b/database/migrations/2026_07_28_110000_add_mail_manage_permission.php @@ -0,0 +1,36 @@ +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(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index d1ebd1e..36d66f7 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -21,6 +21,7 @@ return [ 'maintenance' => 'Wartungen', 'vpn' => 'VPN', 'revenue' => 'Umsatz', + 'mail' => 'E-Mail', 'secrets' => 'Zugangsdaten', 'settings' => 'Einstellungen', ], diff --git a/lang/de/mail_settings.php b/lang/de/mail_settings.php new file mode 100644 index 0000000..486ddf1 --- /dev/null +++ b/lang/de/mail_settings.php @@ -0,0 +1,44 @@ + 'E-Mail', + 'subtitle' => 'Absenderadressen und der Server, über den sie verschickt werden.', + + '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', + 'server_hint' => 'Gilt für alle Postfächer — sie liegen auf demselben Server.', + 'host' => 'Server', + 'port' => 'Port', + 'encryption' => 'Verschlüsselung', + 'encryption_none' => 'Keine', + 'save' => 'Speichern', + 'server_saved' => 'Serverdaten gespeichert.', + + 'boxes_title' => 'Postfächer', + 'address' => 'Adresse', + 'display_name' => 'Anzeigename', + 'username' => 'SMTP-Benutzer', + 'username_hint' => 'Leer lassen, wenn er der Adresse entspricht.', + 'password' => 'Passwort', + 'password_hint' => 'Wird verschlüsselt gespeichert und nie wieder vollständig angezeigt. Leer lassen, um das bisherige zu behalten.', + 'no_reply' => 'Keine Antworten (kein Reply-To)', + 'no_reply_hint' => 'Ein „no-reply", auf das man antworten kann, ist eine Lüge im Absender.', + 'active' => 'Aktiv', + 'edit' => 'Bearbeiten', + 'saved' => 'Postfach gespeichert.', + 'never_verified' => 'Noch nicht bestätigt', + 'last_verified' => 'Zuletzt bestätigt', + + 'purposes_title' => 'Wer verschickt was', + 'purposes_hint' => 'Ein Zweck ohne Postfach verschickt über „System".', + 'purpose' => [ + 'maintenance' => 'Wartungsankündigungen', + 'provisioning' => 'Bereitstellung und Bestellbestätigung', + 'support' => 'Antworten auf Support-Anfragen', + 'billing' => 'Rechnungen und Zahlungserinnerungen', + 'system' => 'System (Rückfall für alles Übrige)', + ], + 'purposes_saved' => 'Zuordnung gespeichert.', + 'system_required' => '„System" muss ein Postfach haben — es ist der Rückfall für alle anderen.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index b624c01..437f973 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -21,6 +21,7 @@ return [ 'maintenance' => 'Maintenance', 'vpn' => 'VPN', 'revenue' => 'Revenue', + 'mail' => 'Email', 'secrets' => 'Credentials', 'settings' => 'Settings', ], diff --git a/lang/en/mail_settings.php b/lang/en/mail_settings.php new file mode 100644 index 0000000..a91df3d --- /dev/null +++ b/lang/en/mail_settings.php @@ -0,0 +1,44 @@ + 'Email', + 'subtitle' => 'Sending addresses, and the server they go out through.', + + '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', + 'server_hint' => 'Applies to every mailbox — they all sit on the same server.', + 'host' => 'Server', + 'port' => 'Port', + 'encryption' => 'Encryption', + 'encryption_none' => 'None', + 'save' => 'Save', + 'server_saved' => 'Server settings saved.', + + 'boxes_title' => 'Mailboxes', + 'address' => 'Address', + 'display_name' => 'Display name', + 'username' => 'SMTP user', + 'username_hint' => 'Leave empty if it matches the address.', + 'password' => 'Password', + 'password_hint' => 'Stored encrypted and never shown in full again. Leave empty to keep the current one.', + 'no_reply' => 'No replies (no Reply-To)', + 'no_reply_hint' => 'A "no-reply" that can be replied to is a lie in the sender.', + 'active' => 'Active', + 'edit' => 'Edit', + 'saved' => 'Mailbox saved.', + 'never_verified' => 'Not yet verified', + 'last_verified' => 'Last verified', + + 'purposes_title' => 'Who sends what', + 'purposes_hint' => 'A purpose with no mailbox sends through "System".', + 'purpose' => [ + 'maintenance' => 'Maintenance announcements', + 'provisioning' => 'Provisioning and order confirmation', + 'support' => 'Replies to support requests', + 'billing' => 'Invoices and payment reminders', + 'system' => 'System (fallback for everything else)', + ], + 'purposes_saved' => 'Mapping saved.', + 'system_required' => '"System" must have a mailbox — it is the fallback for all the others.', +]; diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 7e9c586..9ec707c 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -38,6 +38,7 @@ 'pencil' => '', 'trash-2' => '', 'settings' => '', + 'mail' => '', ]; $body = $icons[$name] ?? ''; diff --git a/resources/views/livewire/admin/mail.blade.php b/resources/views/livewire/admin/mail.blade.php new file mode 100644 index 0000000..2ed068b --- /dev/null +++ b/resources/views/livewire/admin/mail.blade.php @@ -0,0 +1,100 @@ +
+
+

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

+

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

+
+ + @if (! $usable) + {{ __('mail_settings.no_key') }} + @endif + + {{-- 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') }}

+ + + + + + + + + + + @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') }} + + {{-- Edit only (R18: icon beside its text, single line). + The test-send button belongs to a later task — + shipping one that cannot yet test anything is a + promise this page does not keep. --}} + + {{ __('mail_settings.edit') }} + +
+
+ + {{-- 3. The mapping. One + + @foreach ($mailboxes as $box) + + @endforeach + +
+ @endforeach + + + @error('purposes.system')

{{ $message }}

@enderror + + + {{ __('mail_settings.save') }} + + + diff --git a/resources/views/livewire/edit-mailbox.blade.php b/resources/views/livewire/edit-mailbox.blade.php new file mode 100644 index 0000000..40b6a90 --- /dev/null +++ b/resources/views/livewire/edit-mailbox.blade.php @@ -0,0 +1,27 @@ +
+

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

+ +
+ + + + + + + + +
+ +

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

+ + +
+
+ +
+ {{ __('common.cancel') }} + {{ __('mail_settings.save') }} +
+
diff --git a/routes/admin.php b/routes/admin.php index 79bfce7..e692fd1 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -36,6 +36,7 @@ Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); +Route::get('/mail', Admin\Mail::class)->name('mail'); Route::get('/secrets', Admin\Secrets::class)->name('secrets'); Route::get('/settings', Admin\Settings::class)->name('settings'); diff --git a/tests/Feature/Mail/MailSettingsPageTest.php b/tests/Feature/Mail/MailSettingsPageTest.php new file mode 100644 index 0000000..4b41900 --- /dev/null +++ b/tests/Feature/Mail/MailSettingsPageTest.php @@ -0,0 +1,280 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + + // Blank slate, not the migration's seeded five: every other file in this + // directory does the same (see tests/Pest.php). Two of the tests below + // pin a mailbox to the key 'support' and another to 'no-reply' — both + // collide with mailboxes.key's unique constraint against the seeded + // baseline RefreshDatabase leaves in place. Clearing keeps this file + // deterministic regardless of what the seed migration happens to contain. + clearMailboxSeed(); +}); + +it('is closed to an operator without mail.manage', function () { + $user = User::factory()->operator('Read-only')->create(); + + Livewire::actingAs($user)->test(MailPage::class)->assertForbidden(); +}); + +it('opens for an operator who has it', function () { + $user = User::factory()->operator('Owner')->create(); + + Livewire::actingAs($user)->test(MailPage::class)->assertOk()->assertSet('usable', true); +}); + +it('grants mail.manage to Admin and Support as well as Owner', function () { + // The migration hands the capability to three roles, not one. Nothing + // above proves the other two actually received it — an Owner-only test + // would stay green even if the migration's role loop silently lost a name. + expect(User::factory()->operator('Admin')->create()->can('mail.manage'))->toBeTrue() + ->and(User::factory()->operator('Support')->create()->can('mail.manage'))->toBeTrue(); +}); + +it('is not opened by secrets.manage alone — that is the point of splitting them', function () { + // Billing holds secrets-adjacent capabilities but has no business changing + // the support sender, and vice versa. If this ever passes, the two + // capabilities have quietly been merged again. + $user = User::factory()->operator('Billing')->create(); + + expect($user->can('mail.manage'))->toBeFalse(); + + Livewire::actingAs($user)->test(MailPage::class)->assertForbidden(); +}); + +it('edits a mailbox in a modal, never in the row (R20)', function () { + $box = Mailbox::factory()->create(['key' => 'support']); + + $blade = file_get_contents(resource_path('views/livewire/admin/mail.blade.php')); + + expect($blade)->not->toMatch('/]*>(?:(?!<\/td>).)*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'); +}); + +// --- Coverage beyond the brief's list: behaviours the page and modal claim +// but the tests above never exercise. Each was verified to fail for the +// right reason before the implementation existed, and again by mutation +// afterwards (see the task report). + +it('lists every mailbox on the page', function () { + Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); + + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(MailPage::class) + ->assertSee('support@clupilot.com') + ->assertSee('support'); +}); + +it('shows the currently configured mailbox for each purpose', function () { + Mailbox::factory()->create(['key' => 'support']); + Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); + + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(MailPage::class) + ->assertSet('purposes.support', 'support'); +}); + +it('saves the mail server settings', function () { + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(MailPage::class) + ->set('host', 'smtp.example.com') + ->set('port', 2525) + ->set('encryption', 'ssl') + ->call('saveServer') + ->assertHasNoErrors(); + + expect(Settings::get('mail.host'))->toBe('smtp.example.com') + ->and(Settings::get('mail.port'))->toBe(2525) + ->and(Settings::get('mail.encryption'))->toBe('ssl'); +}); + +it('rejects an empty mail server host', function () { + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(MailPage::class) + ->set('host', '') + ->call('saveServer') + ->assertHasErrors('host'); +}); + +it('saves the purpose-to-mailbox mapping', function () { + Mailbox::factory()->create(['key' => 'support']); + Mailbox::factory()->create(['key' => 'no-reply']); + + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(MailPage::class) + ->set('purposes.system', 'no-reply') + ->set('purposes.support', 'support') + ->call('savePurposes') + ->assertHasNoErrors(); + + expect(Settings::get(MailPurpose::settingKey(MailPurpose::SUPPORT)))->toBe('support') + ->and(Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM)))->toBe('no-reply'); +}); + +it('re-checks mail.manage on every page action, not only at mount', function () { + // mount() alone would pass every test above: an Owner has the capability + // for the whole test, so nothing so far proves saveServer()/savePurposes() + // carry their own authorize() rather than relying on mount() having run + // once. Swap the authenticated user between mount and the action — same + // shape as SecretsPageTest's "refuses every action while locked", applied + // to the capability gate instead of the password gate. + $owner = User::factory()->operator('Owner')->create(); + $billing = User::factory()->operator('Billing')->create(); + + // A fresh mount per action, like SecretsPageTest's equivalent: a 403 + // response carries no usable Livewire snapshot, so a second ->call() on + // the same already-forbidden instance fails on the snapshot itself + // rather than on the authorization this test means to check. + foreach (['saveServer', 'savePurposes'] as $action) { + $page = Livewire::actingAs($owner)->test(MailPage::class)->assertOk(); + + Livewire::actingAs($billing); + + $page->call($action)->assertForbidden(); + } +}); + +it('re-checks mail.manage on modal save, not only at mount', function () { + // Same gap as above, for the modal: mount() re-reads the record instead of + // trusting the browser (R20), but that is a different guarantee from + // save() re-authorising instead of trusting a mount that already happened. + $box = Mailbox::factory()->create(['key' => 'support']); + $owner = User::factory()->operator('Owner')->create(); + $billing = User::factory()->operator('Billing')->create(); + + $modal = Livewire::actingAs($owner)->test(EditMailbox::class, ['uuid' => $box->uuid])->assertOk(); + + Livewire::actingAs($billing); + + $modal->set('address', 'neu@clupilot.com')->call('save')->assertForbidden(); +}); + +it('refuses to edit a mailbox without mail.manage, even though the modal bypasses the page route', function () { + $box = Mailbox::factory()->create(['key' => 'support']); + + Livewire::actingAs(User::factory()->operator('Billing')->create()) + ->test(EditMailbox::class, ['uuid' => $box->uuid]) + ->assertForbidden(); +}); + +it('updates the password when a new one is entered', function () { + // The mirror of "keeps the old password when the field is left empty": + // that test alone would stay green even if save() stopped writing the + // password entirely, since "never changes" also satisfies "kept the old + // one". This proves the other half — a new value actually lands. + $box = Mailbox::factory()->create(['password' => 'alt']); + + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(EditMailbox::class, ['uuid' => $box->uuid]) + ->set('password', 'neu-und-sicher') + ->call('save'); + + expect($box->fresh()->password)->toBe('neu-und-sicher'); +}); + +it('clears last_verified_at when the password changes, since it no longer proves anything', function () { + $box = Mailbox::factory()->create(['password' => 'alt', 'last_verified_at' => now()]); + + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(EditMailbox::class, ['uuid' => $box->uuid]) + ->set('password', 'neu-und-sicher') + ->call('save'); + + expect($box->fresh()->last_verified_at)->toBeNull(); +}); + +it('saves display name, username, no-reply and active together', function () { + $box = Mailbox::factory()->create(['no_reply' => false, 'active' => true]); + + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(EditMailbox::class, ['uuid' => $box->uuid]) + ->set('displayName', 'Support-Team') + ->set('username', 'smtp-user') + ->set('noReply', true) + ->set('active', false) + ->call('save'); + + $box->refresh(); + + expect($box->display_name)->toBe('Support-Team') + ->and($box->username)->toBe('smtp-user') + ->and($box->no_reply)->toBeTrue() + ->and($box->active)->toBeFalse(); +}); + +it('pre-fills the form from the existing record', function () { + $box = Mailbox::factory()->create([ + 'address' => 'support@clupilot.com', + 'display_name' => 'Support-Team', + 'username' => 'smtp-support', + 'no_reply' => true, + 'active' => false, + ]); + + Livewire::actingAs(User::factory()->operator('Owner')->create()) + ->test(EditMailbox::class, ['uuid' => $box->uuid]) + ->assertSet('address', 'support@clupilot.com') + ->assertSet('displayName', 'Support-Team') + ->assertSet('username', 'smtp-support') + ->assertSet('noReply', true) + ->assertSet('active', false); +});