diff --git a/app/Livewire/Settings/Email.php b/app/Livewire/Settings/Email.php index 67a5e94..b0539af 100644 --- a/app/Livewire/Settings/Email.php +++ b/app/Livewire/Settings/Email.php @@ -2,10 +2,185 @@ 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 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 { + 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() { return view('livewire.settings.email'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8e6dad5..6202f93 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,9 +3,12 @@ namespace App\Providers; use App\Http\Middleware\EnsureSecurityOnboarded; +use App\Models\Setting; use App\Services\DeploymentService; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; +use Throwable; class AppServiceProvider extends ServiceProvider { @@ -49,5 +52,51 @@ class AppServiceProvider extends ServiceProvider if ($domain !== null) { 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. + } } } diff --git a/lang/de/mail.php b/lang/de/mail.php new file mode 100644 index 0000000..d34d66c --- /dev/null +++ b/lang/de/mail.php @@ -0,0 +1,39 @@ + '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.', +]; diff --git a/lang/en/mail.php b/lang/en/mail.php new file mode 100644 index 0000000..2dba56f --- /dev/null +++ b/lang/en/mail.php @@ -0,0 +1,39 @@ + '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.', +]; diff --git a/resources/views/livewire/settings/email.blade.php b/resources/views/livewire/settings/email.blade.php index 37b971b..d863572 100644 --- a/resources/views/livewire/settings/email.blade.php +++ b/resources/views/livewire/settings/email.blade.php @@ -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 +
- -

{{ __('settings.coming_soon') }}

+ +
+
+
+ + + @error('mail_host')

{{ $message }}

@enderror +
+
+ + + @error('mail_port')

{{ $message }}

@enderror +
+
+ +
+
+ + + @error('mail_username')

{{ $message }}

@enderror +
+
+ + + @error('mail_password')

{{ $message }}

@enderror +
+
+ +
+
+ + + @error('mail_encryption')

{{ $message }}

@enderror +
+
+ +
+
+ + + @error('mail_from_address')

{{ $message }}

@enderror +
+
+ + + @error('mail_from_name')

{{ $message }}

@enderror +
+
+ +
+ + + {{ __('mail.test_send') }} + + + + {{ __('common.save') }} + +
+
diff --git a/tests/Feature/SmtpConfigTest.php b/tests/Feature/SmtpConfigTest.php new file mode 100644 index 0000000..b5abb6a --- /dev/null +++ b/tests/Feature/SmtpConfigTest.php @@ -0,0 +1,187 @@ +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()); + } +}