Skip to content

Commit 6c5be45

Browse files
authored
Merge pull request #103 from utopia-php/feat-resend-email-adapter
feat: add Resend email adapter
2 parents 0b866d5 + 1ba18f1 commit 6c5be45

File tree

6 files changed

+322
-0
lines changed

6 files changed

+322
-0
lines changed

.env.dev

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
MAILGUN_API_KEY=
22
MAILGUN_DOMAIN=
3+
RESEND_API_KEY=
4+
RESEND_TEST_EMAIL=
35
SENDGRID_API_KEY=
46
FCM_SERVICE_ACCOUNT_JSON=
57
FCM_TO=

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
env:
1717
MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }}
1818
MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }}
19+
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
1920
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
2021
FCM_SERVICE_ACCOUNT_JSON: ${{ secrets.FCM_SERVICE_ACCOUNT_JSON }}
2122
FCM_TO: ${{ secrets.FCM_TO }}
@@ -50,6 +51,8 @@ jobs:
5051
FAST2SMS_TO: ${{ secrets.FAST2SMS_TO }}
5152
INFORU_API_TOKEN: ${{ secrets.INFORU_API_TOKEN }}
5253
INFORU_SENDER_ID: ${{ secrets.INFORU_SENDER_ID }}
54+
55+
RESEND_TEST_EMAIL: ${{ vars.RESEND_TEST_EMAIL }}
5356
run: |
5457
docker compose up -d --build
5558
sleep 5

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ composer require utopia-php/messaging
2323
use \Utopia\Messaging\Messages\Email;
2424
use \Utopia\Messaging\Adapter\Email\SendGrid;
2525
use \Utopia\Messaging\Adapter\Email\Mailgun;
26+
use \Utopia\Messaging\Adapter\Email\Resend;
2627

2728
$message = new Email(
2829
to: ['team@appwrite.io'],
@@ -35,6 +36,9 @@ $messaging->send($message);
3536

3637
$messaging = new Mailgun('YOUR_API_KEY', 'YOUR_DOMAIN');
3738
$messaging->send($message);
39+
40+
$messaging = new Resend('YOUR_API_KEY');
41+
$messaging->send($message);
3842
```
3943

4044
## SMS
@@ -82,6 +86,7 @@ $messaging->send($message);
8286
### Email
8387
- [x] [SendGrid](https://sendgrid.com/)
8488
- [x] [Mailgun](https://www.mailgun.com/)
89+
- [x] [Resend](https://resend.com/)
8590
- [ ] [Mailjet](https://www.mailjet.com/)
8691
- [ ] [Mailchimp](https://www.mailchimp.com/)
8792
- [ ] [Postmark](https://postmarkapp.com/)

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ services:
99
environment:
1010
- MAILGUN_API_KEY
1111
- MAILGUN_DOMAIN
12+
- RESEND_API_KEY
13+
- RESEND_TEST_EMAIL
1214
- SENDGRID_API_KEY
1315
- FCM_SERVICE_ACCOUNT_JSON
1416
- FCM_TO
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
namespace Utopia\Messaging\Adapter\Email;
4+
5+
use Utopia\Messaging\Adapter\Email as EmailAdapter;
6+
use Utopia\Messaging\Messages\Email as EmailMessage;
7+
use Utopia\Messaging\Response;
8+
9+
class Resend extends EmailAdapter
10+
{
11+
protected const NAME = 'Resend';
12+
13+
/**
14+
* @param string $apiKey Your Resend API key to authenticate with the API.
15+
*/
16+
public function __construct(
17+
private string $apiKey
18+
) {
19+
}
20+
21+
public function getName(): string
22+
{
23+
return static::NAME;
24+
}
25+
26+
public function getMaxMessagesPerRequest(): int
27+
{
28+
return 100;
29+
}
30+
31+
/**
32+
* Uses Resend's batch sending API to send multiple emails at once.
33+
*
34+
* @link https://resend.com/docs/api-reference/emails/send-batch-emails
35+
*/
36+
protected function process(EmailMessage $message): array
37+
{
38+
// Resend doesn't support attachments yet
39+
if (! \is_null($message->getAttachments()) && ! empty($message->getAttachments())) {
40+
throw new \Exception('Resend does not support attachments at this time');
41+
}
42+
43+
$response = new Response($this->getType());
44+
45+
$emails = [];
46+
foreach ($message->getTo() as $to) {
47+
$email = [
48+
'from' => $message->getFromName()
49+
? "{$message->getFromName()} <{$message->getFromEmail()}>"
50+
: $message->getFromEmail(),
51+
'to' => [$to],
52+
'subject' => $message->getSubject(),
53+
];
54+
55+
if ($message->isHtml()) {
56+
$email['html'] = $message->getContent();
57+
} else {
58+
$email['text'] = $message->getContent();
59+
}
60+
61+
if (! empty($message->getReplyToEmail())) {
62+
$email['reply_to'] = $message->getReplyToName()
63+
? ["{$message->getReplyToName()} <{$message->getReplyToEmail()}>"]
64+
: [$message->getReplyToEmail()];
65+
}
66+
67+
if (! \is_null($message->getCC()) && ! empty($message->getCC())) {
68+
$ccList = [];
69+
foreach ($message->getCC() as $cc) {
70+
if (! empty($cc['email'])) {
71+
$ccList[] = ! empty($cc['name'])
72+
? "{$cc['name']} <{$cc['email']}>"
73+
: $cc['email'];
74+
}
75+
}
76+
if (! empty($ccList)) {
77+
$email['cc'] = $ccList;
78+
}
79+
}
80+
81+
if (! \is_null($message->getBCC()) && ! empty($message->getBCC())) {
82+
$bccList = [];
83+
foreach ($message->getBCC() as $bcc) {
84+
if (! empty($bcc['email'])) {
85+
$bccList[] = ! empty($bcc['name'])
86+
? "{$bcc['name']} <{$bcc['email']}>"
87+
: $bcc['email'];
88+
}
89+
}
90+
if (! empty($bccList)) {
91+
$email['bcc'] = $bccList;
92+
}
93+
}
94+
95+
$emails[] = $email;
96+
}
97+
98+
$headers = [
99+
'Authorization: Bearer '.$this->apiKey,
100+
'Content-Type: application/json',
101+
];
102+
103+
$result = $this->request(
104+
method: 'POST',
105+
url: 'https://api.resend.com/emails/batch',
106+
headers: $headers,
107+
body: $emails, // @phpstan-ignore-line
108+
);
109+
110+
$statusCode = $result['statusCode'];
111+
112+
if ($statusCode === 200) {
113+
$responseData = $result['response'];
114+
115+
if (isset($responseData['errors']) && ! empty($responseData['errors'])) {
116+
$failedIndices = [];
117+
foreach ($responseData['errors'] as $error) {
118+
$failedIndices[$error['index']] = $error['message'];
119+
}
120+
121+
foreach ($message->getTo() as $index => $to) {
122+
if (isset($failedIndices[$index])) {
123+
$response->addResult($to, $failedIndices[$index]);
124+
} else {
125+
$response->addResult($to);
126+
}
127+
}
128+
129+
$successCount = \count($message->getTo()) - \count($failedIndices);
130+
$response->setDeliveredTo($successCount);
131+
} else {
132+
$response->setDeliveredTo(\count($message->getTo()));
133+
foreach ($message->getTo() as $to) {
134+
$response->addResult($to);
135+
}
136+
}
137+
} elseif ($statusCode >= 400 && $statusCode < 500) {
138+
$errorMessage = 'Unknown error';
139+
140+
if (\is_string($result['response'])) {
141+
$errorMessage = $result['response'];
142+
} elseif (isset($result['response']['message'])) {
143+
$errorMessage = $result['response']['message'];
144+
} elseif (isset($result['response']['error'])) {
145+
$errorMessage = $result['response']['error'];
146+
}
147+
148+
foreach ($message->getTo() as $to) {
149+
$response->addResult($to, $errorMessage);
150+
}
151+
} elseif ($statusCode >= 500) {
152+
$errorMessage = 'Server error';
153+
154+
if (\is_string($result['response'])) {
155+
$errorMessage = $result['response'];
156+
} elseif (isset($result['response']['message'])) {
157+
$errorMessage = $result['response']['message'];
158+
}
159+
160+
foreach ($message->getTo() as $to) {
161+
$response->addResult($to, $errorMessage);
162+
}
163+
}
164+
165+
return $response->toArray();
166+
}
167+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
namespace Utopia\Tests\Adapter\Email;
4+
5+
use Utopia\Messaging\Adapter\Email\Resend;
6+
use Utopia\Messaging\Messages\Email;
7+
use Utopia\Messaging\Messages\Email\Attachment;
8+
use Utopia\Tests\Adapter\Base;
9+
10+
class ResendTest extends Base
11+
{
12+
private Resend $sender;
13+
private string $testEmail;
14+
15+
protected function setUp(): void
16+
{
17+
parent::setUp();
18+
$key = \getenv('RESEND_API_KEY');
19+
$this->sender = new Resend($key);
20+
$this->testEmail = \getenv('RESEND_TEST_EMAIL');
21+
22+
sleep(2);
23+
}
24+
25+
public function testSendEmail(): void
26+
{
27+
$to = $this->testEmail;
28+
$subject = 'Test Subject';
29+
$content = 'Test Content';
30+
$fromEmail = $this->testEmail;
31+
$cc = [['email' => $this->testEmail]];
32+
$bcc = [['name' => 'Test BCC', 'email' => $this->testEmail]];
33+
34+
$message = new Email(
35+
to: [$to],
36+
subject: $subject,
37+
content: $content,
38+
fromName: 'Test Sender',
39+
fromEmail: $fromEmail,
40+
cc: $cc,
41+
bcc: $bcc,
42+
);
43+
44+
$response = $this->sender->send($message);
45+
46+
$this->assertResponse($response);
47+
}
48+
49+
public function testSendEmailWithHtml(): void
50+
{
51+
$to = $this->testEmail;
52+
$subject = 'Test HTML Subject';
53+
$content = '<h1>Test HTML Content</h1><p>This is a test email with HTML content.</p>';
54+
$fromEmail = $this->testEmail;
55+
56+
$message = new Email(
57+
to: [$to],
58+
subject: $subject,
59+
content: $content,
60+
fromName: 'Test Sender',
61+
fromEmail: $fromEmail,
62+
html: true,
63+
);
64+
65+
$response = $this->sender->send($message);
66+
67+
$this->assertResponse($response);
68+
}
69+
70+
public function testSendEmailWithReplyTo(): void
71+
{
72+
$to = $this->testEmail;
73+
$subject = 'Test Reply-To Subject';
74+
$content = 'Test Content with Reply-To';
75+
$fromEmail = $this->testEmail;
76+
$replyToEmail = $this->testEmail;
77+
78+
$message = new Email(
79+
to: [$to],
80+
subject: $subject,
81+
content: $content,
82+
fromName: 'Test Sender',
83+
fromEmail: $fromEmail,
84+
replyToName: 'Reply To Name',
85+
replyToEmail: $replyToEmail,
86+
);
87+
88+
$response = $this->sender->send($message);
89+
90+
$this->assertResponse($response);
91+
}
92+
93+
public function testSendMultipleEmails(): void
94+
{
95+
$to1 = $this->testEmail;
96+
$to2 = $this->testEmail;
97+
$subject = 'Test Batch Subject';
98+
$content = 'Test Batch Content';
99+
$fromEmail = $this->testEmail;
100+
101+
$message = new Email(
102+
to: [$to1, $to2],
103+
subject: $subject,
104+
content: $content,
105+
fromName: 'Test Sender',
106+
fromEmail: $fromEmail,
107+
);
108+
109+
$response = $this->sender->send($message);
110+
111+
$this->assertEquals(2, $response['deliveredTo'], \var_export($response, true));
112+
$this->assertEquals('', $response['results'][0]['error'], \var_export($response, true));
113+
$this->assertEquals('success', $response['results'][0]['status'], \var_export($response, true));
114+
$this->assertEquals('', $response['results'][1]['error'], \var_export($response, true));
115+
$this->assertEquals('success', $response['results'][1]['status'], \var_export($response, true));
116+
}
117+
118+
public function testSendEmailWithAttachmentsThrowsException(): void
119+
{
120+
$this->expectException(\Exception::class);
121+
$this->expectExceptionMessage('Resend does not support attachments at this time');
122+
123+
$to = $this->testEmail;
124+
$subject = 'Test Subject';
125+
$content = 'Test Content';
126+
$fromEmail = $this->testEmail;
127+
128+
$message = new Email(
129+
to: [$to],
130+
subject: $subject,
131+
content: $content,
132+
fromName: 'Test Sender',
133+
fromEmail: $fromEmail,
134+
attachments: [new Attachment(
135+
name: 'image.png',
136+
path: __DIR__.'/../../../assets/image.png',
137+
type: 'image/png'
138+
)],
139+
);
140+
141+
$this->sender->send($message);
142+
}
143+
}

0 commit comments

Comments
 (0)