mirror of
https://github.com/coollabsio/coolify.git
synced 2025-12-27 21:25:48 +00:00
feat(EmailChannel): enhance error handling with user-friendly messages for Resend API errors
This commit is contained in:
parent
3def8ce5f7
commit
0d14bc1df7
@ -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')) {
|
||||
|
||||
@ -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",
|
||||
|
||||
14
composer.lock
generated
14
composer.lock
generated
@ -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",
|
||||
|
||||
186
tests/Unit/Notifications/Channels/EmailChannelTest.php
Normal file
186
tests/Unit/Notifications/Channels/EmailChannelTest.php
Normal file
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\NonReportableException;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Channels\EmailChannel;
|
||||
use App\Notifications\Channels\SendsEmail;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Resend\Exceptions\ErrorException;
|
||||
use Resend\Exceptions\TransporterException;
|
||||
|
||||
beforeEach(function () {
|
||||
// Mock the Team with members
|
||||
$this->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('<html>Test</html>');
|
||||
|
||||
$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.');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user