From 0a50d870ad97ec2c127dd96ec29024fdbb9444dd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Oct 2025 20:20:21 +0530 Subject: [PATCH 1/4] feat: add Resend email adapter Add support for Resend email service with a new adapter implementation and comprehensive tests. --- .env.dev | 1 + .github/workflows/test.yml | 1 + README.md | 5 + docker-compose.yml | 1 + src/Utopia/Messaging/Adapter/Email/Resend.php | 167 ++++++++++++++++++ tests/Messaging/Adapter/Email/ResendTest.php | 145 +++++++++++++++ 6 files changed, 320 insertions(+) create mode 100644 src/Utopia/Messaging/Adapter/Email/Resend.php create mode 100644 tests/Messaging/Adapter/Email/ResendTest.php diff --git a/.env.dev b/.env.dev index db4ed53..18d36b5 100644 --- a/.env.dev +++ b/.env.dev @@ -1,6 +1,7 @@ MAILGUN_API_KEY= MAILGUN_DOMAIN= SENDGRID_API_KEY= +RESEND_API_KEY= FCM_SERVICE_ACCOUNT_JSON= FCM_TO= TWILIO_ACCOUNT_SID= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7ec5b2..00ebdc6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }} MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }} SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} FCM_SERVICE_ACCOUNT_JSON: ${{ secrets.FCM_SERVICE_ACCOUNT_JSON }} FCM_TO: ${{ secrets.FCM_TO }} TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} diff --git a/README.md b/README.md index 110842b..b4b3673 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ composer require utopia-php/messaging use \Utopia\Messaging\Messages\Email; use \Utopia\Messaging\Adapter\Email\SendGrid; use \Utopia\Messaging\Adapter\Email\Mailgun; +use \Utopia\Messaging\Adapter\Email\Resend; $message = new Email( to: ['team@appwrite.io'], @@ -35,6 +36,9 @@ $messaging->send($message); $messaging = new Mailgun('YOUR_API_KEY', 'YOUR_DOMAIN'); $messaging->send($message); + +$messaging = new Resend('YOUR_API_KEY'); +$messaging->send($message); ``` ## SMS @@ -82,6 +86,7 @@ $messaging->send($message); ### Email - [x] [SendGrid](https://sendgrid.com/) - [x] [Mailgun](https://www.mailgun.com/) +- [x] [Resend](https://resend.com/) - [ ] [Mailjet](https://www.mailjet.com/) - [ ] [Mailchimp](https://www.mailchimp.com/) - [ ] [Postmark](https://postmarkapp.com/) diff --git a/docker-compose.yml b/docker-compose.yml index d729e2f..8bcb853 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - MAILGUN_API_KEY - MAILGUN_DOMAIN - SENDGRID_API_KEY + - RESEND_API_KEY - FCM_SERVICE_ACCOUNT_JSON - FCM_TO - TWILIO_ACCOUNT_SID diff --git a/src/Utopia/Messaging/Adapter/Email/Resend.php b/src/Utopia/Messaging/Adapter/Email/Resend.php new file mode 100644 index 0000000..c0dfb41 --- /dev/null +++ b/src/Utopia/Messaging/Adapter/Email/Resend.php @@ -0,0 +1,167 @@ +getAttachments()) && ! empty($message->getAttachments())) { + throw new \Exception('Resend does not support attachments at this time'); + } + + $response = new Response($this->getType()); + + $emails = []; + foreach ($message->getTo() as $to) { + $email = [ + 'from' => $message->getFromName() + ? "{$message->getFromName()} <{$message->getFromEmail()}>" + : $message->getFromEmail(), + 'to' => [$to], + 'subject' => $message->getSubject(), + ]; + + if ($message->isHtml()) { + $email['html'] = $message->getContent(); + } else { + $email['text'] = $message->getContent(); + } + + if (! empty($message->getReplyToEmail())) { + $email['reply_to'] = $message->getReplyToName() + ? ["{$message->getReplyToName()} <{$message->getReplyToEmail()}>"] + : [$message->getReplyToEmail()]; + } + + if (! \is_null($message->getCC()) && ! empty($message->getCC())) { + $ccList = []; + foreach ($message->getCC() as $cc) { + if (! empty($cc['email'])) { + $ccList[] = ! empty($cc['name']) + ? "{$cc['name']} <{$cc['email']}>" + : $cc['email']; + } + } + if (! empty($ccList)) { + $email['cc'] = $ccList; + } + } + + if (! \is_null($message->getBCC()) && ! empty($message->getBCC())) { + $bccList = []; + foreach ($message->getBCC() as $bcc) { + if (! empty($bcc['email'])) { + $bccList[] = ! empty($bcc['name']) + ? "{$bcc['name']} <{$bcc['email']}>" + : $bcc['email']; + } + } + if (! empty($bccList)) { + $email['bcc'] = $bccList; + } + } + + $emails[] = $email; + } + + $headers = [ + 'Authorization: Bearer '.$this->apiKey, + 'Content-Type: application/json', + ]; + + $result = $this->request( + method: 'POST', + url: 'https://api.resend.com/emails/batch', + headers: $headers, + body: $emails, // @phpstan-ignore-line + ); + + $statusCode = $result['statusCode']; + + if ($statusCode === 200) { + $responseData = $result['response']; + + if (isset($responseData['errors']) && ! empty($responseData['errors'])) { + $failedIndices = []; + foreach ($responseData['errors'] as $error) { + $failedIndices[$error['index']] = $error['message']; + } + + foreach ($message->getTo() as $index => $to) { + if (isset($failedIndices[$index])) { + $response->addResult($to, $failedIndices[$index]); + } else { + $response->addResult($to); + } + } + + $successCount = \count($message->getTo()) - \count($failedIndices); + $response->setDeliveredTo($successCount); + } else { + $response->setDeliveredTo(\count($message->getTo())); + foreach ($message->getTo() as $to) { + $response->addResult($to); + } + } + } 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']; + } + + foreach ($message->getTo() as $to) { + $response->addResult($to, $errorMessage); + } + } elseif ($statusCode >= 500) { + $errorMessage = '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, $errorMessage); + } + } + + return $response->toArray(); + } +} diff --git a/tests/Messaging/Adapter/Email/ResendTest.php b/tests/Messaging/Adapter/Email/ResendTest.php new file mode 100644 index 0000000..3f6e96d --- /dev/null +++ b/tests/Messaging/Adapter/Email/ResendTest.php @@ -0,0 +1,145 @@ + \getenv('TEST_CC_EMAIL')]]; + $bcc = [['name' => \getenv('TEST_BCC_NAME'), 'email' => \getenv('TEST_BCC_EMAIL')]]; + + $message = new Email( + to: [$to], + subject: $subject, + content: $content, + fromName: 'Test Sender', + fromEmail: $fromEmail, + cc: $cc, + bcc: $bcc, + ); + + $response = $sender->send($message); + + $this->assertResponse($response); + } + + public function testSendEmailWithHtml(): void + { + $key = \getenv('RESEND_API_KEY'); + $sender = new Resend($key); + + $to = \getenv('TEST_EMAIL'); + $subject = 'Test HTML Subject'; + $content = '

Test HTML Content

This is a test email with HTML content.

'; + $fromEmail = \getenv('TEST_FROM_EMAIL'); + + $message = new Email( + to: [$to], + subject: $subject, + content: $content, + fromName: 'Test Sender', + fromEmail: $fromEmail, + html: true, + ); + + $response = $sender->send($message); + + $this->assertResponse($response); + } + + public function testSendEmailWithReplyTo(): void + { + $key = \getenv('RESEND_API_KEY'); + $sender = new Resend($key); + + $to = \getenv('TEST_EMAIL'); + $subject = 'Test Reply-To Subject'; + $content = 'Test Content with Reply-To'; + $fromEmail = \getenv('TEST_FROM_EMAIL'); + $replyToEmail = \getenv('TEST_CC_EMAIL'); + + $message = new Email( + to: [$to], + subject: $subject, + content: $content, + fromName: 'Test Sender', + fromEmail: $fromEmail, + replyToName: 'Reply To Name', + replyToEmail: $replyToEmail, + ); + + $response = $sender->send($message); + + $this->assertResponse($response); + } + + public function testSendMultipleEmails(): void + { + $key = \getenv('RESEND_API_KEY'); + $sender = new Resend($key); + + $to1 = \getenv('TEST_EMAIL'); + $to2 = \getenv('TEST_CC_EMAIL'); + $subject = 'Test Batch Subject'; + $content = 'Test Batch Content'; + $fromEmail = \getenv('TEST_FROM_EMAIL'); + + $message = new Email( + to: [$to1, $to2], + subject: $subject, + content: $content, + fromName: 'Test Sender', + fromEmail: $fromEmail, + ); + + $response = $sender->send($message); + + $this->assertEquals(2, $response['deliveredTo'], \var_export($response, true)); + $this->assertEquals('', $response['results'][0]['error'], \var_export($response, true)); + $this->assertEquals('success', $response['results'][0]['status'], \var_export($response, true)); + $this->assertEquals('', $response['results'][1]['error'], \var_export($response, true)); + $this->assertEquals('success', $response['results'][1]['status'], \var_export($response, true)); + } + + public function testSendEmailWithAttachmentsThrowsException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Resend does not support attachments at this time'); + + $key = \getenv('RESEND_API_KEY'); + $sender = new Resend($key); + + $to = \getenv('TEST_EMAIL'); + $subject = 'Test Subject'; + $content = 'Test Content'; + $fromEmail = \getenv('TEST_FROM_EMAIL'); + + $message = new Email( + to: [$to], + subject: $subject, + content: $content, + fromName: 'Test Sender', + fromEmail: $fromEmail, + attachments: [new Attachment( + name: 'image.png', + path: __DIR__.'/../../../assets/image.png', + type: 'image/png' + )], + ); + + $sender->send($message); + } +} From 200a2b8be6818588eed66c33625893b48e22e3ce Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Oct 2025 20:28:55 +0530 Subject: [PATCH 2/4] use test email by resend --- .env.dev | 1 + .github/workflows/test.yml | 2 ++ docker-compose.yml | 1 + tests/Messaging/Adapter/Email/ResendTest.php | 28 ++++++++++---------- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.env.dev b/.env.dev index 18d36b5..807312c 100644 --- a/.env.dev +++ b/.env.dev @@ -2,6 +2,7 @@ MAILGUN_API_KEY= MAILGUN_DOMAIN= SENDGRID_API_KEY= RESEND_API_KEY= +RESEND_TEST_EMAIL= FCM_SERVICE_ACCOUNT_JSON= FCM_TO= TWILIO_ACCOUNT_SID= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00ebdc6..e4c36b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,6 +51,8 @@ jobs: FAST2SMS_TO: ${{ secrets.FAST2SMS_TO }} INFORU_API_TOKEN: ${{ secrets.INFORU_API_TOKEN }} INFORU_SENDER_ID: ${{ secrets.INFORU_SENDER_ID }} + + RESEND_TEST_EMAIL: ${{ vars.RESEND_TEST_EMAIL }} run: | docker compose up -d --build sleep 5 diff --git a/docker-compose.yml b/docker-compose.yml index 8bcb853..5562ef3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - MAILGUN_DOMAIN - SENDGRID_API_KEY - RESEND_API_KEY + - RESEND_TEST_EMAIL - FCM_SERVICE_ACCOUNT_JSON - FCM_TO - TWILIO_ACCOUNT_SID diff --git a/tests/Messaging/Adapter/Email/ResendTest.php b/tests/Messaging/Adapter/Email/ResendTest.php index 3f6e96d..6855b33 100644 --- a/tests/Messaging/Adapter/Email/ResendTest.php +++ b/tests/Messaging/Adapter/Email/ResendTest.php @@ -14,12 +14,12 @@ public function testSendEmail(): void $key = \getenv('RESEND_API_KEY'); $sender = new Resend($key); - $to = \getenv('TEST_EMAIL'); + $to = \getenv('RESEND_TEST_EMAIL'); $subject = 'Test Subject'; $content = 'Test Content'; - $fromEmail = \getenv('TEST_FROM_EMAIL'); - $cc = [['email' => \getenv('TEST_CC_EMAIL')]]; - $bcc = [['name' => \getenv('TEST_BCC_NAME'), 'email' => \getenv('TEST_BCC_EMAIL')]]; + $fromEmail = \getenv('RESEND_TEST_EMAIL'); + $cc = [['email' => \getenv('RESEND_TEST_EMAIL')]]; + $bcc = [['name' => 'Test BCC', 'email' => \getenv('RESEND_TEST_EMAIL')]]; $message = new Email( to: [$to], @@ -41,10 +41,10 @@ public function testSendEmailWithHtml(): void $key = \getenv('RESEND_API_KEY'); $sender = new Resend($key); - $to = \getenv('TEST_EMAIL'); + $to = \getenv('RESEND_TEST_EMAIL'); $subject = 'Test HTML Subject'; $content = '

Test HTML Content

This is a test email with HTML content.

'; - $fromEmail = \getenv('TEST_FROM_EMAIL'); + $fromEmail = \getenv('RESEND_TEST_EMAIL'); $message = new Email( to: [$to], @@ -65,11 +65,11 @@ public function testSendEmailWithReplyTo(): void $key = \getenv('RESEND_API_KEY'); $sender = new Resend($key); - $to = \getenv('TEST_EMAIL'); + $to = \getenv('RESEND_TEST_EMAIL'); $subject = 'Test Reply-To Subject'; $content = 'Test Content with Reply-To'; - $fromEmail = \getenv('TEST_FROM_EMAIL'); - $replyToEmail = \getenv('TEST_CC_EMAIL'); + $fromEmail = \getenv('RESEND_TEST_EMAIL'); + $replyToEmail = \getenv('RESEND_TEST_EMAIL'); $message = new Email( to: [$to], @@ -91,11 +91,11 @@ public function testSendMultipleEmails(): void $key = \getenv('RESEND_API_KEY'); $sender = new Resend($key); - $to1 = \getenv('TEST_EMAIL'); - $to2 = \getenv('TEST_CC_EMAIL'); + $to1 = \getenv('RESEND_TEST_EMAIL'); + $to2 = \getenv('RESEND_TEST_EMAIL'); $subject = 'Test Batch Subject'; $content = 'Test Batch Content'; - $fromEmail = \getenv('TEST_FROM_EMAIL'); + $fromEmail = \getenv('RESEND_TEST_EMAIL'); $message = new Email( to: [$to1, $to2], @@ -122,10 +122,10 @@ public function testSendEmailWithAttachmentsThrowsException(): void $key = \getenv('RESEND_API_KEY'); $sender = new Resend($key); - $to = \getenv('TEST_EMAIL'); + $to = \getenv('RESEND_TEST_EMAIL'); $subject = 'Test Subject'; $content = 'Test Content'; - $fromEmail = \getenv('TEST_FROM_EMAIL'); + $fromEmail = \getenv('RESEND_TEST_EMAIL'); $message = new Email( to: [$to], From 9704e0c34a1f0ab4cc9d77dfd8b1ab33b6cfb968 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Oct 2025 21:58:52 +0530 Subject: [PATCH 3/4] format tests with setup --- .env.dev | 2 +- .github/workflows/test.yml | 2 +- docker-compose.yml | 2 +- tests/Messaging/Adapter/Email/ResendTest.php | 62 +++++++++----------- 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/.env.dev b/.env.dev index 807312c..74dfc24 100644 --- a/.env.dev +++ b/.env.dev @@ -1,8 +1,8 @@ MAILGUN_API_KEY= MAILGUN_DOMAIN= -SENDGRID_API_KEY= RESEND_API_KEY= RESEND_TEST_EMAIL= +SENDGRID_API_KEY= FCM_SERVICE_ACCOUNT_JSON= FCM_TO= TWILIO_ACCOUNT_SID= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4c36b3..8794f4b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,8 +16,8 @@ jobs: env: MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }} MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }} - SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} FCM_SERVICE_ACCOUNT_JSON: ${{ secrets.FCM_SERVICE_ACCOUNT_JSON }} FCM_TO: ${{ secrets.FCM_TO }} TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} diff --git a/docker-compose.yml b/docker-compose.yml index 5562ef3..6e036f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,9 @@ services: environment: - MAILGUN_API_KEY - MAILGUN_DOMAIN - - SENDGRID_API_KEY - RESEND_API_KEY - RESEND_TEST_EMAIL + - SENDGRID_API_KEY - FCM_SERVICE_ACCOUNT_JSON - FCM_TO - TWILIO_ACCOUNT_SID diff --git a/tests/Messaging/Adapter/Email/ResendTest.php b/tests/Messaging/Adapter/Email/ResendTest.php index 6855b33..e21bc31 100644 --- a/tests/Messaging/Adapter/Email/ResendTest.php +++ b/tests/Messaging/Adapter/Email/ResendTest.php @@ -9,17 +9,25 @@ class ResendTest extends Base { - public function testSendEmail(): void + private Resend $sender; + private string $testEmail; + + protected function setUp(): void { + parent::setUp(); $key = \getenv('RESEND_API_KEY'); - $sender = new Resend($key); + $this->sender = new Resend($key); + $this->testEmail = \getenv('RESEND_TEST_EMAIL'); + } - $to = \getenv('RESEND_TEST_EMAIL'); + public function testSendEmail(): void + { + $to = $this->testEmail; $subject = 'Test Subject'; $content = 'Test Content'; - $fromEmail = \getenv('RESEND_TEST_EMAIL'); - $cc = [['email' => \getenv('RESEND_TEST_EMAIL')]]; - $bcc = [['name' => 'Test BCC', 'email' => \getenv('RESEND_TEST_EMAIL')]]; + $fromEmail = $this->testEmail; + $cc = [['email' => $this->testEmail]]; + $bcc = [['name' => 'Test BCC', 'email' => $this->testEmail]]; $message = new Email( to: [$to], @@ -31,20 +39,17 @@ public function testSendEmail(): void bcc: $bcc, ); - $response = $sender->send($message); + $response = $this->sender->send($message); $this->assertResponse($response); } public function testSendEmailWithHtml(): void { - $key = \getenv('RESEND_API_KEY'); - $sender = new Resend($key); - - $to = \getenv('RESEND_TEST_EMAIL'); + $to = $this->testEmail; $subject = 'Test HTML Subject'; $content = '

Test HTML Content

This is a test email with HTML content.

'; - $fromEmail = \getenv('RESEND_TEST_EMAIL'); + $fromEmail = $this->testEmail; $message = new Email( to: [$to], @@ -55,21 +60,18 @@ public function testSendEmailWithHtml(): void html: true, ); - $response = $sender->send($message); + $response = $this->sender->send($message); $this->assertResponse($response); } public function testSendEmailWithReplyTo(): void { - $key = \getenv('RESEND_API_KEY'); - $sender = new Resend($key); - - $to = \getenv('RESEND_TEST_EMAIL'); + $to = $this->testEmail; $subject = 'Test Reply-To Subject'; $content = 'Test Content with Reply-To'; - $fromEmail = \getenv('RESEND_TEST_EMAIL'); - $replyToEmail = \getenv('RESEND_TEST_EMAIL'); + $fromEmail = $this->testEmail; + $replyToEmail = $this->testEmail; $message = new Email( to: [$to], @@ -81,21 +83,18 @@ public function testSendEmailWithReplyTo(): void replyToEmail: $replyToEmail, ); - $response = $sender->send($message); + $response = $this->sender->send($message); $this->assertResponse($response); } public function testSendMultipleEmails(): void { - $key = \getenv('RESEND_API_KEY'); - $sender = new Resend($key); - - $to1 = \getenv('RESEND_TEST_EMAIL'); - $to2 = \getenv('RESEND_TEST_EMAIL'); + $to1 = $this->testEmail; + $to2 = $this->testEmail; $subject = 'Test Batch Subject'; $content = 'Test Batch Content'; - $fromEmail = \getenv('RESEND_TEST_EMAIL'); + $fromEmail = $this->testEmail; $message = new Email( to: [$to1, $to2], @@ -105,7 +104,7 @@ public function testSendMultipleEmails(): void fromEmail: $fromEmail, ); - $response = $sender->send($message); + $response = $this->sender->send($message); $this->assertEquals(2, $response['deliveredTo'], \var_export($response, true)); $this->assertEquals('', $response['results'][0]['error'], \var_export($response, true)); @@ -119,13 +118,10 @@ public function testSendEmailWithAttachmentsThrowsException(): void $this->expectException(\Exception::class); $this->expectExceptionMessage('Resend does not support attachments at this time'); - $key = \getenv('RESEND_API_KEY'); - $sender = new Resend($key); - - $to = \getenv('RESEND_TEST_EMAIL'); + $to = $this->testEmail; $subject = 'Test Subject'; $content = 'Test Content'; - $fromEmail = \getenv('RESEND_TEST_EMAIL'); + $fromEmail = $this->testEmail; $message = new Email( to: [$to], @@ -140,6 +136,6 @@ public function testSendEmailWithAttachmentsThrowsException(): void )], ); - $sender->send($message); + $this->sender->send($message); } } From 1ba18f14319111e9ba00a5036d498c52830d4b0d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Oct 2025 09:53:21 +0530 Subject: [PATCH 4/4] add sleep --- tests/Messaging/Adapter/Email/ResendTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Messaging/Adapter/Email/ResendTest.php b/tests/Messaging/Adapter/Email/ResendTest.php index e21bc31..8e8f023 100644 --- a/tests/Messaging/Adapter/Email/ResendTest.php +++ b/tests/Messaging/Adapter/Email/ResendTest.php @@ -18,6 +18,8 @@ protected function setUp(): void $key = \getenv('RESEND_API_KEY'); $this->sender = new Resend($key); $this->testEmail = \getenv('RESEND_TEST_EMAIL'); + + sleep(2); } public function testSendEmail(): void