Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 82 additions & 20 deletions src/Utopia/Messaging/Adapter/Email/Resend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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
{
Expand Down Expand Up @@ -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'];

Expand Down Expand Up @@ -133,19 +134,33 @@ 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<array<string, mixed>> $emails
* @param array<string> $headers
* @return array{deliveredTo: int, type: string, results: array<array<string, mixed>>}
*/
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'];

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'];
Expand All @@ -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<array<string, mixed>> $emails
* @param array<string> $headers
* @return array{deliveredTo: int, type: string, results: array<array<string, mixed>>}
*/
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, mixed>|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;
}
}
164 changes: 164 additions & 0 deletions tests/Messaging/Adapter/Email/ResendRoutingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php

namespace Utopia\Tests\Adapter\Email;

use PHPUnit\Framework\TestCase;
use Utopia\Messaging\Adapter\Email\Resend;
use Utopia\Messaging\Messages\Email;
use Utopia\Messaging\Messages\Email\Attachment;

class ResendRoutingTest extends TestCase
{
public function testWithoutAttachmentsUsesBatchEndpoint(): void
{
$stub = new ResendStub('test-key');
$stub->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<array{url: string, method: string, headers: array<string>, body: mixed}>
*/
public array $capturedRequests = [];

/**
* @var array<array{statusCode: int, response: array<string, mixed>|string|null}>
*/
public array $stubResponses = [];

/**
* @param array<string> $headers
* @param array<string, mixed>|null $body
* @return array{url: string, statusCode: int, response: array<string, mixed>|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,
];
}
}
2 changes: 1 addition & 1 deletion tests/Messaging/Adapter/Email/ResendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading