fix(notifications): skip mail dispatch when recipient email is invalid (#387)

## 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
This commit is contained in:
Víctor Falcón 2026-05-13 09:47:50 +01:00 committed by GitHub
parent 06e7eed4e2
commit d140b4fd4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 83 additions and 1 deletions

View File

@ -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

View File

@ -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;

View File

@ -0,0 +1,52 @@
<?php
use App\Models\User;
use App\Models\UserLead;
use App\Notifications\VerifyEmailNotification;
use App\Notifications\VerifyUserLeadEmailNotification;
test('user returns email for mail routing when valid', function () {
$user = User::factory()->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();
});