From 0d14bc1df76aa750a76d64d32e404e4cec5cf3d1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 11 Nov 2025 13:23:45 +0100 Subject: [PATCH] feat(EmailChannel): enhance error handling with user-friendly messages for Resend API errors --- app/Notifications/Channels/EmailChannel.php | 32 +++ composer.json | 2 +- composer.lock | 14 +- .../Channels/EmailChannelTest.php | 186 ++++++++++++++++++ 4 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/Notifications/Channels/EmailChannelTest.php diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 245bd85f0..234bc37ad 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -101,6 +101,38 @@ class EmailChannel $mailer->send($email); } + } catch (\Resend\Exceptions\ErrorException $e) { + // Map HTTP status codes to user-friendly messages + $userMessage = match ($e->getErrorCode()) { + 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', + 401 => 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.', + 429 => 'Resend rate limit exceeded. Please try again in a few minutes.', + 400 => 'Email validation failed: '.$e->getErrorMessage(), + default => 'Failed to send email via Resend: '.$e->getErrorMessage(), + }; + + // Log detailed error for admin debugging (redact sensitive data) + $emailSettings = $notifiable->emailNotificationSettings ?? instanceSettings(); + data_set($emailSettings, 'smtp_password', '********'); + data_set($emailSettings, 'resend_api_key', '********'); + + send_internal_notification(sprintf( + "Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s", + $e->getErrorCode(), + $e->getErrorMessage(), + get_class($notification), + json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + )); + + // Don't report expected errors (invalid keys, validation) to Sentry + if (in_array($e->getErrorCode(), [403, 401, 400])) { + throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); + } + + throw new \Exception($userMessage, $e->getCode(), $e); + } catch (\Resend\Exceptions\TransporterException $e) { + send_internal_notification("Resend Transport Error: {$e->getMessage()}"); + throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { diff --git a/composer.json b/composer.json index ea466049d..1db389a57 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "poliander/cron": "^3.2.1", "purplepixie/phpdns": "^2.2", "pusher/pusher-php-server": "^7.2.7", - "resend/resend-laravel": "^0.19.0", + "resend/resend-laravel": "^0.20.0", "sentry/sentry-laravel": "^4.15.1", "socialiteproviders/authentik": "^5.2", "socialiteproviders/clerk": "^5.0", diff --git a/composer.lock b/composer.lock index 6320db071..5ffeb7d39 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a993799242581bd06b5939005ee458d9", + "content-hash": "423b7d10901b9f31c926d536ff163a22", "packages": [ { "name": "amphp/amp", @@ -7048,16 +7048,16 @@ }, { "name": "resend/resend-laravel", - "version": "v0.19.0", + "version": "v0.20.0", "source": { "type": "git", "url": "https://github.com/resend/resend-laravel.git", - "reference": "ce11e363c42c1d6b93983dfebbaba3f906863c3a" + "reference": "f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/resend/resend-laravel/zipball/ce11e363c42c1d6b93983dfebbaba3f906863c3a", - "reference": "ce11e363c42c1d6b93983dfebbaba3f906863c3a", + "url": "https://api.github.com/repos/resend/resend-laravel/zipball/f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed", + "reference": "f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed", "shasum": "" }, "require": { @@ -7111,9 +7111,9 @@ ], "support": { "issues": "https://github.com/resend/resend-laravel/issues", - "source": "https://github.com/resend/resend-laravel/tree/v0.19.0" + "source": "https://github.com/resend/resend-laravel/tree/v0.20.0" }, - "time": "2025-05-06T21:36:51+00:00" + "time": "2025-08-04T19:26:47+00:00" }, { "name": "resend/resend-php", diff --git a/tests/Unit/Notifications/Channels/EmailChannelTest.php b/tests/Unit/Notifications/Channels/EmailChannelTest.php new file mode 100644 index 000000000..6600495d3 --- /dev/null +++ b/tests/Unit/Notifications/Channels/EmailChannelTest.php @@ -0,0 +1,186 @@ +team = Mockery::mock(Team::class); + $this->team->id = 1; + + $user1 = new User(['email' => 'test@example.com']); + $user2 = new User(['email' => 'admin@example.com']); + $members = collect([$user1, $user2]); + $this->team->shouldReceive('getAttribute')->with('members')->andReturn($members); + Team::shouldReceive('find')->with(1)->andReturn($this->team); + + // Mock the notifiable (Team) + $this->notifiable = Mockery::mock(SendsEmail::class); + $this->notifiable->shouldReceive('getAttribute')->with('id')->andReturn(1); + + // Mock email settings with Resend enabled + $this->settings = Mockery::mock(EmailNotificationSettings::class); + $this->settings->resend_enabled = true; + $this->settings->smtp_enabled = false; + $this->settings->use_instance_email_settings = false; + $this->settings->smtp_from_name = 'Test Sender'; + $this->settings->smtp_from_address = 'sender@example.com'; + $this->settings->resend_api_key = 'test_api_key'; + $this->settings->smtp_password = 'password'; + + $this->notifiable->shouldReceive('getAttribute')->with('emailNotificationSettings')->andReturn($this->settings); + $this->notifiable->emailNotificationSettings = $this->settings; + $this->notifiable->shouldReceive('getRecipients')->andReturn(['test@example.com']); + + // Mock the notification + $this->notification = Mockery::mock(Notification::class); + $this->notification->shouldReceive('getAttribute')->with('isTransactionalEmail')->andReturn(false); + $this->notification->shouldReceive('getAttribute')->with('emails')->andReturn(null); + + $mailMessage = Mockery::mock(MailMessage::class); + $mailMessage->subject = 'Test Email'; + $mailMessage->shouldReceive('render')->andReturn('Test'); + + $this->notification->shouldReceive('toMail')->andReturn($mailMessage); + + // Mock global functions + $this->app->instance('send_internal_notification', function () {}); +}); + +it('throws user-friendly error for invalid Resend API key (403)', function () { + // Create mock ErrorException for invalid API key + $resendError = Mockery::mock(ErrorException::class); + $resendError->shouldReceive('getErrorCode')->andReturn(403); + $resendError->shouldReceive('getErrorMessage')->andReturn('API key is invalid.'); + $resendError->shouldReceive('getCode')->andReturn(403); + + // Mock Resend client to throw the error + $resendClient = Mockery::mock(); + $emailsService = Mockery::mock(); + $emailsService->shouldReceive('send')->andThrow($resendError); + $resendClient->emails = $emailsService; + + Resend::shouldReceive('client')->andReturn($resendClient); + + $channel = new EmailChannel; + + expect(fn () => $channel->send($this->notifiable, $this->notification)) + ->toThrow( + NonReportableException::class, + 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.' + ); +}); + +it('throws user-friendly error for restricted Resend API key (401)', function () { + // Create mock ErrorException for restricted key + $resendError = Mockery::mock(ErrorException::class); + $resendError->shouldReceive('getErrorCode')->andReturn(401); + $resendError->shouldReceive('getErrorMessage')->andReturn('This API key is restricted to only send emails.'); + $resendError->shouldReceive('getCode')->andReturn(401); + + // Mock Resend client to throw the error + $resendClient = Mockery::mock(); + $emailsService = Mockery::mock(); + $emailsService->shouldReceive('send')->andThrow($resendError); + $resendClient->emails = $emailsService; + + Resend::shouldReceive('client')->andReturn($resendClient); + + $channel = new EmailChannel; + + expect(fn () => $channel->send($this->notifiable, $this->notification)) + ->toThrow( + NonReportableException::class, + 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.' + ); +}); + +it('throws user-friendly error for rate limiting (429)', function () { + // Create mock ErrorException for rate limit + $resendError = Mockery::mock(ErrorException::class); + $resendError->shouldReceive('getErrorCode')->andReturn(429); + $resendError->shouldReceive('getErrorMessage')->andReturn('Too many requests.'); + $resendError->shouldReceive('getCode')->andReturn(429); + + // Mock Resend client to throw the error + $resendClient = Mockery::mock(); + $emailsService = Mockery::mock(); + $emailsService->shouldReceive('send')->andThrow($resendError); + $resendClient->emails = $emailsService; + + Resend::shouldReceive('client')->andReturn($resendClient); + + $channel = new EmailChannel; + + expect(fn () => $channel->send($this->notifiable, $this->notification)) + ->toThrow(Exception::class, 'Resend rate limit exceeded. Please try again in a few minutes.'); +}); + +it('throws user-friendly error for validation errors (400)', function () { + // Create mock ErrorException for validation error + $resendError = Mockery::mock(ErrorException::class); + $resendError->shouldReceive('getErrorCode')->andReturn(400); + $resendError->shouldReceive('getErrorMessage')->andReturn('Invalid email format.'); + $resendError->shouldReceive('getCode')->andReturn(400); + + // Mock Resend client to throw the error + $resendClient = Mockery::mock(); + $emailsService = Mockery::mock(); + $emailsService->shouldReceive('send')->andThrow($resendError); + $resendClient->emails = $emailsService; + + Resend::shouldReceive('client')->andReturn($resendClient); + + $channel = new EmailChannel; + + expect(fn () => $channel->send($this->notifiable, $this->notification)) + ->toThrow(NonReportableException::class, 'Email validation failed: Invalid email format.'); +}); + +it('throws user-friendly error for network/transport errors', function () { + // Create mock TransporterException + $transportError = Mockery::mock(TransporterException::class); + $transportError->shouldReceive('getMessage')->andReturn('Network error'); + + // Mock Resend client to throw the error + $resendClient = Mockery::mock(); + $emailsService = Mockery::mock(); + $emailsService->shouldReceive('send')->andThrow($transportError); + $resendClient->emails = $emailsService; + + Resend::shouldReceive('client')->andReturn($resendClient); + + $channel = new EmailChannel; + + expect(fn () => $channel->send($this->notifiable, $this->notification)) + ->toThrow(Exception::class, 'Unable to connect to Resend API. Please check your internet connection and try again.'); +}); + +it('throws generic error with message for unknown error codes', function () { + // Create mock ErrorException with unknown code + $resendError = Mockery::mock(ErrorException::class); + $resendError->shouldReceive('getErrorCode')->andReturn(500); + $resendError->shouldReceive('getErrorMessage')->andReturn('Internal server error.'); + $resendError->shouldReceive('getCode')->andReturn(500); + + // Mock Resend client to throw the error + $resendClient = Mockery::mock(); + $emailsService = Mockery::mock(); + $emailsService->shouldReceive('send')->andThrow($resendError); + $resendClient->emails = $emailsService; + + Resend::shouldReceive('client')->andReturn($resendClient); + + $channel = new EmailChannel; + + expect(fn () => $channel->send($this->notifiable, $this->notification)) + ->toThrow(Exception::class, 'Failed to send email via Resend: Internal server error.'); +});