fix(alerts): fresh mailer at queue time (poller!) + branded HTML alert e-mail

ROOT CAUSE of "alert fires but no e-mail", third and final layer: the
metrics poller (clusev:poll-metrics, a long-lived supervisor process) is
what queues alert mail — and Laravel bakes the DEFAULT MAILER NAME into a
mailable at Mail::to()->queue() time. A poller that booted before SMTP
was configured bakes `log` into every alert e-mail forever; the worker
then faithfully "sends" them into laravel.log no matter how correct its
own config is. This is why manual test sends (fresh processes → smtp)
arrived while real alert mails (poller → log) never did.

Fix: extract the SMTP-settings application into App\Support\MailSettings
(::apply(), now with Mail::purge on change so long-lived processes
rebuild the transport) and call it from all three process shapes:
  - web requests   → AppServiceProvider::boot()
  - queue worker   → Queue::before
  - poller/loops   → AlertNotifier::notify() right before queueing,
                     so the CURRENT mailer name is baked in.

Also: enterprise HTML alert e-mail (frontend-design). Dark Tactical
Terminal card — signal rail + status pill (red ALARM / green BEHOBEN),
rule headline, mono terminal readout (server/metric/value/threshold),
bulletproof dashboard CTA, CID-embedded logo (no external assets; renders
in Gmail/Outlook with images blocked), full de/en, plain-text part kept
as fallback. Offline rules render state text (offline/back online)
instead of a bare 0/1.

Verified: app restarted (poller reloaded), synchronous + queued sends to
the admin both deliver over real SMTP (476ms send, no new log-mailer
entries). 750 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-06 21:14:52 +02:00
parent 5024675c94
commit da53b6f7c7
7 changed files with 276 additions and 55 deletions

View File

@ -31,7 +31,10 @@ class AlertNotification extends Mailable implements ShouldQueue
public function content(): Content 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( return new Content(
html: 'mail.alert-html',
text: 'mail.alert', text: 'mail.alert',
with: [ with: [
'incident' => $this->incident, 'incident' => $this->incident,

View File

@ -4,12 +4,11 @@ namespace App\Providers;
use App\Enums\Role; use App\Enums\Role;
use App\Http\Middleware\EnsureSecurityOnboarded; use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Services\DeploymentService; use App\Services\DeploymentService;
use App\Support\MailSettings;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@ -17,7 +16,6 @@ use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite; use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Livewire\Livewire; use Livewire\Livewire;
use Throwable;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -89,57 +87,12 @@ class AppServiceProvider extends ServiceProvider
config(['app.url' => 'https://'.$domain]); config(['app.url' => 'https://'.$domain]);
} }
$this->applyMailSettings(); // 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
// The queue worker is a LONG-LIVED process: boot() runs once at startup, so a mail-config // (Queue::before, per job — boot() runs only once in that long-lived process), and the
// change (or SMTP first configured after the worker started — the normal case) would never // long-lived metrics poller (AlertNotifier applies it right before queueing, since the
// reach queued mail, and a fired alert's e-mail would silently use the stale/log mailer. // default MAILER NAME is baked into a mailable at queue time).
// Re-apply the DB SMTP settings before every job so queued alert/reset mail always sends MailSettings::apply();
// with the current config. Queue::before(fn () => MailSettings::apply());
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.
}
} }
} }

View File

@ -7,6 +7,7 @@ use App\Mail\AlertNotification;
use App\Models\AlertIncident; use App\Models\AlertIncident;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Support\MailSettings;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
@ -21,6 +22,12 @@ class AlertNotifier
{ {
public function notify(AlertIncident $incident, bool $resolved): void 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->email($incident, $resolved);
$this->webhooks($incident, $resolved); $this->webhooks($incident, $resolved);
} }

View File

@ -0,0 +1,68 @@
<?php
namespace App\Support;
use App\Models\Setting;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Applies the operator's DB-stored SMTP settings (Settings\Email) to runtime config. ONE shared
* entry point for every process shape:
*
* - web request AppServiceProvider::boot() (per request)
* - queue worker Queue::before hook (per job)
* - LONG-LIVED loops AlertNotifier::notify() (per notification)
*
* The last one matters most: the metrics poller (clusev:poll-metrics under supervisor) runs for
* days. Laravel bakes the DEFAULT MAILER NAME into a mailable at Mail::to()->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.
}
}
}

View File

@ -79,4 +79,13 @@ return [
'mail_metric' => 'Metrik', 'mail_metric' => 'Metrik',
'mail_value' => 'Wert', 'mail_value' => 'Wert',
'mail_threshold' => 'Schwelle', '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.',
]; ];

View File

@ -79,4 +79,13 @@ return [
'mail_metric' => 'Metric', 'mail_metric' => 'Metric',
'mail_value' => 'Value', 'mail_value' => 'Value',
'mail_threshold' => 'Threshold', '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.',
]; ];

View File

@ -0,0 +1,172 @@
@php
// All colors inline (email clients strip <style>): Clusev "Tactical Terminal" palette.
$void = '#0b0e13'; // outer background
$surface = '#12161d'; // card
$inset = '#0e1218'; // readout background
$line = '#232a35'; // hairline
$ink = '#e9eef3'; // primary text
$ink2 = '#aab4c0'; // secondary
$ink3 = '#77828f'; // muted
$accent = '#ff6a2b'; // signal orange
$online = '#35d07f';
$offline = '#ff5247';
$state = $resolved ? $online : $offline;
$stateTint = $resolved ? '#0f2a1d' : '#2a1214';
$stateLabel = $resolved ? __('alerts.mail_status_resolved') : __('alerts.mail_status_firing');
$when = ($resolved ? $incident->resolved_at : $incident->started_at) ?? now();
$mono = "'JetBrains Mono','SFMono-Regular',Consolas,'Liberation Mono','Courier New',monospace";
$sans = "'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif";
$isOffline = $metric === 'offline';
$metricLabel = $isOffline ? __('alerts.metric_offline') : __('alerts.metric_'.$metric);
$valueText = $isOffline
? ($resolved ? __('alerts.mail_state_online') : __('alerts.mail_state_offline'))
: rtrim(rtrim(number_format((float) $value, 1, ',', '.'), '0'), ',').(in_array($metric, ['cpu', 'mem', 'disk'], true) ? ' %' : '');
$dashboardUrl = rtrim(config('app.url'), '/').'/alerts';
@endphp
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
<title>{{ $stateLabel }} {{ $rule }}</title>
</head>
<body style="margin:0; padding:0; background-color:{{ $void }}; -webkit-text-size-adjust:100%;">
{{-- Preheader (inbox preview text, invisible in the mail body) --}}
<div style="display:none; max-height:0; overflow:hidden; mso-hide:all;">
{{ $stateLabel }}: {{ $rule }} · {{ $server }} · {{ $when->format('d.m.Y H:i') }}
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:{{ $void }};">
<tr>
<td align="center" style="padding:32px 12px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px; max-width:100%;">
{{-- Brand row --}}
<tr>
<td style="padding:0 4px 14px 4px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="vertical-align:middle;">
<img src="{{ $message->embed(public_path('icon-192.png')) }}" width="28" height="28" alt="Clusev"
style="display:block; border:0; border-radius:6px;">
</td>
<td style="vertical-align:middle; padding-left:10px; font-family:{{ $sans }}; font-size:17px; font-weight:700; letter-spacing:0.04em; color:{{ $ink }};">
Clus<span style="color:{{ $accent }};">e</span>v
</td>
<td style="vertical-align:middle; padding-left:12px; font-family:{{ $mono }}; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; color:{{ $ink3 }};">
{{ __('alerts.mail_brand_tag') }}
</td>
</tr>
</table>
</td>
</tr>
{{-- Card --}}
<tr>
<td style="background-color:{{ $surface }}; border:1px solid {{ $line }}; border-radius:12px; overflow:hidden;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{-- Signal rail --}}
<tr><td style="height:4px; background-color:{{ $state }}; font-size:0; line-height:0;">&nbsp;</td></tr>
{{-- Status band --}}
<tr>
<td style="padding:18px 28px 0 28px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color:{{ $stateTint }}; border:1px solid {{ $state }}; border-radius:999px; padding:5px 14px; font-family:{{ $mono }}; font-size:11px; font-weight:700; letter-spacing:0.18em; text-transform:uppercase; color:{{ $state }};">
&#9679;&nbsp; {{ $stateLabel }}
</td>
</tr>
</table>
</td>
</tr>
{{-- Headline --}}
<tr>
<td style="padding:16px 28px 4px 28px; font-family:{{ $sans }}; font-size:24px; line-height:1.25; font-weight:700; color:{{ $ink }};">
{{ $rule }}
</td>
</tr>
<tr>
<td style="padding:0 28px 20px 28px; font-family:{{ $mono }}; font-size:12px; color:{{ $ink3 }};">
{{ $server }} &nbsp;&middot;&nbsp; {{ $when->format('d.m.Y H:i:s') }} UTC
</td>
</tr>
{{-- Terminal readout --}}
<tr>
<td style="padding:0 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color:{{ $inset }}; border:1px solid {{ $line }}; border-radius:8px;">
<tr>
<td style="padding:6px 16px; border-bottom:1px solid {{ $line }}; font-family:{{ $mono }}; font-size:10px; letter-spacing:0.2em; text-transform:uppercase; color:{{ $ink3 }};">
{{ __('alerts.mail_readout') }}
</td>
</tr>
<tr>
<td style="padding:14px 16px 4px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{{ $mono }};">
<tr>
<td width="38%" style="padding:6px 0; font-size:11px; letter-spacing:0.14em; text-transform:uppercase; color:{{ $ink3 }};">{{ __('alerts.mail_server') }}</td>
<td style="padding:6px 0; font-size:13px; color:{{ $ink }};">{{ $server }}</td>
</tr>
<tr>
<td style="padding:6px 0; font-size:11px; letter-spacing:0.14em; text-transform:uppercase; color:{{ $ink3 }}; border-top:1px solid {{ $line }};">{{ __('alerts.mail_metric') }}</td>
<td style="padding:6px 0; font-size:13px; color:{{ $ink }}; border-top:1px solid {{ $line }};">{{ $metricLabel }}</td>
</tr>
<tr>
<td style="padding:6px 0; font-size:11px; letter-spacing:0.14em; text-transform:uppercase; color:{{ $ink3 }}; border-top:1px solid {{ $line }};">{{ __('alerts.mail_value') }}</td>
<td style="padding:6px 0; font-size:16px; font-weight:700; color:{{ $state }}; border-top:1px solid {{ $line }};">{{ $valueText }}</td>
</tr>
@unless ($isOffline)
<tr>
<td style="padding:6px 0 12px 0; font-size:11px; letter-spacing:0.14em; text-transform:uppercase; color:{{ $ink3 }}; border-top:1px solid {{ $line }};">{{ __('alerts.mail_threshold') }}</td>
<td style="padding:6px 0 12px 0; font-size:13px; color:{{ $ink2 }}; border-top:1px solid {{ $line }};">{{ rtrim(rtrim(number_format((float) $threshold, 1, ',', '.'), '0'), ',') }}{{ in_array($metric, ['cpu', 'mem', 'disk'], true) ? ' %' : '' }}</td>
</tr>
@endunless
</table>
</td>
</tr>
</table>
</td>
</tr>
{{-- CTA --}}
<tr>
<td style="padding:22px 28px 26px 28px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color:{{ $accent }}; border-radius:8px;">
<a href="{{ $dashboardUrl }}"
style="display:inline-block; padding:11px 22px; font-family:{{ $sans }}; font-size:13px; font-weight:700; color:#151006; text-decoration:none;">
{{ __('alerts.mail_open_dashboard') }} &rarr;
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
{{-- Footer --}}
<tr>
<td style="padding:16px 6px 0 6px; font-family:{{ $mono }}; font-size:11px; line-height:1.7; color:{{ $ink3 }};">
{{ __('alerts.mail_footer_auto') }}<br>
{{ __('alerts.mail_footer_manage') }}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>