feat(mail): SMTP configuration in Settings (encrypted password, runtime override, test-send)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 23:42:19 +02:00
parent bb1ad53030
commit 73eecd03e0
6 changed files with 562 additions and 2 deletions

View File

@ -2,10 +2,185 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Models\Setting;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
use Throwable;
/**
* SMTP configuration card. The operator points the panel at an SMTP server so
* password-reset mails (and future notifications) actually leave the box. Values
* are stored as Setting rows and applied to runtime config by AppServiceProvider::boot().
*
* The SMTP password is the one secret here: it is stored ENCRYPTED at rest
* (Crypt::encryptString, APP_KEY) Setting::put stores plaintext, so we encrypt the
* value ourselves never rendered back to the browser, and never logged/echoed
* (any error from the test-send is shown without the password).
*/
class Email extends Component class Email extends Component
{ {
public string $mail_host = '';
public int $mail_port = 587;
public string $mail_username = '';
/** New password typed in the form. Empty = keep the stored one (never pre-filled). */
public string $mail_password = '';
public string $mail_encryption = 'tls';
public string $mail_from_address = '';
public string $mail_from_name = '';
/** True once a password has been stored, so the field shows a "(set)" placeholder. */
public bool $passwordSet = false;
public function mount(): void
{
$this->mail_host = Setting::get('mail_host', '') ?? '';
$this->mail_port = (int) (Setting::get('mail_port', '587') ?? 587);
$this->mail_username = Setting::get('mail_username', '') ?? '';
$this->mail_encryption = Setting::get('mail_encryption', 'tls') ?? 'tls';
$this->mail_from_address = Setting::get('mail_from_address', '') ?? '';
$this->mail_from_name = Setting::get('mail_from_name', '') ?? '';
$this->passwordSet = (Setting::get('mail_password') ?? '') !== '';
}
protected function rules(): array
{
return [
'mail_host' => ['required', 'string', 'max:255'],
'mail_port' => ['required', 'integer', 'min:1', 'max:65535'],
'mail_username' => ['nullable', 'string', 'max:255'],
'mail_password' => ['nullable', 'string', 'max:255'],
'mail_encryption' => ['required', 'in:none,tls,ssl'],
'mail_from_address' => ['required', 'email', 'max:255'],
'mail_from_name' => ['required', 'string', 'max:255'],
];
}
public function save(): void
{
$this->validate();
Setting::put('mail_host', $this->mail_host);
Setting::put('mail_port', (string) $this->mail_port);
Setting::put('mail_username', $this->mail_username);
Setting::put('mail_encryption', $this->mail_encryption);
Setting::put('mail_from_address', $this->mail_from_address);
Setting::put('mail_from_name', $this->mail_from_name);
// Only overwrite the stored password when a new one was typed; otherwise keep
// the existing (encrypted) value untouched. Store encrypted at rest.
if ($this->mail_password !== '') {
Setting::put('mail_password', Crypt::encryptString($this->mail_password));
$this->passwordSet = true;
}
$this->reset('mail_password');
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => 'mail.configure',
'target' => $this->mail_host,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('mail.notify_saved'));
}
/**
* Send a one-line test mail to the CURRENT user's own address, using a mailer built
* from the saved settings. Never targets anyone else; never leaks the password any
* transport error is surfaced as a message with the secret masked out.
*/
public function sendTest(): void
{
if (! $this->configured()) {
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.not_configured')]));
return;
}
$to = Auth::user()->email;
try {
$this->applyMailerConfig();
Mail::raw(__('mail.test_body'), function ($message) use ($to) {
$message->to($to)->subject(__('mail.test_subject'));
});
$this->dispatch('notify', message: __('mail.notify_test_ok', ['email' => $to]));
} catch (Throwable $e) {
$this->dispatch('notify', message: __('mail.notify_test_failed', [
'error' => $this->maskSecret(Str::limit($e->getMessage(), 160)),
]));
}
}
/** Host + from-address present — the minimum to actually send. */
public function configured(): bool
{
return $this->mail_host !== '' && $this->mail_from_address !== '';
}
/**
* Apply the saved SMTP settings to runtime config for the duration of this request,
* so the test-send uses exactly what the operator configured (decrypting the stored
* password). Mirrors AppServiceProvider's runtime override.
*/
private function applyMailerConfig(): void
{
$password = '';
$stored = Setting::get('mail_password');
if ($stored !== null && $stored !== '') {
try {
$password = Crypt::decryptString($stored);
} catch (Throwable) {
$password = '';
}
}
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.host' => $this->mail_host,
'mail.mailers.smtp.port' => $this->mail_port,
'mail.mailers.smtp.username' => $this->mail_username ?: null,
'mail.mailers.smtp.password' => $password ?: null,
'mail.mailers.smtp.encryption' => $this->mail_encryption === 'none' ? null : $this->mail_encryption,
'mail.from.address' => $this->mail_from_address,
'mail.from.name' => $this->mail_from_name,
]);
// Drop any mailer Laravel already resolved this request so the new config takes effect.
Mail::purge('smtp');
}
/** Strip the stored SMTP password out of any string before it is shown/logged. */
private function maskSecret(string $text): string
{
$stored = Setting::get('mail_password');
if ($stored === null || $stored === '') {
return $text;
}
try {
$plain = Crypt::decryptString($stored);
} catch (Throwable) {
return $text;
}
return $plain !== '' ? str_replace($plain, '****', $text) : $text;
}
public function render() public function render()
{ {
return view('livewire.settings.email'); return view('livewire.settings.email');

View File

@ -3,9 +3,12 @@
namespace App\Providers; namespace App\Providers;
use App\Http\Middleware\EnsureSecurityOnboarded; use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Models\Setting;
use App\Services\DeploymentService; use App\Services\DeploymentService;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Livewire\Livewire; use Livewire\Livewire;
use Throwable;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -49,5 +52,51 @@ class AppServiceProvider extends ServiceProvider
if ($domain !== null) { if ($domain !== null) {
config(['app.url' => 'https://'.$domain]); config(['app.url' => 'https://'.$domain]);
} }
$this->applyMailSettings();
}
/**
* Apply the operator's SMTP settings (Settings\Email) to runtime config so
* password-reset mails and notifications actually send. boot() runs every request,
* so a saved change takes effect on the next request.
*
* Only switches to the `smtp` mailer when host + from-address are configured;
* otherwise the safe install default (`log`) is left untouched, so nothing breaks
* while unconfigured. The stored SMTP password is encrypted at rest (APP_KEY) and
* decrypted here. Wrapped in try/catch like DeploymentService the settings table
* may not exist yet during install/migrate, and that must never break boot.
*/
private function applyMailSettings(): void
{
try {
$host = Setting::get('mail_host');
$from = Setting::get('mail_from_address');
if ($host === null || $host === '' || $from === null || $from === '') {
return; // unconfigured -> keep the safe default mailer
}
$encryption = Setting::get('mail_encryption', 'tls');
$storedPassword = Setting::get('mail_password');
$password = null;
if ($storedPassword !== null && $storedPassword !== '') {
$password = Crypt::decryptString($storedPassword);
}
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.host' => $host,
'mail.mailers.smtp.port' => (int) (Setting::get('mail_port', '587') ?? 587),
'mail.mailers.smtp.username' => Setting::get('mail_username') ?: null,
'mail.mailers.smtp.password' => $password ?: null,
'mail.mailers.smtp.encryption' => $encryption === 'none' ? null : $encryption,
'mail.from.address' => $from,
'mail.from.name' => Setting::get('mail_from_name') ?: $from,
]);
} catch (Throwable) {
// Settings table not migrated yet (install/migrate) or an undecryptable value —
// leave the default mailer untouched. Never break boot.
}
} }
} }

39
lang/de/mail.php Normal file
View File

@ -0,0 +1,39 @@
<?php
// SMTP/E-Mail-Konfiguration (Settings → E-Mail). Schlüssel identisch mit lang/en/mail.php (R16).
return [
// Panel
'title' => 'SMTP-Server',
'subtitle' => 'Postausgang für Passwort-Reset und Benachrichtigungen',
// Felder
'host' => 'Host',
'port' => 'Port',
'username' => 'Benutzername',
'password' => 'Passwort',
'encryption' => 'Verschlüsselung',
'from_address' => 'Absenderadresse',
'from_name' => 'Absendername',
// Verschlüsselungsoptionen
'encryption_none' => 'Keine',
'encryption_tls' => 'TLS',
'encryption_ssl' => 'SSL',
// Passwortfeld
'password_set_placeholder' => '•••• (gesetzt)',
// Aktionen
'save' => 'Speichern',
'test_send' => 'Testmail senden',
// Testmail-Inhalt
'test_subject' => 'Clusev — Testmail',
'test_body' => 'Dies ist eine Testmail von Clusev. Wenn du sie erhältst, ist der SMTP-Server korrekt konfiguriert.',
// Benachrichtigungen
'notify_saved' => 'SMTP-Einstellungen gespeichert.',
'notify_test_ok' => 'Testmail an :email gesendet.',
'notify_test_failed' => 'Testmail fehlgeschlagen: :error',
'not_configured' => 'SMTP ist nicht konfiguriert.',
];

39
lang/en/mail.php Normal file
View File

@ -0,0 +1,39 @@
<?php
// SMTP/email configuration (Settings → Email). Keys identical to lang/de/mail.php (R16).
return [
// Panel
'title' => 'SMTP server',
'subtitle' => 'Outgoing mail for password resets and notifications',
// Fields
'host' => 'Host',
'port' => 'Port',
'username' => 'Username',
'password' => 'Password',
'encryption' => 'Encryption',
'from_address' => 'From address',
'from_name' => 'From name',
// Encryption options
'encryption_none' => 'None',
'encryption_tls' => 'TLS',
'encryption_ssl' => 'SSL',
// Password field
'password_set_placeholder' => '•••• (set)',
// Actions
'save' => 'Save',
'test_send' => 'Send test mail',
// Test mail content
'test_subject' => 'Clusev — test mail',
'test_body' => 'This is a test mail from Clusev. If you received it, the SMTP server is configured correctly.',
// Notifications
'notify_saved' => 'SMTP settings saved.',
'notify_test_ok' => 'Test mail sent to :email.',
'notify_test_failed' => 'Test mail failed: :error',
'not_configured' => 'SMTP is not configured.',
];

View File

@ -1,5 +1,76 @@
@php
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="space-y-5"> <div class="space-y-5">
<x-panel :title="__('settings.email_title')" :subtitle="__('settings.email_subtitle')"> <x-panel :title="__('mail.title')" :subtitle="__('mail.subtitle')">
<p class="font-mono text-[11px] text-ink-4">{{ __('settings.coming_soon') }}</p> <form wire:submit="save" class="space-y-4">
<div class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_120px]">
<div>
<label class="{{ $label }}">{{ __('mail.host') }}</label>
<input wire:model="mail_host" type="text" class="{{ $field }} font-mono" placeholder="smtp.example.com" />
@error('mail_host') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('mail.port') }}</label>
<input wire:model="mail_port" type="text" inputmode="numeric" class="{{ $field }} font-mono" />
@error('mail_port') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">{{ __('mail.username') }}</label>
<input wire:model="mail_username" type="text" autocomplete="off" class="{{ $field }} font-mono" />
@error('mail_username') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('mail.password') }}</label>
<input wire:model="mail_password" type="password" autocomplete="new-password"
placeholder="{{ $passwordSet ? __('mail.password_set_placeholder') : '' }}"
class="{{ $field }} font-mono" />
@error('mail_password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">{{ __('mail.encryption') }}</label>
<select wire:model="mail_encryption" class="{{ $field }} font-mono">
<option value="none">{{ __('mail.encryption_none') }}</option>
<option value="tls">{{ __('mail.encryption_tls') }}</option>
<option value="ssl">{{ __('mail.encryption_ssl') }}</option>
</select>
@error('mail_encryption') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">{{ __('mail.from_address') }}</label>
<input wire:model="mail_from_address" type="text" class="{{ $field }} font-mono" placeholder="panel@example.com" />
@error('mail_from_address') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('mail.from_name') }}</label>
<input wire:model="mail_from_name" type="text" class="{{ $field }}" placeholder="Clusev" />
@error('mail_from_name') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
</div>
<div class="flex flex-wrap justify-end gap-2 border-t border-line pt-4">
<x-btn variant="secondary" type="button" wire:click="sendTest" wire:target="sendTest" wire:loading.attr="disabled"
:disabled="! $this->configured()">
<svg wire:loading wire:target="sendTest" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('mail.test_send') }}
</x-btn>
<x-btn variant="primary" type="submit" wire:target="save" wire:loading.attr="disabled">
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('common.save') }}
</x-btn>
</div>
</form>
</x-panel> </x-panel>
</div> </div>

View File

@ -0,0 +1,187 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\Email;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Models\User;
use App\Providers\AppServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
use Tests\TestCase;
class SmtpConfigTest extends TestCase
{
use RefreshDatabase;
private function actingUser(): User
{
$user = User::factory()->create(['email' => 'admin@clusev.local']);
$this->actingAs($user);
return $user;
}
public function test_saving_persists_settings_and_stores_password_encrypted(): void
{
$this->actingUser();
Livewire::test(Email::class)
->set('mail_host', 'smtp.example.com')
->set('mail_port', 2525)
->set('mail_username', 'panel')
->set('mail_password', 's3cr3t-pw')
->set('mail_encryption', 'tls')
->set('mail_from_address', 'panel@example.com')
->set('mail_from_name', 'Clusev')
->call('save')
->assertHasNoErrors();
$this->assertSame('smtp.example.com', Setting::get('mail_host'));
$this->assertSame('2525', Setting::get('mail_port'));
$this->assertSame('panel', Setting::get('mail_username'));
$this->assertSame('tls', Setting::get('mail_encryption'));
$this->assertSame('panel@example.com', Setting::get('mail_from_address'));
$this->assertSame('Clusev', Setting::get('mail_from_name'));
// Stored value must NOT be the plaintext, but must decrypt back to it.
$stored = Setting::get('mail_password');
$this->assertNotNull($stored);
$this->assertNotSame('s3cr3t-pw', $stored, 'password must be encrypted at rest');
$this->assertSame('s3cr3t-pw', Crypt::decryptString($stored));
$this->assertDatabaseHas('audit_events', ['action' => 'mail.configure', 'target' => 'smtp.example.com']);
}
public function test_password_is_not_overwritten_when_left_blank(): void
{
$this->actingUser();
Setting::put('mail_host', 'smtp.example.com');
Setting::put('mail_from_address', 'panel@example.com');
Setting::put('mail_password', Crypt::encryptString('original-pw'));
Livewire::test(Email::class)
->set('mail_from_name', 'Clusev')
->set('mail_password', '') // left blank -> keep the stored one
->call('save')
->assertHasNoErrors();
$this->assertSame('original-pw', Crypt::decryptString(Setting::get('mail_password')));
}
public function test_configured_reflects_host_and_from_presence(): void
{
$this->actingUser();
$component = Livewire::test(Email::class);
$this->assertFalse($component->instance()->configured());
$component->set('mail_host', 'smtp.example.com');
$this->assertFalse($component->instance()->configured(), 'host alone is not enough');
$component->set('mail_from_address', 'panel@example.com');
$this->assertTrue($component->instance()->configured());
}
public function test_runtime_override_switches_to_smtp_when_configured(): void
{
config(['mail.default' => 'log']);
Setting::put('mail_host', 'smtp.example.com');
Setting::put('mail_port', '2525');
Setting::put('mail_username', 'panel');
Setting::put('mail_password', Crypt::encryptString('s3cr3t-pw'));
Setting::put('mail_encryption', 'ssl');
Setting::put('mail_from_address', 'panel@example.com');
Setting::put('mail_from_name', 'Clusev');
// Re-run the provider's boot, which applies the mail settings.
$this->app->register(AppServiceProvider::class, true);
$this->assertSame('smtp', config('mail.default'));
$this->assertSame('smtp.example.com', config('mail.mailers.smtp.host'));
$this->assertSame(2525, config('mail.mailers.smtp.port'));
$this->assertSame('panel', config('mail.mailers.smtp.username'));
$this->assertSame('s3cr3t-pw', config('mail.mailers.smtp.password'));
$this->assertSame('ssl', config('mail.mailers.smtp.encryption'));
$this->assertSame('panel@example.com', config('mail.from.address'));
}
public function test_runtime_override_leaves_default_mailer_when_unconfigured(): void
{
config(['mail.default' => 'log']);
// No settings -> default mailer must stay untouched.
$this->app->register(AppServiceProvider::class, true);
$this->assertSame('log', config('mail.default'));
}
public function test_encryption_none_maps_to_null(): void
{
config(['mail.default' => 'log']);
Setting::put('mail_host', 'smtp.example.com');
Setting::put('mail_encryption', 'none');
Setting::put('mail_from_address', 'panel@example.com');
$this->app->register(AppServiceProvider::class, true);
$this->assertSame('smtp', config('mail.default'));
$this->assertNull(config('mail.mailers.smtp.encryption'));
}
public function test_test_send_dispatches_a_mail_to_the_current_user(): void
{
// Force the smtp mailer onto the in-memory `array` transport so the test-send
// captures the message WITHOUT opening a real SMTP connection. The component's
// runtime config() only sets host/port/etc., never `transport`, so this sticks
// across its Mail::purge('smtp') rebuild — fully deterministic, no network.
config(['mail.mailers.smtp.transport' => 'array']);
$user = $this->actingUser();
Setting::put('mail_host', 'smtp.example.com');
Setting::put('mail_from_address', 'panel@example.com');
Livewire::test(Email::class)
->set('mail_host', 'smtp.example.com')
->set('mail_from_address', 'panel@example.com')
->call('sendTest')
->assertHasNoErrors();
$messages = app('mailer')->getSymfonyTransport()->messages();
$this->assertCount(1, $messages, 'exactly one test mail must be sent');
$sent = $messages[0]->getOriginalMessage();
$recipients = array_map(fn ($a) => $a->getAddress(), $sent->getTo());
$this->assertSame([$user->email], $recipients, 'test mail must go only to the current user');
}
public function test_test_send_does_nothing_when_unconfigured(): void
{
Mail::fake();
$this->actingUser();
Livewire::test(Email::class)
->call('sendTest')
->assertHasNoErrors();
Mail::assertNothingSent();
}
public function test_audit_event_is_recorded_on_save(): void
{
$this->actingUser();
Livewire::test(Email::class)
->set('mail_host', 'smtp.example.com')
->set('mail_from_address', 'panel@example.com')
->set('mail_from_name', 'Clusev')
->call('save')
->assertHasNoErrors();
$this->assertSame(1, AuditEvent::where('action', 'mail.configure')->count());
}
}