From d140b4fd4cd5402b464c203d619c231db9672ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 13 May 2026 09:47:50 +0100 Subject: [PATCH] fix(notifications): skip mail dispatch when recipient email is invalid (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Sentry: [PHP-LARAVEL-1Z](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1Z) — 5 events. `ResendTransport::doSend` throws `TransportException: Invalid \`to\` field` when a notifiable's email fails Resend's RFC validation (malformed address, whitespace, etc.). The queued notification job dies and we get paged. Stacktrace path: `SendQueuedNotifications → NotificationSender → MailChannel → ResendTransport`. Mail channel calls `routeNotificationForMail()` on the notifiable to resolve the address; both `User` and `UserLead` were returning the raw `email` column without format checks. ## Fix Validate the address in `routeNotificationForMail()` on both models: - trim whitespace - run `filter_var(..., FILTER_VALIDATE_EMAIL)` - return `null` (skips the channel) when invalid - log a warning with model id + notification class for triage Other channels (database, etc.) keep working. ## Test New `tests/Feature/RouteNotificationForMailTest.php` covers valid, malformed, whitespace, and trimmed cases on both `User` and `UserLead`. ``` php artisan test --filter='RouteNotificationForMailTest|UserLeadTest|VerificationNotificationTest|UserLeadInvitationTest|SendUserLeadInvitations' Tests: 48 passed ``` Fixes PHP-LARAVEL-1Z --- app/Models/User.php | 14 ++++- app/Models/UserLead.php | 18 +++++++ .../Feature/RouteNotificationForMailTest.php | 52 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/RouteNotificationForMailTest.php diff --git a/app/Models/User.php b/app/Models/User.php index f59d74c9..0dfbd18c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,6 +17,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Pennant\Concerns\HasFeatures; @@ -216,7 +217,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma return null; } - return $this->email; + $email = trim((string) $this->email); + + if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + Log::warning('Skipping mail notification: invalid recipient email', [ + 'user_id' => $this->getKey(), + 'notification' => $notification ? $notification::class : null, + ]); + + return null; + } + + return $email; } public function sendEmailVerificationNotification(): void diff --git a/app/Models/UserLead.php b/app/Models/UserLead.php index 14ca7406..4ccfd9f5 100644 --- a/app/Models/UserLead.php +++ b/app/Models/UserLead.php @@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Notifications\Notifiable; +use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; /** @@ -124,6 +126,22 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail return $this->locale ?? 'en'; } + public function routeNotificationForMail(?Notification $notification = null): ?string + { + $email = trim((string) $this->email); + + if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + Log::warning('Skipping mail notification: invalid recipient email', [ + 'user_lead_id' => $this->getKey(), + 'notification' => $notification ? $notification::class : null, + ]); + + return null; + } + + return $email; + } + public function getEmailForVerification(): string { return $this->email; diff --git a/tests/Feature/RouteNotificationForMailTest.php b/tests/Feature/RouteNotificationForMailTest.php new file mode 100644 index 00000000..b7dfa70d --- /dev/null +++ b/tests/Feature/RouteNotificationForMailTest.php @@ -0,0 +1,52 @@ +create(['email' => 'valid@example.com']); + + expect($user->routeNotificationForMail(new VerifyEmailNotification)) + ->toBe('valid@example.com'); +}); + +test('user skips mail routing when email is malformed', function () { + $user = User::factory()->create(); + $user->forceFill(['email' => 'not-an-email'])->saveQuietly(); + + expect($user->routeNotificationForMail(new VerifyEmailNotification)) + ->toBeNull(); +}); + +test('user skips mail routing when email has surrounding whitespace it cannot parse', function () { + $user = User::factory()->create(); + $user->forceFill(['email' => ' '])->saveQuietly(); + + expect($user->routeNotificationForMail(new VerifyEmailNotification)) + ->toBeNull(); +}); + +test('user trims valid email before routing', function () { + $user = User::factory()->create(); + $user->forceFill(['email' => " spaced@example.com\n"])->saveQuietly(); + + expect($user->routeNotificationForMail(new VerifyEmailNotification)) + ->toBe('spaced@example.com'); +}); + +test('user lead returns email for mail routing when valid', function () { + $lead = UserLead::factory()->create(['email' => 'lead@example.com']); + + expect($lead->routeNotificationForMail(new VerifyUserLeadEmailNotification('https://example.com'))) + ->toBe('lead@example.com'); +}); + +test('user lead skips mail routing when email is malformed', function () { + $lead = UserLead::factory()->create(); + $lead->forceFill(['email' => 'broken@@example'])->saveQuietly(); + + expect($lead->routeNotificationForMail(new VerifyUserLeadEmailNotification('https://example.com'))) + ->toBeNull(); +});