Skip to content

Commit 67366d5

Browse files
Merge pull request #120 from utopia-php/fix-resend-attachments
fix(Resend): send attachments via per-message endpoint
2 parents f99fece + 18a4ca9 commit 67366d5

3 files changed

Lines changed: 247 additions & 21 deletions

File tree

src/Utopia/Messaging/Adapter/Email/Resend.php

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class Resend extends EmailAdapter
1010
{
1111
protected const NAME = 'Resend';
1212

13+
protected const MAX_ATTACHMENT_BYTES = 40 * 1024 * 1024;
14+
1315
/**
1416
* @param string $apiKey Your Resend API key to authenticate with the API.
1517
*/
@@ -29,9 +31,8 @@ public function getMaxMessagesPerRequest(): int
2931
}
3032

3133
/**
32-
* Uses Resend's batch sending API to send multiple emails at once.
33-
*
3434
* @link https://resend.com/docs/api-reference/emails/send-batch-emails
35+
* @link https://resend.com/docs/api-reference/emails/send-email
3536
*/
3637
protected function process(EmailMessage $message): array
3738
{
@@ -77,7 +78,7 @@ protected function process(EmailMessage $message): array
7778

7879
$emails = [];
7980
foreach ($message->getTo() as $to) {
80-
$toFormatted = !empty($to['name'])
81+
$toFormatted = ! empty($to['name'])
8182
? "{$to['name']} <{$to['email']}>"
8283
: $to['email'];
8384

@@ -133,19 +134,33 @@ protected function process(EmailMessage $message): array
133134
'Content-Type: application/json',
134135
];
135136

137+
if (! empty($attachments)) {
138+
return $this->sendIndividually($message, $emails, $headers, $response);
139+
}
140+
141+
return $this->sendBatch($message, $emails, $headers, $response);
142+
}
143+
144+
/**
145+
* @param array<array<string, mixed>> $emails
146+
* @param array<string> $headers
147+
* @return array{deliveredTo: int, type: string, results: array<array<string, mixed>>}
148+
*/
149+
private function sendBatch(EmailMessage $message, array $emails, array $headers, Response $response): array
150+
{
136151
$result = $this->request(
137152
method: 'POST',
138153
url: 'https://api.resend.com/emails/batch',
139154
headers: $headers,
140-
body: $emails, // @phpstan-ignore-line
155+
body: $emails,
141156
);
142157

143158
$statusCode = $result['statusCode'];
144159

145160
if ($statusCode === 200) {
146161
$responseData = $result['response'];
147162

148-
if (isset($responseData['errors']) && ! empty($responseData['errors'])) {
163+
if (\is_array($responseData) && isset($responseData['errors']) && ! empty($responseData['errors'])) {
149164
$failedIndices = [];
150165
foreach ($responseData['errors'] as $error) {
151166
$failedIndices[$error['index']] = $error['message'];
@@ -168,33 +183,80 @@ protected function process(EmailMessage $message): array
168183
}
169184
}
170185
} elseif ($statusCode >= 400 && $statusCode < 500) {
171-
$errorMessage = 'Unknown error';
172-
173-
if (\is_string($result['response'])) {
174-
$errorMessage = $result['response'];
175-
} elseif (isset($result['response']['message'])) {
176-
$errorMessage = $result['response']['message'];
177-
} elseif (isset($result['response']['error'])) {
178-
$errorMessage = $result['response']['error'];
179-
}
186+
$errorMessage = $this->extractErrorMessage($result['response'], 'Unknown error');
180187

181188
foreach ($message->getTo() as $to) {
182189
$response->addResult($to['email'], $errorMessage);
183190
}
184191
} elseif ($statusCode >= 500) {
185-
$errorMessage = 'Server error';
192+
$errorMessage = $this->extractErrorMessage($result['response'], 'Server error');
186193

187-
if (\is_string($result['response'])) {
188-
$errorMessage = $result['response'];
189-
} elseif (isset($result['response']['message'])) {
190-
$errorMessage = $result['response']['message'];
194+
foreach ($message->getTo() as $to) {
195+
$response->addResult($to['email'], $errorMessage);
191196
}
197+
}
192198

193-
foreach ($message->getTo() as $to) {
199+
return $response->toArray();
200+
}
201+
202+
/**
203+
* @param array<array<string, mixed>> $emails
204+
* @param array<string> $headers
205+
* @return array{deliveredTo: int, type: string, results: array<array<string, mixed>>}
206+
*/
207+
private function sendIndividually(EmailMessage $message, array $emails, array $headers, Response $response): array
208+
{
209+
$recipients = $message->getTo();
210+
$deliveredTo = 0;
211+
212+
foreach ($emails as $index => $email) {
213+
$to = $recipients[$index];
214+
215+
$result = $this->request(
216+
method: 'POST',
217+
url: 'https://api.resend.com/emails',
218+
headers: $headers,
219+
body: $email,
220+
);
221+
222+
$statusCode = $result['statusCode'];
223+
224+
if ($statusCode >= 200 && $statusCode < 300) {
225+
$response->addResult($to['email']);
226+
$deliveredTo++;
227+
} elseif ($statusCode >= 400 && $statusCode < 500) {
228+
$errorMessage = $this->extractErrorMessage($result['response'], 'Unknown error');
229+
$response->addResult($to['email'], $errorMessage);
230+
} else {
231+
$errorMessage = $this->extractErrorMessage($result['response'], 'Server error');
194232
$response->addResult($to['email'], $errorMessage);
195233
}
196234
}
197235

236+
$response->setDeliveredTo($deliveredTo);
237+
198238
return $response->toArray();
199239
}
240+
241+
/**
242+
* @param array<string, mixed>|string|null $body
243+
*/
244+
private function extractErrorMessage(array|string|null $body, string $default): string
245+
{
246+
if (\is_string($body)) {
247+
return $body;
248+
}
249+
250+
if (\is_array($body)) {
251+
if (isset($body['message']) && \is_string($body['message'])) {
252+
return $body['message'];
253+
}
254+
255+
if (isset($body['error']) && \is_string($body['error'])) {
256+
return $body['error'];
257+
}
258+
}
259+
260+
return $default;
261+
}
200262
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
3+
namespace Utopia\Tests\Adapter\Email;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Utopia\Messaging\Adapter\Email\Resend;
7+
use Utopia\Messaging\Messages\Email;
8+
use Utopia\Messaging\Messages\Email\Attachment;
9+
10+
class ResendRoutingTest extends TestCase
11+
{
12+
public function testWithoutAttachmentsUsesBatchEndpoint(): void
13+
{
14+
$stub = new ResendStub('test-key');
15+
$stub->stubResponses[] = ['statusCode' => 200, 'response' => []];
16+
17+
$message = new Email(
18+
to: [['email' => 'a@example.com'], ['email' => 'b@example.com']],
19+
subject: 'Subject',
20+
content: 'Body',
21+
fromName: 'Sender',
22+
fromEmail: 'from@example.com',
23+
);
24+
25+
$response = $stub->send($message);
26+
27+
$this->assertCount(1, $stub->capturedRequests);
28+
$this->assertEquals('https://api.resend.com/emails/batch', $stub->capturedRequests[0]['url']);
29+
$this->assertCount(2, $stub->capturedRequests[0]['body']);
30+
$this->assertArrayNotHasKey('attachments', $stub->capturedRequests[0]['body'][0]);
31+
$this->assertEquals(2, $response['deliveredTo']);
32+
}
33+
34+
public function testWithAttachmentsUsesSingleEndpointPerRecipient(): void
35+
{
36+
$stub = new ResendStub('test-key');
37+
$stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'one']];
38+
$stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'two']];
39+
40+
$message = new Email(
41+
to: [['email' => 'a@example.com'], ['email' => 'b@example.com']],
42+
subject: 'Subject',
43+
content: 'Body',
44+
fromName: 'Sender',
45+
fromEmail: 'from@example.com',
46+
attachments: [new Attachment(
47+
name: 'note.txt',
48+
path: '',
49+
type: 'text/plain',
50+
content: 'hello',
51+
)],
52+
);
53+
54+
$response = $stub->send($message);
55+
56+
$this->assertCount(2, $stub->capturedRequests);
57+
58+
foreach ($stub->capturedRequests as $request) {
59+
$this->assertEquals('https://api.resend.com/emails', $request['url']);
60+
$this->assertArrayHasKey('attachments', $request['body']);
61+
$this->assertCount(1, $request['body']['attachments']);
62+
$this->assertEquals('note.txt', $request['body']['attachments'][0]['filename']);
63+
$this->assertEquals('text/plain', $request['body']['attachments'][0]['content_type']);
64+
$this->assertEquals(\base64_encode('hello'), $request['body']['attachments'][0]['content']);
65+
}
66+
67+
$this->assertEquals(2, $response['deliveredTo']);
68+
}
69+
70+
public function testPartialFailureWithAttachmentsAggregatesResults(): void
71+
{
72+
$stub = new ResendStub('test-key');
73+
$stub->stubResponses[] = ['statusCode' => 200, 'response' => ['id' => 'one']];
74+
$stub->stubResponses[] = ['statusCode' => 422, 'response' => ['message' => 'Invalid recipient']];
75+
76+
$message = new Email(
77+
to: [['email' => 'a@example.com'], ['email' => 'b@example.com']],
78+
subject: 'Subject',
79+
content: 'Body',
80+
fromName: 'Sender',
81+
fromEmail: 'from@example.com',
82+
attachments: [new Attachment(
83+
name: 'note.txt',
84+
path: '',
85+
type: 'text/plain',
86+
content: 'hello',
87+
)],
88+
);
89+
90+
$response = $stub->send($message);
91+
92+
$this->assertEquals(1, $response['deliveredTo']);
93+
$this->assertEquals('success', $response['results'][0]['status']);
94+
$this->assertEquals('failure', $response['results'][1]['status']);
95+
$this->assertEquals('Invalid recipient', $response['results'][1]['error']);
96+
}
97+
98+
public function testAttachmentExceedingMaxSizeThrows(): void
99+
{
100+
$this->expectException(\Exception::class);
101+
$this->expectExceptionMessage('Total attachment size exceeds');
102+
103+
$stub = new ResendStub('test-key');
104+
105+
$message = new Email(
106+
to: [['email' => 'a@example.com']],
107+
subject: 'Subject',
108+
content: 'Body',
109+
fromName: 'Sender',
110+
fromEmail: 'from@example.com',
111+
attachments: [new Attachment(
112+
name: 'large.bin',
113+
path: '',
114+
type: 'application/octet-stream',
115+
content: \str_repeat('x', 40 * 1024 * 1024 + 1),
116+
)],
117+
);
118+
119+
$stub->send($message);
120+
}
121+
}
122+
123+
class ResendStub extends Resend
124+
{
125+
/**
126+
* @var array<array{url: string, method: string, headers: array<string>, body: mixed}>
127+
*/
128+
public array $capturedRequests = [];
129+
130+
/**
131+
* @var array<array{statusCode: int, response: array<string, mixed>|string|null}>
132+
*/
133+
public array $stubResponses = [];
134+
135+
/**
136+
* @param array<string> $headers
137+
* @param array<string, mixed>|null $body
138+
* @return array{url: string, statusCode: int, response: array<string, mixed>|string|null, error: string|null}
139+
*/
140+
protected function request(
141+
string $method,
142+
string $url,
143+
array $headers = [],
144+
?array $body = null,
145+
int $timeout = 30,
146+
int $connectTimeout = 10
147+
): array {
148+
$this->capturedRequests[] = [
149+
'method' => $method,
150+
'url' => $url,
151+
'headers' => $headers,
152+
'body' => $body,
153+
];
154+
155+
$stub = \array_shift($this->stubResponses) ?? ['statusCode' => 200, 'response' => []];
156+
157+
return [
158+
'url' => $url,
159+
'statusCode' => $stub['statusCode'],
160+
'response' => $stub['response'],
161+
'error' => null,
162+
];
163+
}
164+
}

tests/Messaging/Adapter/Email/ResendTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public function testSendEmailWithAttachmentExceedingMaxSize(): void
161161
$this->expectException(\Exception::class);
162162
$this->expectExceptionMessage('Total attachment size exceeds');
163163

164-
$largeContent = \str_repeat('x', 25 * 1024 * 1024 + 1);
164+
$largeContent = \str_repeat('x', 40 * 1024 * 1024 + 1);
165165

166166
$message = new Email(
167167
to: [$this->testEmail],

0 commit comments

Comments
 (0)