Give the mailboxes a page, and the support sender its own capability

feat/mailboxes
nexxo 2026-07-28 01:57:11 +02:00
parent a12ab148b3
commit 4211f3dfab
13 changed files with 727 additions and 0 deletions

104
app/Livewire/Admin/Mail.php Normal file
View File

@ -0,0 +1,104 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Livewire\Attributes\Layout;
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 is a later task's job. This page only shows and edits
* what already exists a button that cannot actually test is a promise the
* page does not keep (the same reasoning the secrets page already applies).
*/
#[Layout('layouts.admin')]
class Mail extends Component
{
public string $host = '';
public int|string $port = 587;
public string $encryption = 'tls';
/** @var array<string, string> 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,
]);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Livewire;
use App\Models\Mailbox;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a mailbox, in a modal (R20).
*
* A modal is reachable WITHOUT the page's route middleware, so it authorises
* itself and re-reads the record instead of trusting a property the browser
* hydrated. save() re-authorises independently of mount() for the same reason
* Secrets::guard() checks on every action rather than once: a Livewire action
* is reachable by anyone who can post to /livewire/update, and a mount that
* succeeded earlier in the session proves nothing about the request now.
*/
class EditMailbox extends ModalComponent
{
public string $uuid = '';
public string $address = '';
public string $displayName = '';
public string $username = '';
/** Always starts empty: a stored password never travels to the browser. */
public string $password = '';
public bool $noReply = false;
public bool $active = true;
public function mount(string $uuid): void
{
$this->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');
}
}

View File

@ -52,6 +52,7 @@ final class Navigation
['admin.revenue', 'trending-up', 'revenue', null], ['admin.revenue', 'trending-up', 'revenue', null],
]], ]],
['label' => __('admin.nav_group.system'), 'items' => [ ['label' => __('admin.nav_group.system'), 'items' => [
['admin.mail', 'mail', 'mail', 'mail.manage'],
['admin.secrets', 'lock', 'secrets', 'secrets.manage'], ['admin.secrets', 'lock', 'secrets', 'secrets.manage'],
['admin.settings', 'settings', 'settings', null], ['admin.settings', 'settings', 'settings', null],
]], ]],

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Its own capability, not secrets.manage.
*
* Whoever keeps the support sender working does not thereby need the Stripe key
* and the DNS token. Splitting it is the difference between "can change an
* address" and "can move money".
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->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();
}
};

View File

@ -21,6 +21,7 @@ return [
'maintenance' => 'Wartungen', 'maintenance' => 'Wartungen',
'vpn' => 'VPN', 'vpn' => 'VPN',
'revenue' => 'Umsatz', 'revenue' => 'Umsatz',
'mail' => 'E-Mail',
'secrets' => 'Zugangsdaten', 'secrets' => 'Zugangsdaten',
'settings' => 'Einstellungen', 'settings' => 'Einstellungen',
], ],

44
lang/de/mail_settings.php Normal file
View File

@ -0,0 +1,44 @@
<?php
return [
'title' => '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.',
];

View File

@ -21,6 +21,7 @@ return [
'maintenance' => 'Maintenance', 'maintenance' => 'Maintenance',
'vpn' => 'VPN', 'vpn' => 'VPN',
'revenue' => 'Revenue', 'revenue' => 'Revenue',
'mail' => 'Email',
'secrets' => 'Credentials', 'secrets' => 'Credentials',
'settings' => 'Settings', 'settings' => 'Settings',
], ],

44
lang/en/mail_settings.php Normal file
View File

@ -0,0 +1,44 @@
<?php
return [
'title' => '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.',
];

View File

@ -38,6 +38,7 @@
'pencil' => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>', 'pencil' => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>', 'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', 'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'mail' => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
]; ];
$body = $icons[$name] ?? ''; $body = $icons[$name] ?? '';

View File

@ -0,0 +1,100 @@
<div class="mx-auto max-w-3xl 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>
</div>
@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>
<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>
</div>
<x-ui.button wire:click="saveServer" variant="primary" size="md" class="mt-5">
{{ __('mail_settings.save') }}
</x-ui.button>
</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>
<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">
{{-- 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. --}}
<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>
</div>

View File

@ -0,0 +1,27 @@
<div class="rounded-lg bg-surface p-6">
<h3 class="text-base font-semibold text-ink">{{ __('mail_settings.edit') }}</h3>
<div class="mt-4 space-y-4">
<x-ui.input name="address" type="email" wire:model="address" :label="__('mail_settings.address')" />
<x-ui.input name="displayName" wire:model="displayName" :label="__('mail_settings.display_name')" />
<x-ui.input name="username" wire:model="username"
:label="__('mail_settings.username')" :hint="__('mail_settings.username_hint')" />
<x-ui.input name="password" type="password" autocomplete="off" wire:model="password"
:label="__('mail_settings.password')" :hint="__('mail_settings.password_hint')" />
<div class="space-y-2 rounded-lg border border-line bg-surface-2 p-4">
<x-ui.checkbox name="noReply" wire:model="noReply" :label="__('mail_settings.no_reply')" />
<p class="pl-7 text-xs text-muted">{{ __('mail_settings.no_reply_hint') }}</p>
<x-ui.checkbox name="active" wire:model="active" :label="__('mail_settings.active')" />
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled" wire:target="save">{{ __('mail_settings.save') }}</x-ui.button>
</div>
</div>

View File

@ -36,6 +36,7 @@ Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance');
Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/vpn', Admin\Vpn::class)->name('vpn');
Route::get('/revenue', Admin\Revenue::class)->name('revenue'); 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('/secrets', Admin\Secrets::class)->name('secrets');
Route::get('/settings', Admin\Settings::class)->name('settings'); Route::get('/settings', Admin\Settings::class)->name('settings');

View File

@ -0,0 +1,280 @@
<?php
use App\Livewire\Admin\Mail as MailPage;
use App\Livewire\EditMailbox;
use App\Models\Mailbox;
use App\Models\User;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
use Livewire\Livewire;
beforeEach(function () {
config()->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[^>]*>(?:(?!<\/td>).)*<input/s')
->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);
});