10000 Add Resend Mailer bridge · symfony/symfony@8beb4dc · GitHub
[go: up one dir, main page]

Skip to content

Commit 8beb4dc

Browse files
committed
Add Resend Mailer bridge
1 parent 744d0ac commit 8beb4dc

File tree

17 files changed

+774
-0
lines changed

17 files changed

+774
-0
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,6 +2572,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co
25722572
MailerBridge\MailPace\Transport\MailPaceTransportFactory::class => 'mailer.transport_factory.mailpace',
25732573
MailerBridge\Mailchimp\Transport\MandrillTransportFactory::class => 'mailer.transport_factory.mailchimp',
25742574
MailerBridge\Postmark\Transport\PostmarkTransportFactory::class => 'mailer.transport_factory.postmark',
2575+
MailerBridge\Resend\Transport\ResendTransportFactory::class => 'mailer.transport_factory.resend',
25752576
MailerBridge\Scaleway\Transport\ScalewayTransportFactory::class => 'mailer.transport_factory.scaleway',
25762577
MailerBridge\Sendgrid\Transport\SendgridTransportFactory::class => 'mailer.transport_factory.sendgrid',
25772578
MailerBridge\Amazon\Transport\SesTransportFactory::class => 'mailer.transport_factory.amazon',
@@ -2591,6 +2592,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co
25912592
MailerBridge\Mailgun\Webhook\MailgunRequestParser::class => 'mailer.webhook.request_parser.mailgun',
25922593
MailerBridge\Mailjet\Webhook\MailjetRequestParser::class => 'mailer.webhook.request_parser.mailjet',
25932594
MailerBridge\Postmark\Webhook\PostmarkRequestParser::class => 'mailer.webhook.request_parser.postmark',
2595+
MailerBridge\Resend\Webhook\ResendRequestParser::class => 'mailer.webhook.request_parser.resend',
25942596
MailerBridge\Sendgrid\Webhook\SendgridRequestParser::class => 'mailer.webhook.request_parser.sendgrid',
25952597
];
25962598

src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory;
2323
use Symfony\Component\Mailer\Bridge\MailPace\Transport\MailPaceTransportFactory;
2424
use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory;
25+
use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendTransportFactory;
2526
use Symfony\Component\Mailer\Bridge\Scaleway\Transport\ScalewayTransportFactory;
2627
use Symfony\Component\Mailer\Bridge\Sendgrid\Transport\SendgridTransportFactory;
2728
use Symfony\Component\Mailer\Transport\AbstractTransportFactory;
@@ -55,6 +56,7 @@
5556
'native' => NativeTransportFactory::class,
5657
'null' => NullTransportFactory::class,
5758
'postmark' => PostmarkTransportFactory::class,
59+
'resend' => ResendTransportFactory::class,
5860
'scaleway' => ScalewayTransportFactory::class,
5961
'sendgrid' => SendgridTransportFactory::class,
6062
'sendmail' => SendmailTransportFactory::class,

src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use Symfony\Component\Mailer\Bridge\Mailjet\Webhook\MailjetRequestParser;
2020
use Symfony\Component\Mailer\Bridge\Postmark\RemoteEvent\PostmarkPayloadConverter;
2121
use Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser;
22+
use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter;
23+
use Symfony\Component\Mailer\Bridge\Resend\Webhook\ResendRequestParser;
2224
use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter;
2325
use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser;
2426

@@ -44,6 +46,11 @@
4446
->args([service('mailer.payload_converter.postmark')])
4547
->alias(PostmarkRequestParser::class, 'mailer.webhook.request_parser.postmark')
4648

49+
->set('mailer.payload_converter.resend', ResendPayloadConverter::class)
50+
->set('mailer.webhook.request_parser.resend', ResendRequestParser::class)
51+
->args([service('mailer.payload_converter.resend')])
52+
->alias(ResendRequestParser::class, 'mailer.webhook.request_parser.resend')
53+
4754
->set('mailer.payload_converter.sendgrid', SendgridPayloadConverter::class)
4855
->set('mailer.webhook.request_parser.sendgrid', SendgridRequestParser::class)
4956
->args([service('mailer.payload_converter.sendgrid')])
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/Tests export-ignore
2+
/phpunit.xml.dist export-ignore
3+
/.gitattributes export-ignore
4+
/.gitignore export-ignore
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor/
2+
composer.lock
3+
phpunit.xml
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CHANGELOG
2+
=========
3+
4+
7.1
5+
---
6+
7+
* Add the bridge
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2024-present Fabien Potencier
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is furnished
8+
to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all
11+
copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
THE SOFTWARE.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
Resend Bridge
2+
============
3+
4+
Provides Resend integration for Symfony Mailer.
5+
6+
Configuration example:
7+
8+
```env
9+
# SMTP
10+
MAILER_DSN=resend+smtp://resend:API_KEY@default
11+
12+
# API
13+
MAILER_DSN=resend+api://API_KEY@default
14+
```
15+
16+
where:
17+
- `API_KEY` is your Resend API Key
18+
19+
Resources
20+
---------
21+
22+
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
23+
* [Report issues](https://github.com/symfony/symfony/issues) and
24+
[send Pull Requests](https://github.com/symfony/symfony/pulls)
25+
in the [main Symfony repository](https://github.com/symfony/symfony)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Mailer\Bridge\Resend\RemoteEvent;
13+
14+
use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent;
15+
use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;
16+
use Symfony\Component\RemoteEvent\Event\Mailer\MailerEngagementEvent;
17+
use Symfony\Component\RemoteEvent\Exception\ParseException;
18+
use Symfony\Component\RemoteEvent\PayloadConverterInterface;
19+
20+
final class ResendPayloadConverter implements PayloadConverterInterface
21+
{
22+
public function convert(array $payload): AbstractMailerEvent
23+
{
24+
if (\in_array($payload['type'], ['email.sent', 'email.delivered', 'email.delivery_delayed', 'email.bounced'], true)) {
25+
$name = match ($payload['type']) {
26+
'email.sent' => MailerDeliveryEvent::RECEIVED,
27+
'email.delivered' => MailerDeliveryEvent::DELIVERED,
28+
'email.delivery_delayed' => MailerDeliveryEvent::DEFERRED,
29+
'email.bounced' => MailerDeliveryEvent::BOUNCE,
30+
};
31+
32+
$event = new MailerDeliveryEvent($name, $payload['data']['email_id'], $payload);
33+
} else {
34+
$name = match ($payload['type']) {
35+
'email.clicked' => MailerEngagementEvent::CLICK,
36+
'email.opened' => MailerEngagementEvent::OPEN,
37+
'email.complained' => MailerEngagementEvent::SPAM,
38+
default => throw new ParseException(sprintf('Unsupported event "%s".', $payload['type'])),
39+
};
40+
$event = new MailerEngagementEvent($name, $payload['data']['email_id'], $payload);
41+
}
42+
43+
if (!$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uP', $payload['created_at'])) {
44+
throw new ParseException(sprintf('Invalid date "%s".', $payload['created_at']));
45+
}
46+
47+
$event->setDate($date);
48+
$event->setRecipientEmail(implode(', ', $payload['data']['to']));
49+
$event->setMetadata($payload['data']);
50+
51+
return $event;
52+
}
53+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Mailer\Bridge\Resend\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpClient\MockHttpClient;
16+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
17+
use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendApiTransport;
18+
use Symfony\Component\Mailer\Envelope;
19+
use Symfony\Component\Mailer\Exception\HttpTransportException;
20+
use Symfony\Component\Mailer\Header\MetadataHeader;
21+
use Symfony\Component\Mailer\Header\TagHeader;
22+
use Symfony\Component\Mime\Address;
23+
use Symfony\Component\Mime\Email;
24+
use Symfony\Component\Mime\Part\DataPart;
25+
use Symfony\Contracts\HttpClient\ResponseInterface;
26+
27+
class ResendApiTransportTest extends TestCase
28+
{
29+
/**
30+
* @dataProvider getTransportData
31+
*/
32+
public function testToString(ResendApiTransport $transport, string $expected)
33+
{
34+
$this->assertSame($expected, (string) $transport);
35+
}
36+
37+
public static function getTransportData(): \Generator
38+
{
39+
yield [
40+
new ResendApiTransport('ACCESS_KEY'),
41+
'resend+api://api.resend.com',
42+
];
43+
44+
yield [
45+
(new ResendApiTransport('ACCESS_KEY'))->setHost('example.com'),
46+
'resend+api://example.com',
47+
];
48+
49+
yield [
50+
(new ResendApiTransport('ACCESS_KEY'))->setHost('example.com')->setPort(99),
51+
'resend+api://example.com:99',
52+
];
53+
}
54+
55+
public function testCustomHeader()
56+
{
57+
$params = ['param1' => 'foo', 'param2' => 'bar'];
58+
$json = json_encode(['"custom_header_1' => 'custom_value_1']);
59+
60+
$email = new Email();
61+
$email->getHeaders()
62+
->add(new MetadataHeader('custom', $json))
63+
->add(new TagHeader('TagInHeaders'))
64+
->addTextHeader('templateId', 1)
65+
->addParameterizedHeader('params', 'params', $params)
66+
->addTextHeader('foo', 'bar');
67+
$envelope = new Envelope(new Address('alice@system.com', 'Alice'), [new Address('bob@system.com', 'Bob')]);
68+
69+
$transport = new ResendApiTransport('ACCESS_KEY');
70+
$method = new \ReflectionMethod(ResendApiTransport::class, 'getPayload');
71+
$payload = $method->invoke($transport, $email, $envelope);
72+
73+
$this->assertArrayHasKey('X-Metadata-custom', $payload['headers']);
74+
$this->assertEquals($json, $payload['headers']['X-Metadata-custom']);
75+
76+
$this->assertArrayHasKey('tags', $payload);
77+
$this->assertEquals(['X-Tag' => 'TagInHeaders'], current($payload['tags']));
78+
$this->assertArrayHasKey('templateId', $payload['headers']);
79+
$this->assertEquals('1', $payload['headers']['templateId']);
80+
$this->assertArrayHasKey('params', $payload['headers']);
81+
$this->assertEquals('params; param1=foo; param2=bar', $payload['headers']['params']);
82+
$this->assertArrayHasKey('foo', $payload['headers']);
83+
$this->assertEquals('bar', $payload['headers']['foo']);
84+
}
85+
86+
public function testSendThrowsForErrorResponse()
87+
{
88+
$client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface {
89+
$this->assertSame('POST', $method);
90+
$this->assertSame('https://api.resend.com:8984/emails', $url);
91+
$this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]);
92+
93+
return new JsonMockResponse(['message' => 'i\'m a teapot'], [
94+
'http_code' => 418,
95+
]);
96+
});
97+
98+
$transport = new ResendApiTransport('ACCESS_KEY', $client);
99+
$transport->setPort(8984);
100+
101+
$mail = new Email();
102+
$mail->subject('Hello!')
103+
->to(new Address('saif.gmati@symfony.com', 'Saif Eddin'))
104+
->from(new Address('fabpot@symfony.com', 'Fabien'))
105+
->text('Hello There!');
106+
107+
$this->expectException(HttpTransportException::class);
108+
$this->expectExceptionMessage('Unable to send an email: {"message":"i\'m a teapot"} (code 418).');
109+
$transport->send($mail);
110+
}
111+
112+
public function testSend()
113+
{
114+
$client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface {
115+
$this->assertSame('POST', $method);
116+
$this->assertSame('https://api.resend.com:8984/emails', $url);
117+
$this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]);
118+
119+
return new JsonMockResponse(['id' => 'foobar'], [
120+
'http_code' => 200,
121+
]);
122+
});
123+
124+
$transport = new ResendApiTransport('ACCESS_KEY', $client);
125+
$transport->setPort(8984);
126+
127+
$mail = new Email();
128+
$mail->subject('Hello!')
129+
->to(new Address('tony.stark@marvel.com', 'Tony Stark'))
130+
->from(new Address('fabpot@symfony.com', 'Fabien'))
131+
->text('Hello here!')
132+
->html('Hello there!')
133+
->addCc('foo@bar.fr')
134+
->addBcc('foo@bar.fr')
135+
->addReplyTo('foo@bar.fr')
136+
->addPart(new DataPart('body'));
137+
138+
$message = $transport->send($mail);
139+
140+
$this->assertSame('foobar', $message->getMessageId());
141+
}
142+
143+
/**
144+
* IDN (internationalized domain names) like kältetechnik-xyz.de need to be transformed to ACE
145+
* (ASCII Compatible Encoding) e.g.xn--kltetechnik-xyz-0kb.de, otherwise resend api answers with 400 http code.
146+
*
147+
* @throws \Symfony\Component\Mailer\Exception\TransportExceptionInterface
148+
*/
149+
public function testSendForIdnDomains()
150+
{
151+
$client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface {
152+
$this->assertSame('POST', $method);
153+
$this->assertSame('https://api.resend.com:8984/emails', $url);
154+
$this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]);
155+
156+
$body = json_decode($options['body'], true);
157+
// to
158+
$this->assertSame('kältetechnik@xn--kltetechnik-xyz-0kb.de', $body['to'][0]);
159+
// sender
160+
$this->assertStringContainsString('info@xn--kltetechnik-xyz-0kb.de', $body['from']);
161+
$this->assertStringContainsString('Kältetechnik Xyz', $body['from']);
162+
163+
return new JsonMockResponse(['id' => 'foobar'], [
164+
'http_code' => 200,
165+
]);
166+
});
167+
168+
$transport = new ResendApiTransport('ACCESS_KEY', $client);
169+
$transport->setPort(8984);
170+
171+
$mail = new Email();
172+
$mail->subject('Hello!')
173+
->to(new Address('kältetechnik@kältetechnik-xyz.de', 'Kältetechnik Xyz'))
174+
->from(new Address('info@kältetechnik-xyz.de', 'Kältetechnik Xyz'))
175+
->text('Hello here!')
176+
->html('Hello there!');
177+
178+
$message = $transport->send($mail);
179+
180+
$this->assertSame('foobar', $message->getMessageId());
181+
}
182+
}

0 commit comments

Comments
 (0)
0