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
parent
bb1ad53030
commit
73eecd03e0
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue