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' => '
{{ __('mail_settings.subtitle') }}
+{{ __('mail_settings.server_hint') }}
+ +{{ $message }}
@enderror +| {{ __('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. --}}
+ |
+