diff --git a/app/Mail/AlertNotification.php b/app/Mail/AlertNotification.php index f49cbc2..f71dcb8 100644 --- a/app/Mail/AlertNotification.php +++ b/app/Mail/AlertNotification.php @@ -31,7 +31,10 @@ class AlertNotification extends Mailable implements ShouldQueue public function content(): Content { + // Branded HTML (table layout, inline styles, CID-embedded logo — no external assets, so it + // renders in Gmail/Outlook without image blocking) + the plain-text part as fallback. return new Content( + html: 'mail.alert-html', text: 'mail.alert', with: [ 'incident' => $this->incident, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index eebe7e2..ad37e03 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,12 +4,11 @@ namespace App\Providers; use App\Enums\Role; use App\Http\Middleware\EnsureSecurityOnboarded; -use App\Models\Setting; use App\Models\User; use App\Services\DeploymentService; +use App\Support\MailSettings; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\RateLimiter; @@ -17,7 +16,6 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; -use Throwable; class AppServiceProvider extends ServiceProvider { @@ -89,57 +87,12 @@ class AppServiceProvider extends ServiceProvider config(['app.url' => 'https://'.$domain]); } - $this->applyMailSettings(); - - // The queue worker is a LONG-LIVED process: boot() runs once at startup, so a mail-config - // change (or SMTP first configured after the worker started — the normal case) would never - // reach queued mail, and a fired alert's e-mail would silently use the stale/log mailer. - // Re-apply the DB SMTP settings before every job so queued alert/reset mail always sends - // with the current config. - Queue::before(fn () => $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. - } + // Operator SMTP settings → runtime mail config. Shared helper (MailSettings::apply) because + // THREE process shapes need it fresh: web requests (here, per request), the queue worker + // (Queue::before, per job — boot() runs only once in that long-lived process), and the + // long-lived metrics poller (AlertNotifier applies it right before queueing, since the + // default MAILER NAME is baked into a mailable at queue time). + MailSettings::apply(); + Queue::before(fn () => MailSettings::apply()); } } diff --git a/app/Services/AlertNotifier.php b/app/Services/AlertNotifier.php index 62abd82..e230927 100644 --- a/app/Services/AlertNotifier.php +++ b/app/Services/AlertNotifier.php @@ -7,6 +7,7 @@ use App\Mail\AlertNotification; use App\Models\AlertIncident; use App\Models\Setting; use App\Models\User; +use App\Support\MailSettings; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; @@ -21,6 +22,12 @@ class AlertNotifier { public function notify(AlertIncident $incident, bool $resolved): void { + // Re-apply the DB SMTP settings RIGHT BEFORE queueing: the metrics poller calling this is a + // long-lived process, and Laravel bakes the current default MAILER NAME into the mailable at + // queue time — without this, a poller that booted before SMTP was configured would bake the + // `log` mailer into every alert e-mail forever (they'd land in laravel.log, never the inbox). + MailSettings::apply(); + $this->email($incident, $resolved); $this->webhooks($incident, $resolved); } diff --git a/app/Support/MailSettings.php b/app/Support/MailSettings.php new file mode 100644 index 0000000..850470f --- /dev/null +++ b/app/Support/MailSettings.php @@ -0,0 +1,68 @@ +queue() time, so a + * loop that booted before SMTP was configured would bake `log` into every alert mail forever — + * the worker then "sends" them into laravel.log no matter how correct its own config is. Applying + * fresh settings immediately before queueing bakes the right mailer name. + * + * Mail::purge() drops the cached mailer instance so a config change actually rebuilds the + * transport in long-lived processes instead of reusing the old connection. + */ +class MailSettings +{ + public static function apply(): 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); + } + + $changed = config('mail.default') !== 'smtp' + || config('mail.mailers.smtp.host') !== $host; + + 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, + ]); + + if ($changed) { + Mail::purge('smtp'); // rebuild the cached transport with the new settings + } + } catch (Throwable) { + // Settings table not migrated yet (install/migrate) or an undecryptable value — + // leave the default mailer untouched. Never break the caller. + } + } +} diff --git a/lang/de/alerts.php b/lang/de/alerts.php index 2989022..b23095a 100644 --- a/lang/de/alerts.php +++ b/lang/de/alerts.php @@ -79,4 +79,13 @@ return [ 'mail_metric' => 'Metrik', 'mail_value' => 'Wert', 'mail_threshold' => 'Schwelle', + 'mail_status_firing' => 'Alarm ausgelöst', + 'mail_status_resolved' => 'Alarm behoben', + 'mail_state_offline' => 'offline', + 'mail_state_online' => 'wieder online', + 'mail_brand_tag' => 'Fleet Control', + 'mail_readout' => 'Messwerte', + 'mail_open_dashboard' => 'Dashboard öffnen', + 'mail_footer_auto' => 'Diese Benachrichtigung wurde automatisch von Clusev gesendet.', + 'mail_footer_manage' => 'Empfänger verwaltest du im Dashboard unter Alarme → Benachrichtigungs-Kanäle.', ]; diff --git a/lang/en/alerts.php b/lang/en/alerts.php index 9ad8f27..00d9ae1 100644 --- a/lang/en/alerts.php +++ b/lang/en/alerts.php @@ -79,4 +79,13 @@ return [ 'mail_metric' => 'Metric', 'mail_value' => 'Value', 'mail_threshold' => 'Threshold', + 'mail_status_firing' => 'Alert firing', + 'mail_status_resolved' => 'Alert resolved', + 'mail_state_offline' => 'offline', + 'mail_state_online' => 'back online', + 'mail_brand_tag' => 'Fleet Control', + 'mail_readout' => 'Readout', + 'mail_open_dashboard' => 'Open dashboard', + 'mail_footer_auto' => 'This notification was sent automatically by Clusev.', + 'mail_footer_manage' => 'Manage recipients in the dashboard under Alerts → Notification channels.', ]; diff --git a/resources/views/mail/alert-html.blade.php b/resources/views/mail/alert-html.blade.php new file mode 100644 index 0000000..5c36b3d --- /dev/null +++ b/resources/views/mail/alert-html.blade.php @@ -0,0 +1,172 @@ +@php + // All colors inline (email clients strip