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:
parent
06e7eed4e2
commit
d140b4fd4c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue