mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5f6fb5dcc2
commit
d404de87d7
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
<?php
|
||||
$code = (int) getenv('RESEND_STUB_CODE');
|
||||
http_response_code($code);
|
||||
http_response_code((int) getenv('RESEND_STUB_STATUS'));
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => ['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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user