diff --git a/app/Services/AlertNotifier.php b/app/Services/AlertNotifier.php index d1cc052..62abd82 100644 --- a/app/Services/AlertNotifier.php +++ b/app/Services/AlertNotifier.php @@ -27,14 +27,17 @@ class AlertNotifier private function email(AlertIncident $incident, bool $resolved): void { - try { - $recipients = $this->recipients(); - if ($recipients === []) { - return; + // ONE message per recipient — NOT a single To: with everyone. A mixed To that contains an + // undeliverable address (e.g. a seeded admin on a non-routable domain) reads as spam to + // providers like Gmail and gets the WHOLE message silently dropped for the valid recipients + // too. Per-recipient sends isolate that (and don't leak co-recipients' addresses). Each send + // is best-effort: a bad address is logged, never thrown, so it can't break the poll loop. + foreach ($this->recipients() as $recipient) { + try { + Mail::to($recipient)->queue(new AlertNotification($incident, $resolved)); + } catch (Throwable $e) { + Log::warning('alert email failed for '.$recipient.': '.$e->getMessage()); } - Mail::to($recipients)->queue(new AlertNotification($incident, $resolved)); - } catch (Throwable $e) { - Log::warning('alert email failed: '.$e->getMessage()); } } diff --git a/probe_mail.php b/probe_mail.php new file mode 100644 index 0000000..a81aa08 --- /dev/null +++ b/probe_mail.php @@ -0,0 +1,11 @@ + $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 95, 'started_at' => now()]); } - public function test_email_is_queued_to_the_configured_recipients(): void + public function test_email_is_queued_once_per_recipient_with_a_single_to(): void { Mail::fake(); Setting::put('alert_email_to', "ops@example.com\nsecond@example.com"); app(AlertNotifier::class)->notify($this->incident(), resolved: false); - Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $m->hasTo('ops@example.com')); + // One message PER recipient, each with only its own address — a shared To that included an + // undeliverable recipient would otherwise get the whole mail spam-dropped for everyone. + Mail::assertQueued(AlertNotification::class, 2); + Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $m->hasTo('ops@example.com') && ! $m->hasTo('second@example.com')); + Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $m->hasTo('second@example.com') && ! $m->hasTo('ops@example.com')); } public function test_falls_back_to_admin_emails_when_none_configured(): void