Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit df72253

Browse files
committed
Add Resend Mailer bridge
1 parent d4b1992 commit df72253

File tree

17 files changed

+764
-0
lines changed

17 files changed

+764
-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 <[email protected]>
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: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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('[email protected]', 'Alice'), [new Address('[email protected]', '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+
$this->assertArrayHasKey('tags', $payload);
76+
$this->assertEquals(['X-Tag' => 'TagInHeaders'], current($payload['tags']));
77+
$this->assertArrayHasKey('templateId', $payload['headers']);
78+
$this->assertEquals('1', $payload['headers']['templateId']);
79+
$this->assertArrayHasKey('params', $payload['headers']);
80+
$this->assertEquals('params; param1=foo; param2=bar', $payload['headers']['params']);
81+
$this->assertArrayHasKey('foo', $payload['headers']);
82+
$this->assertEquals('bar', $payload['headers']['foo']);
83+
}
84+
85+
public function testSendThrowsForErrorResponse()
86+
{
87+
$client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface {
88+
$this->assertSame('POST', $method);
89+
$this->assertSame('https://api.resend.com:8984/emails', $url);
90+
$this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]);
91+
92+
return new JsonMockResponse(['message' => 'i\'m a teapot'], [
93+
'http_code' => 418,
94+
]);
95+
});
96+
97+
$transport = new ResendApiTransport('ACCESS_KEY', $client);
98+
$transport->setPort(8984);
99+
100+
$mail = new Email();
101+
$mail->subject('Hello!')
102+
->to(new Address('[email protected]', 'Tony Stark'))
103+
->from(new Address('[email protected]', 'Fabien'))
104+
->text('Hello There!');
105+
106+
$this->expectException(HttpTransportException::class);
107+
$this->expectExceptionMessage('Unable to send an email: {"message":"i\'m a teapot"} (code 418).');
108+
$transport->send($mail);
109+
}
110+
111+
public function testSend()
112+
{
113+
$client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface {
114+
$this->assertSame('POST', $method);
115+
$this->assertSame('https://api.resend.com:8984/emails', $url);
116+
$this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]);
117+
118+
return new JsonMockResponse(['id' => 'foobar'], [
119+
'http_code' => 200,
120+
]);
121+
});
122+
123+
$transport = new ResendApiTransport('ACCESS_KEY', $client);
124+
$transport->setPort(8984);
125+
126+
$mail = new Email();
127+
$mail->subject('Hello!')
128+
->to(new Address('[email protected]', 'Tony Stark'))
129+
->from(new Address('[email protected]', 'Fabien'))
130+
->text('Hello here!')
131+
->html('Hello there!')
132+
->addCc('[email protected]')
133+
->addBcc('[email protected]')
134+
->addReplyTo('[email protected]')
135+
->addPart(new DataPart('body'));
136+
137+
$message = $transport->send($mail);
138+
139+
$this->assertSame('foobar', $message->getMessageId());
140+
}
141+
142+
/**
143+
* IDN (internationalized domain names) like kältetechnik-xyz.de need to be transformed to ACE
144+
* (ASCII Compatible Encoding) e.g.xn--kltetechnik-xyz-0kb.de, otherwise resend api answers with 400 http code.
145+
*/
146+
public function testSendForIdnDomains()
147+
{
148+
$client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface {
149+
$this->assertSame('POST', $method);
150+
$this->assertSame('https://api.resend.com:8984/emails', $url);
151+
$this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]);
152+
153+
$body = json_decode($options['body'], true);
154+
// to
155+
$this->assertSame('[email protected]', $body['to'][0]);
156+
// sender
157+
$this->assertStringContainsString('[email protected]', $body['from']);
158+
$this->assertStringContainsString('Kältetechnik Xyz', $body['from']);
159+
160+
return new JsonMockResponse(['id' => 'foobar'], [
161+
'http_code' => 200,
162+
]);
163+
});
164+
165+
$transport = new ResendApiTransport('ACCESS_KEY', $client);
166+
$transport->setPort(8984);
167+
168+
$mail = new Email();
169+
$mail->subject('Hello!')
170+
->to(new Address('kältetechnik@kältetechnik-xyz.de', 'Kältetechnik Xyz'))
171+
->from(new Address('info@kältetechnik-xyz.de', 'Kältetechnik Xyz'))
172+
->text('Hello here!')
173+
->html('Hello there!');
174+
175+
$message = $transport->send($mail);
176+
177+
$this->assertSame('foobar', $message->getMessageId());
178+
}
179+
}

0 commit comments

Comments
 (0)