From d404de87d71b58183e6636a976481ce1a503cfdc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:13:20 +0200 Subject: [PATCH] fix(email): show Resend errors instead of failing in the error handler resend-php 0.18 reads a "code" key that the Resend API does not send (it sends statusCode), so every Resend error failed with Undefined array key "code". Errors with names that the SDK does not know were returned as a normal response and counted as a sent email. Read statusCode and name from the error, and treat a response without an email id as an error. Co-Authored-By: Claude Opus 5.5 --- app/Notifications/Channels/EmailChannel.php | 47 +++++++++++++---- .../Channels/EmailChannelTest.php | 50 ++++++++++++++++--- 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index fd62a90720..d32f639095 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -75,12 +75,20 @@ class EmailChannel if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); - $resend->emails->send([ + $response = $resend->emails->send([ 'from' => mail_from_formatted($settings), 'to' => $recipients, 'subject' => $mailMessage->subject, 'html' => (string) $mailMessage->render(), ]); + + // The Resend SDK only throws for error names it knows; any other API error is returned as a response. + if (blank($response->getAttribute('id'))) { + $error = $response->toArray(); + $error['message'] = $error['message'] ?? 'Resend did not accept the email.'; + + throw new ErrorException($error); + } } elseif ($isSmtpEnabled) { $transport = SmtpTransportFactory::fromSettings( $settings, @@ -96,12 +104,15 @@ class EmailChannel $mailer->send($email); } } catch (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(), + ['status' => $statusCode, 'name' => $errorName] = self::resendErrorDetails($e); + + // Map HTTP status codes and Resend error names to user-friendly messages. + // An invalid key comes back as 401 "validation_error"; an unverified domain as 403 "validation_error". + $userMessage = match (true) { + $errorName === 'restricted_api_key', $statusCode === 401 && $errorName === null => 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.', + $statusCode === 401, $statusCode === 403 && $errorName !== 'validation_error' => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', + in_array($statusCode, [400, 422], true), $errorName === 'validation_error' => 'Email validation failed: '.$e->getErrorMessage(), + $statusCode === 429 && in_array($errorName, [null, 'rate_limit_exceeded'], true) => 'Resend rate limit exceeded. Please try again in a few minutes.', default => 'Failed to send email via Resend: '.$e->getErrorMessage(), }; @@ -112,14 +123,14 @@ class EmailChannel send_internal_notification(sprintf( "Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s", - $e->getErrorCode(), + $statusCode, $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])) { + if (in_array($statusCode, [403, 401, 400, 422], true)) { throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e)); } @@ -136,4 +147,22 @@ class EmailChannel throw $e; } } + + /** + * Read the status code and error name from a Resend error. + * + * resend-php 0.18 reads only the legacy "code" key in getErrorCode(), while the + * API returns {"statusCode", "name", "message"}, so read the raw contents instead. + * + * @return array{status: int, name: ?string} + */ + private static function resendErrorDetails(ErrorException $e): array + { + $contents = (fn (): array => $this->contents)->call($e); + + return [ + 'status' => (int) ($contents['code'] ?? $contents['statusCode'] ?? 0), + 'name' => isset($contents['name']) ? (string) $contents['name'] : null, + ]; + } } diff --git a/tests/Unit/Notifications/Channels/EmailChannelTest.php b/tests/Unit/Notifications/Channels/EmailChannelTest.php index ea2e900cd9..57cc6a7ea1 100644 --- a/tests/Unit/Notifications/Channels/EmailChannelTest.php +++ b/tests/Unit/Notifications/Channels/EmailChannelTest.php @@ -44,9 +44,9 @@ afterEach(function () { /** * Point the real Resend client at a local HTTP stub that answers every request - * with the given Resend error, so the whole Resend SDK error path is exercised. + * with the given status and JSON body, so the whole Resend SDK path is exercised. */ -function fakeResendError(int $code, string $message): Process +function fakeResendResponse(int $status, array $body): Process { $socket = stream_socket_server('tcp://127.0.0.1:0'); $port = (int) parse_url('tcp://'.stream_socket_get_name($socket, false), PHP_URL_PORT); @@ -55,15 +55,14 @@ function fakeResendError(int $code, string $message): Process $router = sys_get_temp_dir()."/coolify-resend-stub-{$port}.php"; file_put_contents($router, <<<'PHP' ['message' => getenv('RESEND_STUB_MESSAGE'), 'type' => 'error', 'code' => $code]]); + echo getenv('RESEND_STUB_BODY'); PHP); $server = new Process( [PHP_BINARY, '-S', "127.0.0.1:{$port}", $router], - env: ['RESEND_STUB_CODE' => (string) $code, 'RESEND_STUB_MESSAGE' => $message], + env: ['RESEND_STUB_STATUS' => (string) $status, 'RESEND_STUB_BODY' => json_encode($body)], ); $server->start(); @@ -77,6 +76,14 @@ function fakeResendError(int $code, string $message): Process return $server; } +/** + * Answer with the legacy nested error shape ({"error": {"message", "type", "code"}}). + */ +function fakeResendError(int $code, string $message): Process +{ + return fakeResendResponse($code, ['error' => ['message' => $message, 'type' => 'error', 'code' => $code]]); +} + it('throws user-friendly error for invalid Resend API key (403)', function () { $this->resendStub = fakeResendError(403, 'API key is invalid.'); @@ -127,3 +134,34 @@ it('throws generic error with message for unknown error codes', function () { expect(fn () => (new EmailChannel)->send($this->team, $this->notification)) ->toThrow(Exception::class, 'Failed to send email via Resend: Internal server error.'); }); + +it('surfaces a friendly message for real Resend error bodies', function (int $status, string $name, string $message, string $exception, string $expected) { + $this->resendStub = fakeResendResponse($status, ['statusCode' => $status, 'name' => $name, 'message' => $message]); + + expect(fn () => (new EmailChannel)->send($this->team, $this->notification)) + ->toThrow($exception, $expected); +})->with([ + 'invalid api key as returned by the live API (401)' => [401, 'validation_error', 'API key is invalid', NonReportableException::class, 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.'], + 'invalid api key (403)' => [403, 'invalid_api_key', 'API key is invalid', NonReportableException::class, 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.'], + 'restricted api key (401)' => [401, 'restricted_api_key', 'This API key is restricted to only send emails', NonReportableException::class, 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.'], + 'unverified domain (403)' => [403, 'validation_error', 'The example.com domain is not verified.', NonReportableException::class, 'Email validation failed: The example.com domain is not verified.'], + 'validation error (422)' => [422, 'validation_error', 'Invalid `to` field.', NonReportableException::class, 'Email validation failed: Invalid `to` field.'], + 'rate limited (429)' => [429, 'rate_limit_exceeded', 'Too many requests.', Exception::class, 'Resend rate limit exceeded. Please try again in a few minutes.'], + 'monthly quota exceeded (429)' => [429, 'monthly_quota_exceeded', 'You have reached your monthly email quota.', Exception::class, 'Failed to send email via Resend: You have reached your monthly email quota.'], + 'suspended api key (403)' => [403, 'suspended_api_key', 'This API key has been suspended.', NonReportableException::class, 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.'], + 'missing parameter (422)' => [422, 'missing_required_parameter', 'Missing `from` field.', NonReportableException::class, 'Email validation failed: Missing `from` field.'], +]); + +it('does not report success when Resend returns no email id', function () { + $this->resendStub = fakeResendResponse(200, ['object' => 'error']); + + expect(fn () => (new EmailChannel)->send($this->team, $this->notification)) + ->toThrow(Exception::class, 'Failed to send email via Resend'); +}); + +it('sends successfully when Resend returns an email id', function () { + $this->resendStub = fakeResendResponse(200, ['id' => '49a3999c-0ce1-4ea6-ab68-afcd6dc2e794']); + + expect(fn () => (new EmailChannel)->send($this->team, $this->notification)) + ->not->toThrow(Throwable::class); +});