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); +});