diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 9c9446fb03ce0..cf8bb8dfc5701 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2572,6 +2572,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co MailerBridge\MailPace\Transport\MailPaceTransportFactory::class => 'mailer.transport_factory.mailpace', MailerBridge\Mailchimp\Transport\MandrillTransportFactory::class => 'mailer.transport_factory.mailchimp', MailerBridge\Postmark\Transport\PostmarkTransportFactory::class => 'mailer.transport_factory.postmark', + MailerBridge\Resend\Transport\ResendTransportFactory::class => 'mailer.transport_factory.resend', MailerBridge\Scaleway\Transport\ScalewayTransportFactory::class => 'mailer.transport_factory.scaleway', MailerBridge\Sendgrid\Transport\SendgridTransportFactory::class => 'mailer.transport_factory.sendgrid', MailerBridge\Amazon\Transport\SesTransportFactory::class => 'mailer.transport_factory.amazon', @@ -2591,6 +2592,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co MailerBridge\Mailgun\Webhook\MailgunRequestParser::class => 'mailer.webhook.request_parser.mailgun', MailerBridge\Mailjet\Webhook\MailjetRequestParser::class => 'mailer.webhook.request_parser.mailjet', MailerBridge\Postmark\Webhook\PostmarkRequestParser::class => 'mailer.webhook.request_parser.postmark', + MailerBridge\Resend\Webhook\ResendRequestParser::class => 'mailer.webhook.request_parser.resend', MailerBridge\Sendgrid\Webhook\SendgridRequestParser::class => 'mailer.webhook.request_parser.sendgrid', ]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php index f95fc6d640c12..5434b4c56e6b2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php @@ -22,6 +22,7 @@ use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; use Symfony\Component\Mailer\Bridge\MailPace\Transport\MailPaceTransportFactory; use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory; +use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendTransportFactory; use Symfony\Component\Mailer\Bridge\Scaleway\Transport\ScalewayTransportFactory; use Symfony\Component\Mailer\Bridge\Sendgrid\Transport\SendgridTransportFactory; use Symfony\Component\Mailer\Transport\AbstractTransportFactory; @@ -55,6 +56,7 @@ 'native' => NativeTransportFactory::class, 'null' => NullTransportFactory::class, 'postmark' => PostmarkTransportFactory::class, + 'resend' => ResendTransportFactory::class, 'scaleway' => ScalewayTransportFactory::class, 'sendgrid' => SendgridTransportFactory::class, 'sendmail' => SendmailTransportFactory::class, diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php index bb487b36c0f21..5c1014713112b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php @@ -19,6 +19,8 @@ use Symfony\Component\Mailer\Bridge\Mailjet\Webhook\MailjetRequestParser; use Symfony\Component\Mailer\Bridge\Postmark\RemoteEvent\PostmarkPayloadConverter; use Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser; +use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter; +use Symfony\Component\Mailer\Bridge\Resend\Webhook\ResendRequestParser; use Symfony\Component\Mailer\Bridge\Sendgrid\RemoteEvent\SendgridPayloadConverter; use Symfony\Component\Mailer\Bridge\Sendgrid\Webhook\SendgridRequestParser; @@ -44,6 +46,11 @@ ->args([service('mailer.payload_converter.postmark')]) ->alias(PostmarkRequestParser::class, 'mailer.webhook.request_parser.postmark') + ->set('mailer.payload_converter.resend', ResendPayloadConverter::class) + ->set('mailer.webhook.request_parser.resend', ResendRequestParser::class) + ->args([service('mailer.payload_converter.resend')]) + ->alias(ResendRequestParser::class, 'mailer.webhook.request_parser.resend') + ->set('mailer.payload_converter.sendgrid', SendgridPayloadConverter::class) ->set('mailer.webhook.request_parser.sendgrid', SendgridRequestParser::class) ->args([service('mailer.payload_converter.sendgrid')]) diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/.gitattributes b/src/Symfony/Component/Mailer/Bridge/Resend/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/.gitignore b/src/Symfony/Component/Mailer/Bridge/Resend/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Resend/CHANGELOG.md new file mode 100644 index 0000000000000..5be39cbeeb951 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.1 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/LICENSE b/src/Symfony/Component/Mailer/Bridge/Resend/LICENSE new file mode 100644 index 0000000000000..e374a5c8339d3 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/README.md b/src/Symfony/Component/Mailer/Bridge/Resend/README.md new file mode 100644 index 0000000000000..fa51476f7a673 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/README.md @@ -0,0 +1,25 @@ +Resend Bridge +============ + +Provides Resend integration for Symfony Mailer. + +Configuration example: + +```env +# SMTP +MAILER_DSN=resend+smtp://resend:API_KEY@default + +# API +MAILER_DSN=resend+api://API_KEY@default +``` + +where: + - `API_KEY` is your Resend API Key + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/RemoteEvent/ResendPayloadConverter.php b/src/Symfony/Component/Mailer/Bridge/Resend/RemoteEvent/ResendPayloadConverter.php new file mode 100644 index 0000000000000..3579e7b3d6ec3 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/RemoteEvent/ResendPayloadConverter.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Resend\RemoteEvent; + +use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent; +use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent; +use Symfony\Component\RemoteEvent\Event\Mailer\MailerEngagementEvent; +use Symfony\Component\RemoteEvent\Exception\ParseException; +use Symfony\Component\RemoteEvent\PayloadConverterInterface; + +final class ResendPayloadConverter implements PayloadConverterInterface +{ + public function convert(array $payload): AbstractMailerEvent + { + if (\in_array($payload['type'], ['email.sent', 'email.delivered', 'email.delivery_delayed', 'email.bounced'], true)) { + $name = match ($payload['type']) { + 'email.sent' => MailerDeliveryEvent::RECEIVED, + 'email.delivered' => MailerDeliveryEvent::DELIVERED, + 'email.delivery_delayed' => MailerDeliveryEvent::DEFERRED, + 'email.bounced' => MailerDeliveryEvent::BOUNCE, + }; + + $event = new MailerDeliveryEvent($name, $payload['data']['email_id'], $payload); + } else { + $name = match ($payload['type']) { + 'email.clicked' => MailerEngagementEvent::CLICK, + 'email.opened' => MailerEngagementEvent::OPEN, + 'email.complained' => MailerEngagementEvent::SPAM, + default => throw new ParseException(sprintf('Unsupported event "%s".', $payload['type'])), + }; + $event = new MailerEngagementEvent($name, $payload['data']['email_id'], $payload); + } + + if (!$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uP', $payload['created_at'])) { + throw new ParseException(sprintf('Invalid date "%s".', $payload['created_at'])); + } + + $event->setDate($date); + $event->setRecipientEmail(implode(', ', $payload['data']['to'])); + $event->setMetadata($payload['data']); + + return $event; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendApiTransportTest.php new file mode 100644 index 0000000000000..1cdadd3df95c9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendApiTransportTest.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Resend\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendApiTransport; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\HttpTransportException; +use Symfony\Component\Mailer\Header\MetadataHeader; +use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class ResendApiTransportTest extends TestCase +{ + /** + * @dataProvider getTransportData + */ + public function testToString(ResendApiTransport $transport, string $expected) + { + $this->assertSame($expected, (string) $transport); + } + + public static function getTransportData(): \Generator + { + yield [ + new ResendApiTransport('ACCESS_KEY'), + 'resend+api://api.resend.com', + ]; + + yield [ + (new ResendApiTransport('ACCESS_KEY'))->setHost('example.com'), + 'resend+api://example.com', + ]; + + yield [ + (new ResendApiTransport('ACCESS_KEY'))->setHost('example.com')->setPort(99), + 'resend+api://example.com:99', + ]; + } + + public function testCustomHeader() + { + $params = ['param1' => 'foo', 'param2' => 'bar']; + $json = json_encode(['custom_header_1' => 'custom_value_1']); + + $email = new Email(); + $email->getHeaders() + ->add(new MetadataHeader('custom', $json)) + ->add(new TagHeader('TagInHeaders')) + ->addTextHeader('templateId', 1) + ->addParameterizedHeader('params', 'params', $params) + ->addTextHeader('foo', 'bar'); + $envelope = new Envelope(new Address('alice@system.com', 'Alice'), [new Address('bob@system.com', 'Bob')]); + + $transport = new ResendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(ResendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('X-Metadata-custom', $payload['headers']); + $this->assertEquals($json, $payload['headers']['X-Metadata-custom']); + $this->assertArrayHasKey('tags', $payload); + $this->assertEquals(['X-Tag' => 'TagInHeaders'], current($payload['tags'])); + $this->assertArrayHasKey('templateId', $payload['headers']); + $this->assertEquals('1', $payload['headers']['templateId']); + $this->assertArrayHasKey('params', $payload['headers']); + $this->assertEquals('params; param1=foo; param2=bar', $payload['headers']['params']); + $this->assertArrayHasKey('foo', $payload['headers']); + $this->assertEquals('bar', $payload['headers']['foo']); + } + + public function testSendThrowsForErrorResponse() + { + $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.resend.com:8984/emails', $url); + $this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]); + + return new JsonMockResponse(['message' => 'i\'m a teapot'], [ + 'http_code' => 418, + ]); + }); + + $transport = new ResendApiTransport('ACCESS_KEY', $client); + $transport->setPort(8984); + + $mail = new Email(); + $mail->subject('Hello!') + ->to(new Address('tony.stark@marvel.com', 'Tony Stark')) + ->from(new Address('fabpot@symfony.com', 'Fabien')) + ->text('Hello There!'); + + $this->expectException(HttpTransportException::class); + $this->expectExceptionMessage('Unable to send an email: {"message":"i\'m a teapot"} (code 418).'); + $transport->send($mail); + } + + public function testSend() + { + $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.resend.com:8984/emails', $url); + $this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]); + + return new JsonMockResponse(['id' => 'foobar'], [ + 'http_code' => 200, + ]); + }); + + $transport = new ResendApiTransport('ACCESS_KEY', $client); + $transport->setPort(8984); + + $mail = new Email(); + $mail->subject('Hello!') + ->to(new Address('tony.stark@marvel.com', 'Tony Stark')) + ->from(new Address('fabpot@symfony.com', 'Fabien')) + ->text('Hello here!') + ->html('Hello there!') + ->addCc('foo@bar.fr') + ->addBcc('foo@bar.fr') + ->addReplyTo('foo@bar.fr') + ->addPart(new DataPart('body')); + + $message = $transport->send($mail); + + $this->assertSame('foobar', $message->getMessageId()); + } + + /** + * IDN (internationalized domain names) like kältetechnik-xyz.de need to be transformed to ACE + * (ASCII Compatible Encoding) e.g.xn--kltetechnik-xyz-0kb.de, otherwise resend api answers with 400 http code. + */ + public function testSendForIdnDomains() + { + $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.resend.com:8984/emails', $url); + $this->assertStringContainsString('Accept: */*', $options['headers'][2] ?? $options['request_headers'][1]); + + $body = json_decode($options['body'], true); + // to + $this->assertSame('kältetechnik@xn--kltetechnik-xyz-0kb.de', $body['to'][0]); + // sender + $this->assertStringContainsString('info@xn--kltetechnik-xyz-0kb.de', $body['from']); + $this->assertStringContainsString('Kältetechnik Xyz', $body['from']); + + return new JsonMockResponse(['id' => 'foobar'], [ + 'http_code' => 200, + ]); + }); + + $transport = new ResendApiTransport('ACCESS_KEY', $client); + $transport->setPort(8984); + + $mail = new Email(); + $mail->subject('Hello!') + ->to(new Address('kältetechnik@kältetechnik-xyz.de', 'Kältetechnik Xyz')) + ->from(new Address('info@kältetechnik-xyz.de', 'Kältetechnik Xyz')) + ->text('Hello here!') + ->html('Hello there!'); + + $message = $transport->send($mail); + + $this->assertSame('foobar', $message->getMessageId()); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendTransportFactoryTest.php new file mode 100644 index 0000000000000..56c7ea3921c7b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/ResendTransportFactoryTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Resend\Tests; + +use Psr\Log\NullLogger; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendApiTransport; +use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendSmtpTransport; +use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendTransportFactory; +use Symfony\Component\Mailer\Test\TransportFactoryTestCase; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; + +class ResendTransportFactoryTest extends TransportFactoryTestCase +{ + public function getFactory(): TransportFactoryInterface + { + return new ResendTransportFactory(null, new MockHttpClient(), new NullLogger()); + } + + public static function supportsProvider(): iterable + { + yield [ + new Dsn('resend', 'default'), + true, + ]; + + yield [ + new Dsn('resend+smtp', 'default'), + true, + ]; + + yield [ + new Dsn('resend+smtp', 'example.com'), + true, + ]; + + yield [ + new Dsn('resend+api', 'default'), + true, + ]; + } + + public static function createProvider(): iterable + { + yield [ + new Dsn('resend', 'default', self::USER, self::PASSWORD), + new ResendSmtpTransport(self::PASSWORD, null, new NullLogger()), + ]; + + yield [ + new Dsn('resend+smtp', 'default', self::USER, self::PASSWORD), + new ResendSmtpTransport(self::PASSWORD, null, new NullLogger()), + ]; + + yield [ + new Dsn('resend+smtp', 'default', self::USER, self::PASSWORD, 465), + new ResendSmtpTransport(self::PASSWORD, null, new NullLogger()), + ]; + + yield [ + new Dsn('resend+api', 'default', self::USER), + new ResendApiTransport(self::USER, new MockHttpClient(), null, new NullLogger()), + ]; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield [ + new Dsn('resend+foo', 'default', self::USER, self::PASSWORD), + 'The "resend+foo" scheme is not supported; supported schemes for mailer "resend" are: "resend", "resend+smtp", "resend+api".', + ]; + } + + public static function incompleteDsnProvider(): iterable + { + yield [new Dsn('resend+smtp', 'default', self::USER)]; + + yield [new Dsn('resend+api', 'default')]; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendApiTransport.php new file mode 100644 index 0000000000000..283db2cd8106d --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendApiTransport.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Resend\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\HttpTransportException; +use Symfony\Component\Mailer\Exception\InvalidArgumentException; +use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractApiTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Mathieu Santostefano + */ +final class ResendApiTransport extends AbstractApiTransport +{ + public function __construct( + #[\SensitiveParameter] private readonly string $apiKey, + HttpClientInterface $client = null, + EventDispatcherInterface $dispatcher = null, + LoggerInterface $logger = null, + ) { + parent::__construct($client, $dispatcher, $logger); + } + + public function __toString(): string + { + return sprintf('resend+api://%s', $this->getEndpoint()); + } + + protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface + { + $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/emails', [ + 'json' => $this->getPayload($email, $envelope), + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + ], + ]); + + try { + $statusCode = $response->getStatusCode(); + $result = $response->toArray(false); + } catch (DecodingExceptionInterface) { + throw new HttpTransportException('Unable to send an email: '.$response->getContent(false).sprintf(' (code %d).', $statusCode), $response); + } catch (TransportExceptionInterface $e) { + throw new HttpTransportException('Could not reach the remote Resend server.', $response, 0, $e); + } + + if (200 !== $statusCode) { + throw new HttpTransportException('Unable to send an email: '.$response->getContent(false).sprintf(' (code %d).', $statusCode), $response); + } + + $sentMessage->setMessageId($result['id']); + + return $response; + } + + /** + * @param Address[] $addresses + * + * @return list + */ + private function formatAddresses(array $addresses): array + { + $formattedAddresses = []; + foreach ($addresses as $address) { + $formattedAddresses[] = $address->getEncodedAddress(); + } + + if (\count($formattedAddresses) > 50) { + throw new InvalidArgumentException('Resend API does not support more than 50 recipients.'); + } + + return $formattedAddresses; + } + + private function getPayload(Email $email, Envelope $envelope): array + { + $payload = [ + 'from' => $this->formatAddress($envelope->getSender()), + 'to' => $this->formatAddresses($this->getRecipients($email, $envelope)), + 'subject' => $email->getSubject(), + ]; + if ($attachements = $this->prepareAttachments($email)) { + $payload['attachments'] = $attachements; + } + if ($emails = $email->getReplyTo()) { + $payload['reply_to'] = current($this->formatAddresses($emails)); + } + if ($emails = $email->getCc()) { + $payload['cc'] = $this->formatAddresses($emails); + } + if ($emails = $email->getBcc()) { + $payload['bcc'] = $this->formatAddresses($emails); + } + if ($email->getTextBody()) { + $payload['text'] = $email->getTextBody(); + } + if ($email->getHtmlBody()) { + $payload['html'] = $email->getHtmlBody(); + } + if ($headersAndTags = $this->prepareHeadersAndTags($email->getHeaders())) { + $payload = array_merge($payload, $headersAndTags); + } + + return $payload; + } + + private function prepareAttachments(Email $email): array + { + $attachments = []; + foreach ($email->getAttachments() as $attachment) { + $attachments[] = [ + 'filename' => $attachment->getPreparedHeaders()->getHeaderParameter('Content-Disposition', 'filename'), + 'content' => str_replace("\r\n", '', $attachment->bodyToString()), + ]; + } + + return $attachments; + } + + private function prepareHeadersAndTags(Headers $headers): array + { + $headersAndTags = []; + $headersToBypass = ['from', 'to', 'cc', 'bcc', 'subject', 'reply_to']; + foreach ($headers->all() as $name => $header) { + if (\in_array($name, $headersToBypass, true)) { + continue; + } + + if ($header instanceof TagHeader) { + $headersAndTags['tags'][] = [$header->getName() => $header->getValue()]; + + continue; + } + + $headersAndTags['headers'][$header->getName()] = $header->getBodyAsString(); + } + + return $headersAndTags; + } + + private function formatAddress(Address $address): string + { + $formattedAddress = $address->getEncodedAddress(); + + if ($address->getName()) { + $formattedAddress = $address->getName().' <'.$formattedAddress.'>'; + } + + return $formattedAddress; + } + + private function getEndpoint(): ?string + { + return ($this->host ?: 'api.resend.com').($this->port ? ':'.$this->port : ''); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendSmtpTransport.php b/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendSmtpTransport.php new file mode 100644 index 0000000000000..243b913de523a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendSmtpTransport.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Resend\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +/** + * @author Mathieu Santostefano + */ +final class ResendSmtpTransport extends EsmtpTransport +{ + public function __construct(#[\SensitiveParameter] string $password, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + parent::__construct('smtp.resend.com', 465, true, $dispatcher, $logger); + + $this->setUsername('resend'); + $this->setPassword($password); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendTransportFactory.php new file mode 100644 index 0000000000000..eba0ba0fa0a76 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendTransportFactory.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Resend\Transport; + +use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; +use Symfony\Component\Mailer\Transport\AbstractTransportFactory; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * @author Mathieu Santostefano + */ +final class ResendTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + return match ($dsn->getScheme()) { + 'resend', 'resend+smtp' => new ResendSmtpTransport($this->getPassword($dsn), $this->dispatcher, $this->logger), + 'resend+api' => (new ResendApiTransport($this->getUser($dsn), $this->client, $this->dispatcher, $this->logger)) + ->setHost('default' === $dsn->getHost() ? null : $dsn->getHost()) + ->setPort($dsn->getPort()), + default => throw new UnsupportedSchemeException($dsn, 'resend', $this->getSupportedSchemes()), + }; + } + + protected function getSupportedSchemes(): array + { + return ['resend', 'resend+smtp', 'resend+api']; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Webhook/ResendRequestParser.php b/src/Symfony/Component/Mailer/Bridge/Resend/Webhook/ResendRequestParser.php new file mode 100644 index 0000000000000..b5ed40f1d85cd --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Webhook/ResendRequestParser.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Resend\Webhook; + +use Symfony\Component\HttpFoundation\ChainRequestMatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcherInterface; +use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter; +use Symfony\Component\RemoteEvent\Event\Mailer\AbstractMailerEvent; +use Symfony\Component\RemoteEvent\Exception\ParseException; +use Symfony\Component\Webhook\Client\AbstractRequestParser; +use Symfony\Component\Webhook\Exception\RejectWebhookException; + +final class ResendRequestParser extends AbstractRequestParser +{ + public function __construct( + private readonly ResendPayloadConverter $converter, + ) { + } + + protected function getRequestMatcher(): RequestMatcherInterface + { + return new ChainRequestMatcher([ + new MethodRequestMatcher('POST'), + new SchemeRequestMatcher('https'), + new IsJsonRequestMatcher(), + ]); + } + + protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?AbstractMailerEvent + { + $content = $request->toArray(); + if ( + !isset($content['type']) + || !isset($content['created_at']) + || !isset($content['data']) + || !isset($content['data']['created_at']) + || !isset($content['data']['email_id']) + || !isset($content['data']['from']) + || !isset($content['data']['to']) + || !isset($content['data']['subject']) + ) { + throw new RejectWebhookException(406, 'Payload is malformed.'); + } + + try { + return $this->converter->convert($content); + } catch (ParseException $e) { + throw new RejectWebhookException(406, $e->getMessage(), $e); + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/composer.json b/src/Symfony/Component/Mailer/Bridge/Resend/composer.json new file mode 100644 index 0000000000000..4c3d69ae96b6b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/resend-mailer", + "type": "symfony-mailer-bridge", + "description": "Symfony Resend Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Mathieu Santostefano", + "homepage": "https://github.com/welcoMattic" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/mailer": "^6.4|^7.0" + }, + "require-dev": { + "symfony/http-client": "^6.4|^7.0", + "symfony/webhook": "^6.4|^7.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Resend\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Resend/phpunit.xml.dist new file mode 100644 index 0000000000000..bd5f5f35442ec --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Resend/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + +