diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 942358181179a..7119002cbee3b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2620,6 +2620,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co MailerBridge\MailerSend\Transport\MailerSendTransportFactory::class => 'mailer.transport_factory.mailersend', MailerBridge\Mailgun\Transport\MailgunTransportFactory::class => 'mailer.transport_factory.mailgun', MailerBridge\Mailjet\Transport\MailjetTransportFactory::class => 'mailer.transport_factory.mailjet', + MailerBridge\Mailomat\Transport\MailomatTransportFactory::class => 'mailer.transport_factory.mailomat', 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', @@ -2643,6 +2644,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co MailerBridge\MailerSend\Webhook\MailerSendRequestParser::class => 'mailer.webhook.request_parser.mailersend', MailerBridge\Mailgun\Webhook\MailgunRequestParser::class => 'mailer.webhook.request_parser.mailgun', MailerBridge\Mailjet\Webhook\MailjetRequestParser::class => 'mailer.webhook.request_parser.mailjet', + MailerBridge\Mailomat\Webhook\MailomatRequestParser::class => 'mailer.webhook.request_parser.mailomat', 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 5434b4c56e6b2..bdcd7e9c691c9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php @@ -20,6 +20,7 @@ use Symfony\Component\Mailer\Bridge\MailerSend\Transport\MailerSendTransportFactory; use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; +use Symfony\Component\Mailer\Bridge\Mailomat\Transport\MailomatTransportFactory; use Symfony\Component\Mailer\Bridge\MailPace\Transport\MailPaceTransportFactory; use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory; use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendTransportFactory; @@ -52,6 +53,7 @@ 'mailersend' => MailerSendTransportFactory::class, 'mailgun' => MailgunTransportFactory::class, 'mailjet' => MailjetTransportFactory::class, + 'mailomat' => MailomatTransportFactory::class, 'mailpace' => MailPaceTransportFactory::class, 'native' => NativeTransportFactory::class, 'null' => NullTransportFactory::class, diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php index f9d2b9686ff03..64020c1b1bf8a 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\Mailgun\Webhook\MailgunRequestParser; use Symfony\Component\Mailer\Bridge\Mailjet\RemoteEvent\MailjetPayloadConverter; use Symfony\Component\Mailer\Bridge\Mailjet\Webhook\MailjetRequestParser; +use Symfony\Component\Mailer\Bridge\Mailomat\RemoteEvent\MailomatPayloadConverter; +use Symfony\Component\Mailer\Bridge\Mailomat\Webhook\MailomatRequestParser; use Symfony\Component\Mailer\Bridge\Postmark\RemoteEvent\PostmarkPayloadConverter; use Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser; use Symfony\Component\Mailer\Bridge\Resend\RemoteEvent\ResendPayloadConverter; @@ -48,6 +50,11 @@ ->args([service('mailer.payload_converter.mailjet')]) ->alias(MailjetRequestParser::class, 'mailer.webhook.request_parser.mailjet') + ->set('mailer.payload_converter.mailomat', MailomatPayloadConverter::class) + ->set('mailer.webhook.request_parser.mailomat', MailomatRequestParser::class) + ->args([service('mailer.payload_converter.mailomat')]) + ->alias(MailomatRequestParser::class, 'mailer.webhook.request_parser.mailomat') + ->set('mailer.payload_converter.postmark', PostmarkPayloadConverter::class) ->set('mailer.webhook.request_parser.postmark', PostmarkRequestParser::class) ->args([service('mailer.payload_converter.postmark')]) diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/.gitattributes b/src/Symfony/Component/Mailer/Bridge/Mailomat/.gitattributes new file mode 100644 index 0000000000000..14c3c35940427 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/.gitignore b/src/Symfony/Component/Mailer/Bridge/Mailomat/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Mailomat/CHANGELOG.md new file mode 100644 index 0000000000000..00149ea5ac6f5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.2 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/LICENSE b/src/Symfony/Component/Mailer/Bridge/Mailomat/LICENSE new file mode 100644 index 0000000000000..e374a5c8339d3 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/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/Mailomat/README.md b/src/Symfony/Component/Mailer/Bridge/Mailomat/README.md new file mode 100644 index 0000000000000..c9d76224bceea --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/README.md @@ -0,0 +1,71 @@ +Mailomat Bridge +=============== + +Provides [Mailomat](https://mailomat.swiss) integration for Symfony Mailer. + +Mailer +------- + +Configuration example: + +```env +# .env.local + +# SMTP +MAILER_DSN=mailomat+smtp://USERNAME:PASSWORD@default + +# API +MAILER_DSN=mailomat+api://KEY@default +``` + +Where: + - `USERNAME` is your Mailomat SMTP username (must use your full email address) + - `PASSWORD` is your Mailomat SMTP password + - `KEY` is your Mailomat API key + + +Webhook +------- + +Create a route: + +```yaml +framework: + webhook: + routing: + mailomat: + service: mailer.webhook.request_parser.mailomat + secret: '%env(WEBHOOK_MAILOMAT_SECRET)%' +``` + +The configuration: + +```env +# .env.local + +WEBHOOK_MAILOMAT_SECRET=your-mailomat-webhook-secret +``` + +And a consumer: + +```php +#[\Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer(name: 'mailomat')] +class MailomatConsumer implements ConsumerInterface +{ + public function consume(AbstractMailerEvent $event): void + { + // your code + } +} +``` + +Where: +- `WEBHOOK_MAILOMAT_SECRET` is your Mailomat Webhook secret + +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/Mailomat/RemoteEvent/MailomatPayloadConverter.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/RemoteEvent/MailomatPayloadConverter.php new file mode 100644 index 0000000000000..37b4fe6c9ef8a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/RemoteEvent/MailomatPayloadConverter.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailomat\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 MailomatPayloadConverter implements PayloadConverterInterface +{ + public function convert(array $payload): AbstractMailerEvent + { + if (\in_array($payload['eventType'], ['accepted', 'not_accepted', 'delivered', 'failure_tmp', 'failure_perm'], true)) { + $name = match ($payload['eventType']) { + 'accepted' => MailerDeliveryEvent::RECEIVED, + 'not_accepted' => MailerDeliveryEvent::DROPPED, + 'delivered' => MailerDeliveryEvent::DELIVERED, + 'failure_tmp' => MailerDeliveryEvent::DEFERRED, + 'failure_perm' => MailerDeliveryEvent::BOUNCE, + }; + $event = new MailerDeliveryEvent($name, $payload['id'], $payload); + if (isset($payload['payload']['reason'])) { + $event->setReason($payload['payload']['reason']); + } + } else { + $name = match ($payload['eventType']) { + 'opened' => MailerEngagementEvent::OPEN, + 'clicked' => MailerEngagementEvent::CLICK, + default => throw new ParseException(sprintf('Unsupported event "%s".', $payload['eventType'])), + }; + $event = new MailerEngagementEvent($name, $payload['id'], $payload); + } + + if (!$date = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $payload['occurredAt'])) { + throw new ParseException(sprintf('Invalid date "%s".', $payload['occurredAt'])); + } + + $event->setDate($date); + $event->setRecipientEmail($payload['recipient']); + + return $event; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Transport/MailomatApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Transport/MailomatApiTransportTest.php new file mode 100644 index 0000000000000..488d9da132074 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Transport/MailomatApiTransportTest.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailomat\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Mailer\Bridge\Mailomat\Transport\MailomatApiTransport; +use Symfony\Component\Mailer\Exception\HttpTransportException; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class MailomatApiTransportTest extends TestCase +{ + private const KEY = 'K3Y'; + + /** + * @dataProvider getTransportData + */ + public function testToString(MailomatApiTransport $transport, string $expected): void + { + $this->assertSame($expected, (string) $transport); + } + + public static function getTransportData(): iterable + { + yield [ + new MailomatApiTransport(self::KEY), + 'mailomat+api://api.mailomat.swiss', + ]; + + yield [ + (new MailomatApiTransport(self::KEY))->setHost('example.com'), + 'mailomat+api://example.com', + ]; + + yield [ + (new MailomatApiTransport(self::KEY))->setHost('example.com')->setPort(99), + 'mailomat+api://example.com:99', + ]; + } + + public function testSend() + { + $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.mailomat.swiss/message', $url); + $this->assertContains('Authorization: Bearer '.self::KEY, $options['headers']); + $this->assertContains('Content-Type: application/json', $options['headers']); + $this->assertContains('Accept: application/json', $options['headers']); + + $body = json_decode($options['body'], true); + $this->assertSame('from@mailomat.swiss', $body['from']['email']); + $this->assertSame('From Doe', $body['from']['name']); + + $this->assertSame('to@mailomat.swiss', $body['to'][0]['email']); + $this->assertSame('To Doe', $body['to'][0]['name']); + $this->assertSame('to-simple@mailomat.swiss', $body['to'][1]['email']); + + $this->assertSame('cc@mailomat.swiss', $body['cc'][0]['email']); + $this->assertSame('Cc Doe', $body['cc'][0]['name']); + $this->assertSame('cc-simple@mailomat.swiss', $body['cc'][1]['email']); + + $this->assertSame('bcc@mailomat.swiss', $body['bcc'][0]['email']); + $this->assertSame('Bcc Doe', $body['bcc'][0]['name']); + $this->assertSame('bcc-simple@mailomat.swiss', $body['bcc'][1]['email']); + + $this->assertSame('replyto@mailomat.swiss', $body['replyTo'][0]['email']); + $this->assertSame('ReplyTo Doe', $body['replyTo'][0]['name']); + $this->assertSame('replyto-simple@mailomat.swiss', $body['replyTo'][1]['email']); + + $this->assertSame('Hello!', $body['subject']); + $this->assertSame('Hello There!', $body['text']); + $this->assertSame('

Hello There!

', $body['html']); + + return new JsonMockResponse(['messageUuid' => 'foobar'], [ + 'http_code' => 202, + ]); + }); + + $transport = new MailomatApiTransport(self::KEY, $client); + + $mail = new Email(); + $mail->subject('Hello!') + ->from(new Address('from@mailomat.swiss', 'From Doe')) + ->to(new Address('to@mailomat.swiss', 'To Doe'), 'to-simple@mailomat.swiss') + ->cc(new Address('cc@mailomat.swiss', 'Cc Doe'), 'cc-simple@mailomat.swiss') + ->bcc(new Address('bcc@mailomat.swiss', 'Bcc Doe'), 'bcc-simple@mailomat.swiss') + ->replyTo(new Address('replyto@mailomat.swiss', 'ReplyTo Doe'), 'replyto-simple@mailomat.swiss') + ->text('Hello There!') + ->html('

Hello There!

'); + + $message = $transport->send($mail); + + $this->assertSame('foobar', $message->getMessageId()); + } + + public function testSendThrowsForErrorResponse() + { + $client = new MockHttpClient(static fn (string $method, string $url, array $options): ResponseInterface => new JsonMockResponse( + [ + 'status' => 422, + 'violations' => [ + [ + 'propertyPath' => '', + 'message' => 'You must specify either text or html', + ], + [ + 'propertyPath' => 'from', + 'message' => 'Dieser Wert sollte nicht null sein.', + ], + [ + 'propertyPath' => 'to[1].email', + 'message' => 'Dieser Wert sollte nicht leer sein.', + ], + [ + 'propertyPath' => 'subject', + 'message' => 'Dieser Wert sollte nicht leer sein.', + ], + ], + ], [ + 'http_code' => 422, + ])); + $transport = new MailomatApiTransport(self::KEY, $client); + $transport->setPort(8984); + + $mail = new Email(); + $mail->subject('Hello!') + ->to(new Address('to@mailomat.swiss', 'To Doe')) + ->from(new Address('from@mailomat.swiss', 'From Doe')) + ->text('Hello There!'); + + $this->expectException(HttpTransportException::class); + $this->expectExceptionMessage('Unable to send an email: You must specify either text or html; (from) Dieser Wert sollte nicht null sein.; (to[1].email) Dieser Wert sollte nicht leer sein.; (subject) Dieser Wert sollte nicht leer sein. (code 422)'); + $transport->send($mail); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Transport/MailomatTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Transport/MailomatTransportFactoryTest.php new file mode 100644 index 0000000000000..8bb1e3dff0fee --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Transport/MailomatTransportFactoryTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailomat\Tests\Transport; + +use Psr\Log\NullLogger; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Mailer\Bridge\Mailomat\Transport\MailomatApiTransport; +use Symfony\Component\Mailer\Bridge\Mailomat\Transport\MailomatSmtpTransport; +use Symfony\Component\Mailer\Bridge\Mailomat\Transport\MailomatTransportFactory; +use Symfony\Component\Mailer\Test\TransportFactoryTestCase; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; + +class MailomatTransportFactoryTest extends TransportFactoryTestCase +{ + public function getFactory(): TransportFactoryInterface + { + return new MailomatTransportFactory(null, new MockHttpClient(), new NullLogger()); + } + + public static function supportsProvider(): iterable + { + yield [ + new Dsn('mailomat+api', 'default'), + true, + ]; + + yield [ + new Dsn('mailomat', 'default'), + true, + ]; + + yield [ + new Dsn('mailomat+smtp', 'default'), + true, + ]; + + yield [ + new Dsn('mailomat+smtps', 'default'), + true, + ]; + + yield [ + new Dsn('mailomat+smtp', 'example.com'), + true, + ]; + } + + public static function createProvider(): iterable + { + $logger = new NullLogger(); + + yield [ + new Dsn('mailomat+api', 'default', self::USER), + new MailomatApiTransport(self::USER, new MockHttpClient(), null, $logger), + ]; + + yield [ + new Dsn('mailomat+api', 'example.com', self::USER, '', 8080), + (new MailomatApiTransport(self::USER, new MockHttpClient(), null, $logger))->setHost('example.com')->setPort(8080), + ]; + + yield [ + new Dsn('mailomat', 'default', self::USER, self::PASSWORD), + new MailomatSmtpTransport(self::USER, self::PASSWORD, null, $logger), + ]; + + yield [ + new Dsn('mailomat+smtp', 'default', self::USER, self::PASSWORD), + new MailomatSmtpTransport(self::USER, self::PASSWORD, null, $logger), + ]; + + yield [ + new Dsn('mailomat+smtps', 'default', self::USER, self::PASSWORD), + new MailomatSmtpTransport(self::USER, self::PASSWORD, null, $logger), + ]; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield [ + new Dsn('mailomat+foo', 'default', self::USER), + 'The "mailomat+foo" scheme is not supported; supported schemes for mailer "mailomat" are: "mailomat", "mailomat+api", "mailomat+smtp", "mailomat+smtps".', + ]; + } + + public static function incompleteDsnProvider(): iterable + { + yield [new Dsn('mailomat+api', 'default')]; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/accepted.json b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/accepted.json new file mode 100644 index 0000000000000..1dab37bcb54ed --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/accepted.json @@ -0,0 +1,44 @@ +{ + "id": "29e785c1-dd0c-4efc-9d41-909d4109769f", + "eventType": "accepted", + "occurredAt": "2024-06-10T09:23:31+02:00", + "messageUuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "messageId": "7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss", + "recipient": "to@mailomat.swiss", + "payload": { + "event": "accepted", + "timestamp": 1718004211.198222, + "message-uuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "username": "username@s.mailomat.swiss", + "sender": "b=6mpthsjrq685ytz5mcc4jsn255=a6d86ea6-d5e6-465e-b41a-ed6486944f62@s.mailomat.swiss", + "recipient": "to@mailomat.swiss", + "recipients": [ + "to@mailomat.swiss" + ], + "transaction": { + "id": "3e1071e0-5bca-455a-be8c-640cf8c63353" + }, + "message": { + "headers": { + "from": "from@mailomat.swiss", + "to": "to@mailomat.swiss", + "message-id": "\u003C7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss\u003E", + "subject": "subject line" + }, + "size": 1234 + }, + "connection": { + "remote": { + "ip": "127.0.0.1", + "ptr": "1.0.0.127.your.isp" + }, + "helo": { + "verb": "EHLO", + "host": "[127.0.0.1]" + }, + "tls": { + "protocol": "TLSv1.3" + } + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/accepted.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/accepted.php new file mode 100644 index 0000000000000..7f7a936631408 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/accepted.php @@ -0,0 +1,9 @@ +setRecipientEmail('to@mailomat.swiss'); +$wh->setDate(DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, '2024-06-10T09:23:31+02:00')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/clicked.json b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/clicked.json new file mode 100644 index 0000000000000..551a9afb28f20 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/clicked.json @@ -0,0 +1,24 @@ +{ + "id": "29e785c1-dd0c-4efc-9d41-909d4109769f", + "eventType": "clicked", + "occurredAt": "2024-06-10T09:23:31+02:00", + "messageUuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "messageId": "7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss", + "recipient": "to@mailomat.swiss", + "payload": { + "event": "clicked", + "timestamp": 1718004211, + "message-uuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "recipient": "to@mailomat.swiss", + "message": { + "headers": { + "message-id": "7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss" + } + }, + "url": "http:\/\/mailomat.swiss", + "client": { + "ip": "127.0.0.1", + "user-agent": "Mozilla\/5.0" + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/clicked.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/clicked.php new file mode 100644 index 0000000000000..ff51f8e4e0102 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/clicked.php @@ -0,0 +1,9 @@ +setRecipientEmail('to@mailomat.swiss'); +$wh->setDate(DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, '2024-06-10T09:23:31+02:00')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/delivered.json b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/delivered.json new file mode 100644 index 0000000000000..3816477fbaede --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/delivered.json @@ -0,0 +1,46 @@ +{ + "id": "29e785c1-dd0c-4efc-9d41-909d4109769f", + "eventType": "delivered", + "occurredAt": "2024-06-10T09:23:31+02:00", + "messageUuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "messageId": "7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss", + "recipient": "to@mailomat.swiss", + "payload": { + "event": "delivered", + "timestamp": 1718004211.198222, + "message-uuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "username": "username@s.mailomat.swiss", + "sender": "b=6mpthsjrq685ytz5mcc4jsn255=a6d86ea6-d5e6-465e-b41a-ed6486944f62@s.mailomat.swiss", + "recipient": "to@mailomat.swiss", + "transaction": { + "id": "3e1071e0-5bca-455a-be8c-640cf8c63353" + }, + "message": { + "headers": { + "from": "from@mailomat.swiss", + "to": "to@mailomat.swiss", + "message-id": "\u003C7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss\u003E", + "subject": "subject line" + }, + "size": 1234 + }, + "delivery-status": { + "attempt-nr": 1, + "code": 250, + "enhanced-code": "2.0.0", + "message": "2.0.0 OK cb5981d2-3afe-4787-bf63-96212def1319", + "duration": 0.757960833, + "local": { + "ip": "127.0.0.1" + }, + "remote": { + "ip": "127.0.0.1", + "mx": "mx.example.com", + "tls": { + "started": true, + "protocol": "TLSv1.3" + } + } + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/delivered.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/delivered.php new file mode 100644 index 0000000000000..b2e1968311be2 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/delivered.php @@ -0,0 +1,9 @@ +setRecipientEmail('to@mailomat.swiss'); +$wh->setDate(DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, '2024-06-10T09:23:31+02:00')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/failure_perm.json b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/failure_perm.json new file mode 100644 index 0000000000000..32fdeb0711484 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/failure_perm.json @@ -0,0 +1,46 @@ +{ + "id": "29e785c1-dd0c-4efc-9d41-909d4109769f", + "eventType": "failure_perm", + "occurredAt": "2024-06-10T09:23:31+02:00", + "messageUuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "messageId": "7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss", + "recipient": "non-existent@mailomat.swiss", + "payload": { + "event": "failure_perm", + "timestamp": 1718004211.198222, + "message-uuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "username": "username@s.mailomat.swiss", + "sender": "b=6mpthsjrq685ytz5mcc4jsn255=a6d86ea6-d5e6-465e-b41a-ed6486944f62@s.mailomat.swiss", + "recipient": "non-existent@mailomat.swiss", + "transaction": { + "id": "3e1071e0-5bca-455a-be8c-640cf8c63353" + }, + "message": { + "headers": { + "from": "from@mailomat.swiss", + "to": "non-existent@mailomat.swiss", + "message-id": "\u003C7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss\u003E", + "subject": "subject line" + }, + "size": 1234 + }, + "delivery-status": { + "attempt-nr": 1, + "code": 550, + "enhanced-code": "5.1.1", + "message": "5.1.1 abc: recipient rejected, address unknown", + "duration": 0.198979651, + "local": { + "ip": "127.0.0.1" + }, + "remote": { + "ip": "127.0.0.1", + "mx": "mx.example.com", + "tls": { + "started": true, + "protocol": "TLSv1.2" + } + } + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/failure_perm.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/failure_perm.php new file mode 100644 index 0000000000000..83923f2ad797a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/failure_perm.php @@ -0,0 +1,9 @@ +setRecipientEmail('non-existent@mailomat.swiss'); +$wh->setDate(DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, '2024-06-10T09:23:31+02:00')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/not_accepted.json b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/not_accepted.json new file mode 100644 index 0000000000000..2cc5f4b5e994b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/not_accepted.json @@ -0,0 +1,45 @@ +{ + "id": "29e785c1-dd0c-4efc-9d41-909d4109769f", + "eventType": "not_accepted", + "occurredAt": "2024-06-10T09:23:31+02:00", + "messageUuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "messageId": "7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss", + "recipient": "to@mailomat.swiss", + "payload": { + "event": "not_accepted", + "timestamp": 1718004211.198222, + "message-uuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "reason": "Not enough remaining emails available to send 1 emails (limit 100000, sent 100000, remaining 0)", + "username": "username@s.mailomat.swiss", + "sender": "b=6mpthsjrq685ytz5mcc4jsn255=a6d86ea6-d5e6-465e-b41a-ed6486944f62@s.mailomat.swiss", + "recipient": "to@mailomat.swiss", + "recipients": [ + "to@mailomat.swiss" + ], + "transaction": { + "id": "3e1071e0-5bca-455a-be8c-640cf8c63353" + }, + "message": { + "headers": { + "from": "from@mailomat.swiss", + "to": "to@mailomat.swiss", + "message-id": "\u003C7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss\u003E", + "subject": "subject line" + }, + "size": 1234 + }, + "connection": { + "remote": { + "ip": "127.0.0.1", + "ptr": "1.0.0.127.your.isp" + }, + "helo": { + "verb": "EHLO", + "host": "[127.0.0.1]" + }, + "tls": { + "protocol": "TLSv1.3" + } + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/not_accepted.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/not_accepted.php new file mode 100644 index 0000000000000..87469078a0c29 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/not_accepted.php @@ -0,0 +1,10 @@ +setRecipientEmail('to@mailomat.swiss'); +$wh->setDate(DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, '2024-06-10T09:23:31+02:00')); +$wh->setReason('Not enough remaining emails available to send 1 emails (limit 100000, sent 100000, remaining 0)'); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/opened.json b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/opened.json new file mode 100644 index 0000000000000..cea185166267d --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/opened.json @@ -0,0 +1,23 @@ +{ + "id": "29e785c1-dd0c-4efc-9d41-909d4109769f", + "eventType": "opened", + "occurredAt": "2024-06-10T09:23:31+02:00", + "messageUuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "messageId": "7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss", + "recipient": "to@mailomat.swiss", + "payload": { + "event": "opened", + "timestamp": 1718004211, + "message-uuid": "1684f904-596e-4d9f-ba16-ead5c4d27957", + "recipient": "to@mailomat.swiss", + "message": { + "headers": { + "message-id": "7bb4cd3f5d8ee2bcb5bed8c2ed7def85@s.mailomat.swiss" + } + }, + "client": { + "ip": "127.0.0.1", + "user-agent": "Mozilla\/5.0" + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/opened.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/opened.php new file mode 100644 index 0000000000000..be738b3d74544 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/Fixtures/opened.php @@ -0,0 +1,9 @@ +setRecipientEmail('to@mailomat.swiss'); +$wh->setDate(DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, '2024-06-10T09:23:31+02:00')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/MailomatRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/MailomatRequestParserTest.php new file mode 100644 index 0000000000000..72fcf88eff13e --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Tests/Webhook/MailomatRequestParserTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailomat\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\Bridge\Mailomat\RemoteEvent\MailomatPayloadConverter; +use Symfony\Component\Mailer\Bridge\Mailomat\Webhook\MailomatRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +class MailomatRequestParserTest extends AbstractRequestParserTestCase +{ + protected function createRequestParser(): RequestParserInterface + { + return new MailomatRequestParser(new MailomatPayloadConverter()); + } + + protected function getSecret(): string + { + return 'NgD3IyUA0oLfkM5IyL8tdMNJeIYeBXOpAcnulN1du1aqh3jFbo766lKdJvMePUy5'; + } + + protected function createRequest(string $payload): Request + { + return Request::create('/', 'POST', [], [], [], [ + 'Content-Type' => 'application/json', + 'HTTP_X-MOM-Webhook-Event' => 'delivered', + 'HTTP_X-MOM-Webhook-ID' => '1d958822-0934-4c6a-abc8-5defec4baa64', + 'HTTP_X-MOM-Webhook-Signature' => 'sha256=1a1e3be272212aefe668db51231f54ba66759d6d4b9c5e03d4aa6825f8eb157c', + 'HTTP_X-MOM-Webhook-Timestamp' => '1718004211', + ], str_replace("\n", "\r\n", $payload)); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatApiTransport.php new file mode 100644 index 0000000000000..317bf97dfb428 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatApiTransport.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailomat\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\HttpTransportException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractApiTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +final class MailomatApiTransport extends AbstractApiTransport +{ + private const HOST = 'api.mailomat.swiss'; + + public function __construct( + #[\SensitiveParameter] private readonly string $key, + ?HttpClientInterface $client = null, + ?EventDispatcherInterface $dispatcher = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($client, $dispatcher, $logger); + } + + public function __toString(): string + { + return sprintf('mailomat+api://%s', $this->getEndpoint()); + } + + protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface + { + $response = $this->client->request('POST', sprintf('https://%s/message', $this->getEndpoint()), [ + 'auth_bearer' => $this->key, + 'json' => $this->getPayload($email, $envelope), + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + try { + $statusCode = $response->getStatusCode(); + $result = $response->toArray(false); + } catch (TransportExceptionInterface $e) { + throw new HttpTransportException('Could not reach the remote Mailomat server.', $response, 0, $e); + } catch (DecodingExceptionInterface $e) { + throw new HttpTransportException(sprintf('Unable to send an email: %s (code %d).', $response->getContent(false), $statusCode), $response, 0, $e); + } + + if (202 !== $statusCode) { + $violations = array_map(static function (array $violation) { + return ($violation['propertyPath'] ? '('.$violation['propertyPath'].') ' : '').$violation['message']; + }, $result['violations']); + + throw new HttpTransportException(sprintf('Unable to send an email: %s (code %d).', implode('; ', $violations), $statusCode), $response); + } + + if (isset($result['messageUuid'])) { + $sentMessage->setMessageId($result['messageUuid']); + } + + return $response; + } + + private function getEndpoint(): string + { + return ($this->host ?: self::HOST).($this->port ? ':'.$this->port : ''); + } + + private function getPayload(Email $email, Envelope $envelope): array + { + $payload = [ + 'from' => $this->addressToPayload($envelope->getSender()), + 'to' => array_map([$this, 'addressToPayload'], $email->getTo()), + 'subject' => $email->getSubject(), + 'text' => $email->getTextBody(), + 'html' => $email->getHtmlBody(), + 'attachments' => $this->getAttachments($email), + ]; + + if ($email->getCc()) { + $payload['cc'] = array_map([$this, 'addressToPayload'], $email->getCc()); + } + + if ($email->getBcc()) { + $payload['bcc'] = array_map([$this, 'addressToPayload'], $email->getBcc()); + } + + if ($email->getReplyTo()) { + $payload['replyTo'] = array_map([$this, 'addressToPayload'], $email->getReplyTo()); + } + + return $payload; + } + + private function addressToPayload(Address $address): array + { + $payload = [ + 'email' => $address->getAddress(), + ]; + + if ($address->getName()) { + $payload['name'] = $address->getName(); + } + + return $payload; + } + + private function getAttachments(Email $email): array + { + $attachments = []; + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); + $disposition = $headers->getHeaderBody('Content-Disposition'); + + $att = [ + 'filename' => $filename, + 'contentBase64' => $attachment->bodyToString(), + 'contentType' => $headers->get('Content-Type')->getBody(), + ]; + + if ('inline' === $disposition) { + $att['ContentID'] = 'cid:'.$filename; + } + + $attachments[] = $att; + } + + return $attachments; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatSmtpTransport.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatSmtpTransport.php new file mode 100644 index 0000000000000..bd9ba4d32bf37 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatSmtpTransport.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailomat\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; + +final class MailomatSmtpTransport extends EsmtpTransport +{ + public function __construct(string $username, #[\SensitiveParameter] string $password, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null) + { + parent::__construct('smtp.mailomat.cloud', 587, false, $dispatcher, $logger); + + $this->setUsername($username); + $this->setPassword($password); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatTransportFactory.php new file mode 100644 index 0000000000000..f7aa7898fbe11 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Transport/MailomatTransportFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailomat\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; + +final class MailomatTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $schema = $dsn->getScheme(); + + if ('mailomat+api' === $schema) { + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new MailomatApiTransport($this->getUser($dsn), $this->client, $this->dispatcher, $this->logger)) + ->setHost($host) + ->setPort($port) + ; + } + + if (\in_array($schema, ['mailomat+smtp', 'mailomat+smtps', 'mailomat'], true)) { + return new MailomatSmtpTransport($dsn->getUser(), $dsn->getPassword(), $this->dispatcher, $this->logger); + } + + throw new UnsupportedSchemeException($dsn, 'mailomat', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['mailomat', 'mailomat+api', 'mailomat+smtp', 'mailomat+smtps']; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/Webhook/MailomatRequestParser.php b/src/Symfony/Component/Mailer/Bridge/Mailomat/Webhook/MailomatRequestParser.php new file mode 100644 index 0000000000000..a2d11ba2d3ba3 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/Webhook/MailomatRequestParser.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Mailomat\Webhook; + +use Symfony\Component\HttpFoundation\ChainRequestMatcher; +use Symfony\Component\HttpFoundation\HeaderBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestMatcher\HeaderRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcherInterface; +use Symfony\Component\Mailer\Bridge\Mailomat\RemoteEvent\MailomatPayloadConverter; +use Symfony\Component\Mailer\Exception\InvalidArgumentException; +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 MailomatRequestParser extends AbstractRequestParser +{ + private const HEADER_EVENT = 'X-MOM-Webhook-Event'; + private const HEADER_ID = 'X-MOM-Webhook-Id'; + private const HEADER_TIMESTAMP = 'X-MOM-Webhook-Timestamp'; + private const HEADER_SIGNATURE = 'X-MOM-Webhook-Signature'; + + public function __construct( + private readonly MailomatPayloadConverter $converter, + ) { + } + + protected function getRequestMatcher(): RequestMatcherInterface + { + return new ChainRequestMatcher([ + new MethodRequestMatcher('POST'), + new IsJsonRequestMatcher(), + new HeaderRequestMatcher([ + self::HEADER_EVENT, + self::HEADER_TIMESTAMP, + self::HEADER_ID, + self::HEADER_SIGNATURE, + ]), + ]); + } + + protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?AbstractMailerEvent + { + if (!$secret) { + throw new InvalidArgumentException('A non-empty secret is required.'); + } + + $content = $request->toArray(); + + if ( + !isset($content['id']) + || !isset($content['eventType']) + || !isset($content['occurredAt']) + || !isset($content['messageId']) + || !isset($content['recipient']) + ) { + throw new RejectWebhookException(406, 'Payload is malformed.'); + } + + $this->validateSignature($request->headers, $secret); + + try { + return $this->converter->convert($content); + } catch (ParseException $e) { + throw new RejectWebhookException(406, $e->getMessage(), $e); + } + } + + private function validateSignature(HeaderBag $headers, #[\SensitiveParameter] string $secret): void + { + // see https://api.mailomat.swiss/docs/#tag/webhook-security + $data = implode('.', [$headers->get(self::HEADER_ID), $headers->get(self::HEADER_EVENT), $headers->get(self::HEADER_TIMESTAMP)]); + + [$algo, $signature] = explode('=', $headers->get(self::HEADER_SIGNATURE)); + if (!hash_equals(hash_hmac($algo, $data, $secret), $signature)) { + throw new RejectWebhookException(406, 'Signature is wrong.'); + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/composer.json b/src/Symfony/Component/Mailer/Bridge/Mailomat/composer.json new file mode 100644 index 0000000000000..2d4cc3f1c8515 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/mailomat-mailer", + "type": "symfony-mailer-bridge", + "description": "Symfony Mailomat Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Patrick Landolt", + "email": "patrick.landolt@artack.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/mailer": "^7.2" + }, + "require-dev": { + "symfony/http-client": "^6.4|^7.0", + "symfony/http-foundation": "^7.1", + "symfony/webhook": "^6.4|^7.0" + }, + "conflict": { + "symfony/http-foundation": "<7.1" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Mailomat\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Mailer/Bridge/Mailomat/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Mailomat/phpunit.xml.dist new file mode 100644 index 0000000000000..2f6ec572e2ecf --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Mailomat/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + + diff --git a/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php index 5ac0d3d730623..01c6b1cb266fa 100644 --- a/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php @@ -48,6 +48,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Mailjet\Transport\MailjetTransportFactory::class, 'package' => 'symfony/mailjet-mailer', ], + 'mailomat' => [ + 'class' => Bridge\Mailomat\Transport\MailomatTransportFactory::class, + 'package' => 'symfony/mailomat-mailer', + ], 'mailpace' => [ 'class' => Bridge\MailPace\Transport\MailPaceTransportFactory::class, 'package' => 'symfony/mail-pace-mailer', diff --git a/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php b/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php index 273197646d319..f294d26b1c3b3 100644 --- a/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php +++ b/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php @@ -22,6 +22,7 @@ use Symfony\Component\Mailer\Bridge\MailerSend\Transport\MailerSendTransportFactory; use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; +use Symfony\Component\Mailer\Bridge\Mailomat\Transport\MailomatTransportFactory; use Symfony\Component\Mailer\Bridge\MailPace\Transport\MailPaceTransportFactory; use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory; use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendTransportFactory; @@ -47,6 +48,7 @@ public static function setUpBeforeClass(): void MailerSendTransportFactory::class => false, MailgunTransportFactory::class => false, MailjetTransportFactory::class => false, + MailomatTransportFactory::class => false, MandrillTransportFactory::class => false, PostmarkTransportFactory::class => false, ResendTransportFactory::class => false, @@ -78,6 +80,7 @@ public static function messageWhereSchemeIsPartOfSchemeToPackageMapProvider(): \ yield ['mailersend', 'symfony/mailersend-mailer']; yield ['mailgun', 'symfony/mailgun-mailer']; yield ['mailjet', 'symfony/mailjet-mailer']; + yield ['mailomat', 'symfony/mailomat-mailer']; yield ['mailpace', 'symfony/mail-pace-mailer']; yield ['mandrill', 'symfony/mailchimp-mailer']; yield ['postmark', 'symfony/postmark-mailer']; diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php index 2a290154df6f5..2d61017518848 100644 --- a/src/Symfony/Component/Mailer/Transport.php +++ b/src/Symfony/Component/Mailer/Transport.php @@ -22,6 +22,7 @@ use Symfony\Component\Mailer\Bridge\MailerSend\Transport\MailerSendTransportFactory; use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; +use Symfony\Component\Mailer\Bridge\Mailomat\Transport\MailomatTransportFactory; use Symfony\Component\Mailer\Bridge\MailPace\Transport\MailPaceTransportFactory; use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory; use Symfony\Component\Mailer\Bridge\Resend\Transport\ResendTransportFactory; @@ -55,6 +56,7 @@ final class Transport MailerSendTransportFactory::class, MailgunTransportFactory::class, MailjetTransportFactory::class, + MailomatTransportFactory::class, MailPaceTransportFactory::class, MandrillTransportFactory::class, PostmarkTransportFactory::class,