From 18a4ca9006eb80dc454d53d410b69490a99cf6c9 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 19 May 2026 04:56:01 +0000 Subject: [PATCH] fix(Resend): send attachments via per-message endpoint, not batch Resend's batch endpoint (/emails/batch) silently drops the attachments field, so any attachment-bearing send through the Resend adapter never delivered the attachment to the recipient. When the message has attachments, route each recipient as an individual request to /emails (which accepts attachments). The no-attachment path keeps using /emails/batch so throughput is unchanged. Also raises the per-send size limit to 40MB to match Resend's documented single-send limit, and adds routing tests that mock the HTTP layer to assert which endpoint is used and that the attachments payload is shaped correctly. Co-Authored-By: Claude Opus 4.7 --- src/Utopia/Messaging/Adapter/Email/Resend.php | 102 ++++++++--- .../Adapter/Email/ResendRoutingTest.php | 164 ++++++++++++++++++ tests/Messaging/Adapter/Email/ResendTest.php | 2 +- 3 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 tests/Messaging/Adapter/Email/ResendRoutingTest.php diff --git a/src/Utopia/Messaging/Adapter/Email/Resend.php b/src/Utopia/Messaging/Adapter/Email/Resend.php index ac15f67b..b1a799a1 100644 --- a/src/Utopia/Messaging/Adapter/Email/Resend.php +++ b/src/Utopia/Messaging/Adapter/Email/Resend.php @@ -10,6 +10,8 @@ class Resend extends EmailAdapter { protected const NAME = 'Resend'; + protected const MAX_ATTACHMENT_BYTES = 40 * 1024 * 1024; + /** * @param string $apiKey Your Resend API key to authenticate with the API. */ @@ -29,9 +31,8 @@ public function getMaxMessagesPerRequest(): int } /** - * Uses Resend's batch sending API to send multiple emails at once. - * * @link https://resend.com/docs/api-reference/emails/send-batch-emails + * @link https://resend.com/docs/api-reference/emails/send-email */ protected function process(EmailMessage $message): array { @@ -77,7 +78,7 @@ protected function process(EmailMessage $message): array $emails = []; foreach ($message->getTo() as $to) { - $toFormatted = !empty($to['name']) + $toFormatted = ! empty($to['name']) ? "{$to['name']} <{$to['email']}>" : $to['email']; @@ -133,11 +134,25 @@ protected function process(EmailMessage $message): array 'Content-Type: application/json', ]; + if (! empty($attachments)) { + return $this->sendIndividually($message, $emails, $headers, $response); + } + + return $this->sendBatch($message, $emails, $headers, $response); + } + + /** + * @param array> $emails + * @param array $headers + * @return array{deliveredTo: int, type: string, results: array>} + */ + private function sendBatch(EmailMessage $message, array $emails, array $headers, Response $response): array + { $result = $this->request( method: 'POST', url: 'https://api.resend.com/emails/batch', headers: $headers, - body: $emails, // @phpstan-ignore-line + body: $emails, ); $statusCode = $result['statusCode']; @@ -145,7 +160,7 @@ protected function process(EmailMessage $message): array if ($statusCode === 200) { $responseData = $result['response']; - if (isset($responseData['errors']) && ! empty($responseData['errors'])) { + if (\is_array($responseData) && isset($responseData['errors']) && ! empty($responseData['errors'])) { $failedIndices = []; foreach ($responseData['errors'] as $error) { $failedIndices[$error['index']] = $error['message']; @@ -168,33 +183,80 @@ protected function process(EmailMessage $message): array } } } elseif ($statusCode >= 400 && $statusCode < 500) { - $errorMessage = 'Unknown error'; - - if (\is_string($result['response'])) { - $errorMessage = $result['response']; - } elseif (isset($result['response']['message'])) { - $errorMessage = $result['response']['message']; - } elseif (isset($result['response']['error'])) { - $errorMessage = $result['response']['error']; - } + $errorMessage = $this->extractErrorMessage($result['response'], 'Unknown error'); foreach ($message->getTo() as $to) { $response->addResult($to['email'], $errorMessage); } } elseif ($statusCode >= 500) { - $errorMessage = 'Server error'; + $errorMessage = $this->extractErrorMessage($result['response'], 'Server error'); - if (\is_string($result['response'])) { - $errorMessage = $result['response']; - } elseif (isset($result['response']['message'])) { - $errorMessage = $result['response']['message']; + foreach ($message->getTo() as $to) { + $response->addResult($to['email'], $errorMessage); } + } - foreach ($message->getTo() as $to) { + return $response->toArray(); + } + + /** + * @param array> $emails + * @param array $headers + * @return array{deliveredTo: int, type: string, results: array>} + */ + private function sendIndividually(EmailMessage $message, array $emails, array $headers, Response $response): array + { + $recipients = $message->getTo(); + $deliveredTo = 0; + + foreach ($emails as $index => $email) { + $to = $recipients[$index]; + + $result = $this->request( + method: 'POST', + url: 'https://api.resend.com/emails', + headers: $headers, + body: $email, + ); + + $statusCode = $result['statusCode']; + + if ($statusCode >= 200 && $statusCode < 300) { + $response->addResult($to['email']); + $deliveredTo++; + } elseif ($statusCode >= 400 && $statusCode < 500) { + $errorMessage = $this->extractErrorMessage($result['response'], 'Unknown error'); + $response->addResult($to['email'], $errorMessage); + } else { + $errorMessage = $this->extractErrorMessage($result['response'], 'Server error'); $response->addResult($to['email'], $errorMessage); } } + $response->setDeliveredTo($deliveredTo); + return $response->toArray(); } + + /** + * @param array|string|null $body + */ + private function extractErrorMessage(array|string|null $body, string $default): string + { + if (\is_string($body)) { + return $body; + } + + if (\is_array($body)) { + if (isset($body['message']) && \is_string($body['message'])) { + return $body['message']; + } + + if (isset($body['error']) && \is_string($body['error'])) { + return $body['error']; + } + } + + return $default; + } } diff --git a/tests/Messaging/Adapter/Email/ResendRoutingTest.php b/tests/Messaging/Adapter/Email/ResendRoutingTest.php new file mode 100644 index 00000000..f674e1a2 --- /dev/null +++ b/tests/Messaging/Adapter/Email/ResendRoutingTest.php @@ -0,0 +1,164 @@ +stubResponses[] = ['statusCode' => 200, 'response' => []]; + + $message = new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $response = $stub->send($message); + + $this->assertCount(1, $stub->capturedRequests); + $this->assertEquals('https://api.resend.com/emails/batch', $stub->capturedRequests[0]['url']); + $this->assertCount(2, $stub->capturedRequests[0]['body']); + $this->assertArrayNotHasKey('attachments', $stub->capturedRequests[0]['body'][0]); + $this->assertEquals(2, $response['deliveredTo']); + } + + public function testWithAttachmentsUsesSingleEndpointPerRecipient(): void + { + $stub = new ResendStub('test-key'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'one']]; + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'two']]; + + $message = new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + attachments: [new Attachment( + name: 'note.txt', + path: '', + type: 'text/plain', + content: 'hello', + )], + ); + + $response = $stub->send($message); + + $this->assertCount(2, $stub->capturedRequests); + + foreach ($stub->capturedRequests as $request) { + $this->assertEquals('https://api.resend.com/emails', $request['url']); + $this->assertArrayHasKey('attachments', $request['body']); + $this->assertCount(1, $request['body']['attachments']); + $this->assertEquals('note.txt', $request['body']['attachments'][0]['filename']); + $this->assertEquals('text/plain', $request['body']['attachments'][0]['content_type']); + $this->assertEquals(\base64_encode('hello'), $request['body']['attachments'][0]['content']); + } + + $this->assertEquals(2, $response['deliveredTo']); + } + + public function testPartialFailureWithAttachmentsAggregatesResults(): void + { + $stub = new ResendStub('test-key'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'one']]; + $stub->stubResponses[] = ['statusCode' => 422, 'response' => ['message' => 'Invalid recipient']]; + + $message = new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + attachments: [new Attachment( + name: 'note.txt', + path: '', + type: 'text/plain', + content: 'hello', + )], + ); + + $response = $stub->send($message); + + $this->assertEquals(1, $response['deliveredTo']); + $this->assertEquals('success', $response['results'][0]['status']); + $this->assertEquals('failure', $response['results'][1]['status']); + $this->assertEquals('Invalid recipient', $response['results'][1]['error']); + } + + public function testAttachmentExceedingMaxSizeThrows(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Total attachment size exceeds'); + + $stub = new ResendStub('test-key'); + + $message = new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + attachments: [new Attachment( + name: 'large.bin', + path: '', + type: 'application/octet-stream', + content: \str_repeat('x', 40 * 1024 * 1024 + 1), + )], + ); + + $stub->send($message); + } +} + +class ResendStub extends Resend +{ + /** + * @var array, body: mixed}> + */ + public array $capturedRequests = []; + + /** + * @var array|string|null}> + */ + public array $stubResponses = []; + + /** + * @param array $headers + * @param array|null $body + * @return array{url: string, statusCode: int, response: array|string|null, error: string|null} + */ + protected function request( + string $method, + string $url, + array $headers = [], + ?array $body = null, + int $timeout = 30, + int $connectTimeout = 10 + ): array { + $this->capturedRequests[] = [ + 'method' => $method, + 'url' => $url, + 'headers' => $headers, + 'body' => $body, + ]; + + $stub = \array_shift($this->stubResponses) ?? ['statusCode' => 200, 'response' => []]; + + return [ + 'url' => $url, + 'statusCode' => $stub['statusCode'], + 'response' => $stub['response'], + 'error' => null, + ]; + } +} diff --git a/tests/Messaging/Adapter/Email/ResendTest.php b/tests/Messaging/Adapter/Email/ResendTest.php index d5f87741..1ee69d14 100644 --- a/tests/Messaging/Adapter/Email/ResendTest.php +++ b/tests/Messaging/Adapter/Email/ResendTest.php @@ -161,7 +161,7 @@ public function testSendEmailWithAttachmentExceedingMaxSize(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('Total attachment size exceeds'); - $largeContent = \str_repeat('x', 25 * 1024 * 1024 + 1); + $largeContent = \str_repeat('x', 40 * 1024 * 1024 + 1); $message = new Email( to: [$this->testEmail],