user()?->can('manage-panel'), 403); $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 { abort_unless(auth()->user()?->can('manage-panel'), 403); $this->validate(); // Retarget guard: if the SMTP HOST or USERNAME changes and no new password was entered, DROP // the stored password BEFORE the new host is written — so the old credential can never be // sent to a new (possibly attacker-controlled) SMTP server; re-entry is then required. Same // class as the alert Gotify token guard. uncached() reads the committed state (no stale cache). $targetChanged = $this->mail_host !== (string) Setting::uncached('mail_host', '') || $this->mail_username !== (string) Setting::uncached('mail_username', ''); if ($targetChanged) { // Drop the old password BEFORE any target field is written — regardless of whether a // fresh one was typed — so no concurrent MailSettings::apply() can ever read the new // host paired with the old password. A freshly typed password is re-written below. Setting::forget('mail_password'); $this->passwordSet = false; } 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')); } /** * Fully clear the SMTP configuration — removes every mail_* setting (incl. the encrypted * password) so the panel reverts to the safe default (log) mailer. Reversible: just reconfigure. */ public function clearConfig(): void { abort_unless(auth()->user()?->can('manage-panel'), 403); foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) { Setting::forget($key); } $this->mail_host = ''; $this->mail_port = 587; $this->mail_username = ''; $this->mail_password = ''; $this->mail_encryption = 'tls'; $this->mail_from_address = ''; $this->mail_from_name = ''; $this->passwordSet = false; AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()->name, 'action' => 'mail.reset', 'target' => '—', 'ip' => request()->ip(), ]); $this->dispatch('notify', message: __('mail.notify_reset')); } /** * 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 { abort_unless(auth()->user()?->can('manage-panel'), 403); if (! $this->configured()) { $this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.not_configured')])); return; } // Throttle test-sends so the panel can't be turned into a spam/mail-bomb relay. // Per-user + auto-expiring: only ever limits the operator's own test button briefly. $key = 'mail-test:'.Auth::id(); if (RateLimiter::tooManyAttempts($key, 3)) { $this->dispatch('notify', message: __('mail.notify_test_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); return; } RateLimiter::hit($key, 600); // 3 test e-mails / 10 minutes per user // Retarget guard: never test-send the STORED password against a host/username the operator // just changed in the form but hasn't saved — that could leak the old credential to a new // (possibly hostile) server. Require the password to be re-entered for a changed target. if ($this->mail_password === '' && ($this->mail_host !== (string) Setting::uncached('mail_host', '') || $this->mail_username !== (string) Setting::uncached('mail_username', ''))) { $this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.test_retarget_password_required')]), level: 'error'); 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 { // Prefer a FRESHLY typed password (test-before-save); fall back to the stored one only when // the field is blank — and the sendTest() retarget guard has already ensured that fallback // is used only when host+username are unchanged from what the stored password was set for. $password = ''; if ($this->mail_password !== '') { $password = $this->mail_password; } else { $stored = Setting::uncached('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'); } }