From 27b75ac74476745ca27310b91b5f30fefa56ce44 Mon Sep 17 00:00:00 2001 From: Farhad Hedayatifard Date: Mon, 28 Oct 2024 22:24:40 +0330 Subject: [PATCH 1/6] Add AhaSend Mailer Bridge --- .../FrameworkExtension.php | 2 + .../Resources/config/mailer_transports.php | 2 + .../Resources/config/mailer_webhook.php | 7 + .../Mailer/Bridge/AhaSend/.gitattributes | 3 + .../AhaSend/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/close-pull-request.yml | 20 ++ .../Mailer/Bridge/AhaSend/.gitignore | 3 + .../Mailer/Bridge/AhaSend/CHANGELOG.md | 6 + .../AhaSend/Event/AhaSendDeliveryEvent.php | 25 ++ .../Component/Mailer/Bridge/AhaSend/LICENSE | 19 + .../Component/Mailer/Bridge/AhaSend/README.md | 27 ++ .../RemoteEvent/AhaSendPayloadConverter.php | 59 ++++ .../Transport/AhaSendApiTransportTest.php | 327 ++++++++++++++++++ .../Transport/AhaSendSmtpTransportTest.php | 47 +++ .../Transport/AhaSendTransportFactoryTest.php | 100 ++++++ .../Webhook/AhaSendRequestParserTest.php | 54 +++ .../Tests/Webhook/Fixtures/bounced.json | 15 + .../Tests/Webhook/Fixtures/bounced.php | 9 + .../Webhook/Fixtures/bounced_headers.txt | 3 + .../Tests/Webhook/Fixtures/clicked.json | 17 + .../Tests/Webhook/Fixtures/clicked.php | 9 + .../Webhook/Fixtures/clicked_headers.txt | 3 + .../Tests/Webhook/Fixtures/delivered.json | 15 + .../Tests/Webhook/Fixtures/delivered.php | 9 + .../Webhook/Fixtures/delivered_headers.txt | 3 + .../Tests/Webhook/Fixtures/opened.json | 16 + .../AhaSend/Tests/Webhook/Fixtures/opened.php | 9 + .../Tests/Webhook/Fixtures/opened_headers.txt | 3 + .../Tests/Webhook/Fixtures/reception.json | 15 + .../Tests/Webhook/Fixtures/reception.php | 9 + .../Webhook/Fixtures/reception_headers.txt | 3 + .../Tests/Webhook/Fixtures/suppressed.json | 15 + .../Tests/Webhook/Fixtures/suppressed.php | 9 + .../Webhook/Fixtures/suppressed_headers.txt | 3 + .../Webhook/Fixtures/transient_error.json | 15 + .../Webhook/Fixtures/transient_error.php | 9 + .../Fixtures/transient_error_headers.txt | 3 + .../AhaSend/Transport/AhaSendApiTransport.php | 211 +++++++++++ .../Transport/AhaSendSmtpTransport.php | 61 ++++ .../Transport/AhaSendTransportFactory.php | 53 +++ .../AhaSend/Webhook/AhaSendRequestParser.php | 77 +++++ .../Mailer/Bridge/AhaSend/composer.json | 37 ++ .../Mailer/Bridge/AhaSend/phpunit.xml.dist | 31 ++ .../Exception/UnsupportedSchemeException.php | 4 + .../UnsupportedSchemeExceptionTest.php | 3 + src/Symfony/Component/Mailer/Transport.php | 2 + 46 files changed, 1380 insertions(+) create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/.gitattributes create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/.github/workflows/close-pull-request.yml create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/.gitignore create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Event/AhaSendDeliveryEvent.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/LICENSE create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/README.md create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/RemoteEvent/AhaSendPayloadConverter.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendSmtpTransportTest.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendTransportFactoryTest.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/AhaSendRequestParserTest.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced.json create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced_headers.txt create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked.json create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked_headers.txt create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered.json create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered_headers.txt create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened.json create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened_headers.txt create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception.json create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception_headers.txt create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed.json create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed_headers.txt create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error.json create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error_headers.txt create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendApiTransport.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendSmtpTransport.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendTransportFactory.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json create mode 100644 src/Symfony/Component/Mailer/Bridge/AhaSend/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index a7749cd30faad..0c87c4aea26f5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2670,6 +2670,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co } $classToServices = [ + MailerBridge\AhaSend\Transport\AhaSendTransportFactory::class => 'mailer.transport_factory.ahasend', MailerBridge\Azure\Transport\AzureTransportFactory::class => 'mailer.transport_factory.azure', MailerBridge\Brevo\Transport\BrevoTransportFactory::class => 'mailer.transport_factory.brevo', MailerBridge\Google\Transport\GmailTransportFactory::class => 'mailer.transport_factory.gmail', @@ -2700,6 +2701,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co if ($webhookEnabled) { $webhookRequestParsers = [ + MailerBridge\AhaSend\Webhook\AhaSendRequestParser::class => 'mailer.webhook.request_parser.ahasend', MailerBridge\Brevo\Webhook\BrevoRequestParser::class => 'mailer.webhook.request_parser.brevo', MailerBridge\MailerSend\Webhook\MailerSendRequestParser::class => 'mailer.webhook.request_parser.mailersend', MailerBridge\Mailchimp\Webhook\MailchimpRequestParser::class => 'mailer.webhook.request_parser.mailchimp', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php index c0e7cc06a4eb8..2c79b4d55556f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendTransportFactory; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureTransportFactory; use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoTransportFactory; @@ -47,6 +48,7 @@ ->tag('monolog.logger', ['channel' => 'mailer']); $factories = [ + 'ahasend' => AhaSendTransportFactory::class, 'amazon' => SesTransportFactory::class, 'azure' => AzureTransportFactory::class, 'brevo' => BrevoTransportFactory::class, diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php index c574324db0b9f..278e2b81c3573 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php @@ -11,6 +11,8 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Mailer\Bridge\AhaSend\RemoteEvent\AhaSendPayloadConverter; +use Symfony\Component\Mailer\Bridge\AhaSend\Webhook\AhaSendRequestParser; use Symfony\Component\Mailer\Bridge\Brevo\RemoteEvent\BrevoPayloadConverter; use Symfony\Component\Mailer\Bridge\Brevo\Webhook\BrevoRequestParser; use Symfony\Component\Mailer\Bridge\Mailchimp\RemoteEvent\MailchimpPayloadConverter; @@ -86,6 +88,11 @@ ->args([service('mailer.payload_converter.sweego')]) ->alias(SweegoRequestParser::class, 'mailer.webhook.request_parser.sweego') + ->set('mailer.payload_converter.ahasend', AhaSendPayloadConverter::class) + ->set('mailer.webhook.request_parser.ahasend', AhaSendRequestParser::class) + ->args([service('mailer.payload_converter.ahasend')]) + ->alias(AhaSendRequestParser::class, 'mailer.webhook.request_parser.ahasend') + ->set('mailer.payload_converter.mailchimp', MailchimpPayloadConverter::class) ->set('mailer.webhook.request_parser.mailchimp', MailchimpRequestParser::class) ->args([service('mailer.payload_converter.mailchimp')]) diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/.gitattributes b/src/Symfony/Component/Mailer/Bridge/AhaSend/.gitattributes new file mode 100644 index 0000000000000..14c3c35940427 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/.github/PULL_REQUEST_TEMPLATE.md b/src/Symfony/Component/Mailer/Bridge/AhaSend/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..4689c4dad430e --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/.github/workflows/close-pull-request.yml b/src/Symfony/Component/Mailer/Bridge/AhaSend/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000000000..e55b47817e69a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/.gitignore b/src/Symfony/Component/Mailer/Bridge/AhaSend/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md new file mode 100644 index 0000000000000..8ce83a620ba33 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md @@ -0,0 +1,6 @@ +CHANGELOG +========= + +7.2 +--- + * Added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Event/AhaSendDeliveryEvent.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Event/AhaSendDeliveryEvent.php new file mode 100644 index 0000000000000..b0710630b2869 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Event/AhaSendDeliveryEvent.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\AhaSend\Event; + +class AhaSendDeliveryEvent +{ + public function __construct( + private readonly string $message, + ) { + } + + public function getMessage(): string + { + return $this->message; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/LICENSE b/src/Symfony/Component/Mailer/Bridge/AhaSend/LICENSE new file mode 100644 index 0000000000000..e374a5c8339d3 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/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/AhaSend/README.md b/src/Symfony/Component/Mailer/Bridge/AhaSend/README.md new file mode 100644 index 0000000000000..0d5dfc4b76813 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/README.md @@ -0,0 +1,27 @@ +AhaSend Bridge +=============== + +Provides AhaSend integration for Symfony Mailer. + +Configuration example: + +```env +# SMTP +MAILER_DSN=ahasend+smtp://USERNAME:PASSWORD@default + +# API +MAILER_DSN=ahasend+api://API_KEY@default +``` + +where: + - `USERNAME` is your AhaSend SMTP Credentials username + - `PASSWORD` is your AhaSend SMTP Credentials password + - `API_KEY` is your AhaSend API Key credential + +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/AhaSend/RemoteEvent/AhaSendPayloadConverter.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/RemoteEvent/AhaSendPayloadConverter.php new file mode 100644 index 0000000000000..ae677987e6855 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/RemoteEvent/AhaSendPayloadConverter.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\AhaSend\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 AhaSendPayloadConverter implements PayloadConverterInterface +{ + public function convert(array $payload): AbstractMailerEvent + { + if (\in_array($payload['type'], ['message.clicked', 'message.opened'])) { + $name = match ($payload['type']) { + 'message.clicked' => MailerEngagementEvent::CLICK, + 'message.opened' => MailerEngagementEvent::OPEN, + default => throw new ParseException(\sprintf('Unsupported event "%s".', $payload['type'])), + }; + $event = new MailerEngagementEvent($name, $payload['data']['id'], $payload); + } elseif (str_starts_with($payload['type'], 'message.')) { + $name = match ($payload['type']) { + 'message.reception' => MailerDeliveryEvent::RECEIVED, + 'message.delivered' => MailerDeliveryEvent::DELIVERED, + 'message.transient_error' => MailerDeliveryEvent::DEFERRED, + 'message.failed', 'message.bounced' => MailerDeliveryEvent::BOUNCE, + 'message.suppressed' => MailerDeliveryEvent::DROPPED, + default => throw new ParseException(\sprintf('Unsupported event "%s".', $payload['type'])), + }; + $event = new MailerDeliveryEvent($name, $payload['data']['id'], $payload); + } else { + // suppressions and domain DNS problem webhooks. Ignore them for now. + throw new ParseException(\sprintf('Unsupported event "%s".', $payload['type'])); + } + + // AhaSend sends timestamps with 9 decimal places for nanosecond precision, + // truncate to 6 decimal places for microseconds. + $truncatedTimestamp = substr($payload['timestamp'], 0, 26).'Z'; + $date = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $payload['timestamp']) + ?: \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', $truncatedTimestamp); + if (!$date) { + throw new ParseException(\sprintf('Invalid date "%s".', $payload['timestamp'])); + } + $event->setDate($date); + $event->setRecipientEmail($payload['data']['recipient']); + + return $event; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php new file mode 100644 index 0000000000000..c32cea1254692 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php @@ -0,0 +1,327 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\AhaSend\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Mailer\Bridge\AhaSend\Event\AhaSendDeliveryEvent; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendApiTransport; +use Symfony\Component\Mailer\Envelope; +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 AhaSendApiTransportTest extends TestCase +{ + /** + * @dataProvider getTransportData + */ + public function testToString(AhaSendApiTransport $transport, string $expected) + { + $this->assertSame($expected, (string) $transport); + } + + public static function getTransportData() + { + return [ + [ + new AhaSendApiTransport('KEY'), + 'ahasend+api://send.ahasend.com', + ], + [ + (new AhaSendApiTransport('KEY'))->setHost('example.com'), + 'ahasend+api://example.com', + ], + [ + (new AhaSendApiTransport('KEY'))->setHost('example.com')->setPort(99), + 'ahasend+api://example.com:99', + ], + ]; + } + + public function testSend() + { + $email = new Email(); + $email->from(new Address('foo@example.com', 'Ms. Foo Bar')) + ->to(new Address('bar@example.com', 'Mr. Recipient')) + ->bcc('baz@example.com') + ->subject('An email') + ->text('Test email body') + ->html('

Test email body

') + ->replyTo(new Address('bar2@example.com', 'Mr. Recipient')); + + $response = $this->createMock(JsonMockResponse::class); + $response->method('toArray')->willReturn([ + 'success_count' => 2, + 'fail_count' => 0, + 'failed_recipients' => [], + 'errors' => [], + ]); + $response + ->expects($this->once()) + ->method('getStatusCode') + ->willReturn(201); + $httpClient = $this->createMock(MockHttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('POST', 'https://send.ahasend.com/v1/email/send', [ + 'json' => [ + 'from' => [ + 'email' => 'foo@example.com', + 'name' => 'Ms. Foo Bar', + ], + 'recipients' => [ + [ + 'email' => 'bar@example.com', + 'name' => 'Mr. Recipient', + ], + [ + 'email' => 'baz@example.com', + ], + ], + 'content' => [ + 'subject' => 'An email', + 'text_body' => 'Test email body', + 'html_body' => '

Test email body

', + 'reply_to' => [ + 'email' => 'bar2@example.com', + 'name' => 'Mr. Recipient', + ], + 'headers' => [ + 'Bcc' => 'baz@example.com', + ], + ], + ], + 'headers' => [ + 'X-Api-Key' => 'foo', + ], + ]) + ->willReturn($response); + + $mailer = new AhaSendApiTransport('foo', $httpClient); + $mailer->send($email); + } + + public function testSendDeliveryEventIsDispatched() + { + $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { + return new JsonMockResponse([ + 'success_count' => 0, + 'fail_count' => 1, + 'failed_recipients' => [ + 'someone@gmil.com', + ], + 'errors' => [ + 'someone@gmil.com: Invalid recipient', + ], + ], [ + 'http_code' => 201, + ]); + }); + + $email = new Email(); + $email->from(new Address('foo@example.com', 'Ms. Foo Bar')) + ->to(new Address('someone@gmil.com', 'Mr. Someone')) + ->subject('An email') + ->text('Test email body'); + + $expectedEvent = (new AhaSendDeliveryEvent('someone@gmil.com: Invalid recipient')); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher + ->method('dispatch') + ->willReturnCallback(function ($event) use ($expectedEvent) { + if ($event instanceof AhaSendDeliveryEvent) { + $this->assertEquals($event, $expectedEvent); + } + + return $event; + }); + + $transport = new AhaSendApiTransport('foo', $client, $dispatcher); + + $transport->send($email); + } + + public function testCustomHeader() + { + $email = new Email(); + $email->getHeaders()->addTextHeader('foo', 'bar'); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = new AhaSendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(AhaSendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('headers', $payload['content']); + $this->assertArrayHasKey('foo', $payload['content']['headers']); + $this->assertEquals('bar', $payload['content']['headers']['foo']); + } + + public function testReplyTo() + { + $from = 'from@example.com'; + $to = 'to@example.com'; + $replyTo = 'replyto@example.com'; + $email = new Email(); + $email->from($from) + ->to($to) + ->replyTo($replyTo) + ->text('content'); + $envelope = new Envelope(new Address($from), [new Address($to)]); + + $transport = new AhaSendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(AhaSendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('from', $payload); + $this->assertArrayHasKey('email', $payload['from']); + $this->assertSame($from, $payload['from']['email']); + + $this->assertArrayHasKey('reply_to', $payload['content']); + $this->assertArrayHasKey('email', $payload['content']['reply_to']); + $this->assertSame($replyTo, $payload['content']['reply_to']['email']); + } + + public function testEnvelopeSenderAndRecipients() + { + $from = 'from@example.com'; + $to = 'to@example.com'; + $envelopeFrom = 'envelopefrom@example.com'; + $envelopeTo = 'envelopeto@example.com'; + $email = new Email(); + $email->from($from) + ->to($to) + ->cc('cc@example.com') + ->bcc('bcc@example.com') + ->text('content'); + $envelope = new Envelope(new Address($envelopeFrom), [new Address($envelopeTo), new Address('cc@example.com'), new Address('bcc@example.com')]); + + $transport = new AhaSendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(AhaSendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('from', $payload); + $this->assertArrayHasKey('email', $payload['from']); + $this->assertSame($envelopeFrom, $payload['from']['email']); + + $this->assertArrayHasKey('recipients', $payload); + $this->assertArrayHasKey('email', $payload['recipients'][0]); + $this->assertCount(3, $payload['recipients']); + $this->assertSame($envelopeTo, $payload['recipients'][0]['email']); + } + + public function testTagHeaders() + { + $email = new Email(); + $email->getHeaders()->add(new TagHeader('category-one')); + $email->getHeaders()->add(new TagHeader('category-two')); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = new AhaSendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(AhaSendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('headers', $payload['content']); + $this->assertArrayHasKey('AhaSend-Tags', $payload['content']['headers']); + + $this->assertCount(1, $payload['content']['headers']); + $this->assertCount(2, explode(',', $payload['content']['headers']['AhaSend-Tags'])); + + $this->assertSame('category-one,category-two', $payload['content']['headers']['AhaSend-Tags']); + } + + public function testInlineWithCustomContentId() + { + $imagePart = (new DataPart('text-contents', 'text.txt')); + $imagePart->asInline(); + $imagePart->setContentId('content-identifier@symfony'); + + $email = new Email(); + $email->addPart($imagePart); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = new AhaSendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(AhaSendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('attachments', $payload['content']); + $this->assertCount(1, $payload['content']['attachments']); + $this->assertArrayHasKey('content_id', $payload['content']['attachments'][0]); + + $this->assertSame('content-identifier@symfony', $payload['content']['attachments'][0]['content_id']); + } + + public function testInlineWithoutCustomContentId() + { + $imagePart = (new DataPart('text-contents', 'text.txt')); + $imagePart->asInline(); + + $email = new Email(); + $email->addPart($imagePart); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = new AhaSendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(AhaSendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('attachments', $payload['content']); + $this->assertCount(1, $payload['content']['attachments']); + $this->assertArrayHasKey('content_id', $payload['content']['attachments'][0]); + + $this->assertSame('text.txt', $payload['content']['attachments'][0]['content_id']); + } + + public function testAttachmentWithBase64Encoding() + { + $textPart = (new DataPart('image-contents', 'image.png')); + + $email = new Email(); + $email->addPart($textPart); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = new AhaSendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(AhaSendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('attachments', $payload['content']); + $this->assertCount(1, $payload['content']['attachments']); + $this->assertArrayHasKey('base64', $payload['content']['attachments'][0]); + + $this->assertTrue($payload['content']['attachments'][0]['base64']); + $this->assertNotSame('image-contents', $payload['content']['attachments'][0]['data']); + } + + public function testAttachmentWithoutBase64Encoding() + { + $textPart = (new DataPart('text-contents', 'text.txt', 'text/plain')); + + $email = new Email(); + $email->addPart($textPart); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = new AhaSendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(AhaSendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('attachments', $payload['content']); + $this->assertCount(1, $payload['content']['attachments']); + $this->assertArrayHasKey('base64', $payload['content']['attachments'][0]); + + $this->assertFalse($payload['content']['attachments'][0]['base64']); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendSmtpTransportTest.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendSmtpTransportTest.php new file mode 100644 index 0000000000000..581a0601a4f16 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendSmtpTransportTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\AhaSend\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendSmtpTransport; +use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mime\Email; + +class AhaSendSmtpTransportTest extends TestCase +{ + public function testCustomHeader() + { + $email = new Email(); + $email->getHeaders()->addTextHeader('foo', 'bar'); + + $transport = new AhaSendSmtpTransport('USERNAME', 'PASSWORD'); + $method = new \ReflectionMethod(AhaSendSmtpTransport::class, 'addAhaSendHeaders'); + $method->invoke($transport, $email); + + $this->assertCount(1, $email->getHeaders()->toArray()); + $this->assertSame('foo: bar', $email->getHeaders()->get('FOO')->toString()); + } + + public function testMultipleTags() + { + $email = new Email(); + $email->getHeaders()->add(new TagHeader('tag1')); + $email->getHeaders()->add(new TagHeader('tag2')); + + $transport = new AhaSendSmtpTransport('USERNAME', 'PASSWORD'); + $method = new \ReflectionMethod(AhaSendSmtpTransport::class, 'addAhaSendHeaders'); + + $method->invoke($transport, $email); + $headers = $email->getHeaders(); + $this->assertSame('AhaSend-Tags: tag1,tag2', $email->getHeaders()->get('AhaSend-Tags')->toString()); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendTransportFactoryTest.php new file mode 100644 index 0000000000000..445d4e5208705 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendTransportFactoryTest.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\AhaSend\Tests\Transport; + +use Psr\Log\NullLogger; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendApiTransport; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendSmtpTransport; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendTransportFactory; +use Symfony\Component\Mailer\Test\AbstractTransportFactoryTestCase; +use Symfony\Component\Mailer\Test\IncompleteDsnTestTrait; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; + +class AhaSendTransportFactoryTest extends AbstractTransportFactoryTestCase +{ + use IncompleteDsnTestTrait; + + public function getFactory(): TransportFactoryInterface + { + return new AhaSendTransportFactory(null, new MockHttpClient(), new NullLogger()); + } + + public static function supportsProvider(): iterable + { + yield [ + new Dsn('ahasend+api', 'default'), + true, + ]; + + yield [ + new Dsn('ahasend', 'default'), + true, + ]; + + yield [ + new Dsn('ahasend+smtp', 'default'), + true, + ]; + + yield [ + new Dsn('ahasend+smtp', 'example.com'), + true, + ]; + } + + public static function createProvider(): iterable + { + $logger = new NullLogger(); + + yield [ + new Dsn('ahasend+api', 'default', self::USER), + new AhaSendApiTransport(self::USER, new MockHttpClient(), null, $logger), + ]; + + yield [ + new Dsn('ahasend+api', 'example.com', self::USER, '', 8080), + (new AhaSendApiTransport(self::USER, new MockHttpClient(), null, $logger))->setHost('example.com')->setPort(8080), + ]; + + yield [ + new Dsn('ahasend+api', 'example.com', self::USER, '', 8080, ['message_stream' => 'broadcasts']), + (new AhaSendApiTransport(self::USER, new MockHttpClient(), null, $logger))->setHost('example.com')->setPort(8080), + ]; + + yield [ + new Dsn('ahasend', 'default', self::USER, self::PASSWORD), + new AhaSendSmtpTransport(self::USER, self::PASSWORD, null, $logger), + ]; + + yield [ + new Dsn('ahasend+smtp', 'default', self::USER, self::PASSWORD), + new AhaSendSmtpTransport(self::USER, self::PASSWORD, null, $logger), + ]; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield [ + new Dsn('ahasend+foo', 'default', self::USER), + 'The "ahasend+foo" scheme is not supported; supported schemes for mailer "ahasend" are: "ahasend", "ahasend+api", "ahasend+smtp".', + ]; + } + + public static function incompleteDsnProvider(): iterable + { + yield [new Dsn('ahasend+api', 'default')]; + yield [new Dsn('ahasend+smtp', 'default', self::USER)]; + yield [new Dsn('ahasend', 'default', self::USER)]; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/AhaSendRequestParserTest.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/AhaSendRequestParserTest.php new file mode 100644 index 0000000000000..2a25869e97439 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/AhaSendRequestParserTest.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\AhaSend\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mailer\Bridge\AhaSend\RemoteEvent\AhaSendPayloadConverter; +use Symfony\Component\Mailer\Bridge\AhaSend\Webhook\AhaSendRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +class AhaSendRequestParserTest extends AbstractRequestParserTestCase +{ + private const SECRET = 'nxLe:L:fZLb7J_Wb3uFeWX/&z4Ed#9&DxPL%Ud&:jhpAW1gLaR%AEFwfKnwp60cC'; + + protected function createRequestParser(): RequestParserInterface + { + return new AhaSendRequestParser(new AhaSendPayloadConverter()); + } + + protected function createRequest(string $payload): Request + { + $payloadArray = json_decode($payload, true); + + $currentDir = \dirname((new \ReflectionClass(static::class))->getFileName()); + $type = str_replace('message.', '', $payloadArray['type']); + $headers = file_get_contents($currentDir.'/Fixtures/'.$type.'_headers.txt'); + $server = [ + 'Content-Type' => 'application/json', + ]; + foreach (explode("\n", $headers) as $row) { + $header = explode(':', $row); + if (2 == \count($header)) { + $server['HTTP_'.$header[0]] = $header[1]; + } + } + $payload = json_encode($payloadArray, \JSON_UNESCAPED_SLASHES); + + return Request::create('/', 'POST', [], [], [], $server, $payload); + } + + protected function getSecret(): string + { + return self::SECRET; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced.json new file mode 100644 index 0000000000000..2599c67e33fe9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced.json @@ -0,0 +1,15 @@ +{ + "type": "message.bounced", + "timestamp": "2024-10-27T19:35:58.267106256Z", + "data": { + "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87", + "event": "on_bounced", + "from": "info@example.com", + "recipient": "someone@example.com", + "subject": "Sample email for testing webhooks", + "message_id_header": "message-id-header", + "user_agent": "", + "is_bot": "", + "id": "ahasend-message-id" + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced.php new file mode 100644 index 0000000000000..b38e668903535 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced.php @@ -0,0 +1,9 @@ +setRecipientEmail('someone@example.com'); +$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', '2024-10-27T19:35:58.267106Z')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced_headers.txt b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced_headers.txt new file mode 100644 index 0000000000000..b0840a72f9f45 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/bounced_headers.txt @@ -0,0 +1,3 @@ +webhook-id:ijDIIJKmF2EmV9oZ7B9t5Uwx9rEB0coJAGeVxhJgyU0UBIWeXcyzMgW6KfG3Iwel +webhook-timestamp:1730057863 +webhook-signature:v1,eY+xFpew7iX16FK8dPLWepQ9XVpSmzLBlUx7fqLBStw= diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked.json new file mode 100644 index 0000000000000..4c5a17fb5af3d --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked.json @@ -0,0 +1,17 @@ +{ + "type": "message.clicked", + "timestamp": "2024-10-28T18:30:01.7994491Z", + "data": { + "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87", + "event": "on_clicked", + "from": "info@example.com", + "recipient": "someone@example.com", + "subject": "Sample email for testing webhooks", + "message_id_header": "message-id-header", + "url": "https://ahasend.com", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246", + "ip": "1.2.3.4", + "id": "ahasend-message-id", + "is_bot": false + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked.php new file mode 100644 index 0000000000000..aeda8913a27ef --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked.php @@ -0,0 +1,9 @@ +setRecipientEmail('someone@example.com'); +$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', '2024-10-28T18:30:01.799449Z')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked_headers.txt b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked_headers.txt new file mode 100644 index 0000000000000..cbd19e7d60e35 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/clicked_headers.txt @@ -0,0 +1,3 @@ +webhook-id:rxIB4LsE0TB0OxIyQQuEHhZ3GEIgYOKVlJ0u30g5xiEFQ8NiZqIsE9Vl8KDUtvy9 +webhook-timestamp:1730140201 +webhook-signature:v1,xGcu5GnTMTlN8qUGvKu8vu8bzTvMQfVoR6UReFjacis= diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered.json new file mode 100644 index 0000000000000..4a6fc3a05e062 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered.json @@ -0,0 +1,15 @@ +{ + "type": "message.delivered", + "timestamp": "2024-10-27T19:37:30.928534039Z", + "data": { + "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87", + "event": "on_delivered", + "from": "info@example.com", + "recipient": "someone@example.com", + "subject": "Sample email for testing webhooks", + "message_id_header": "message-id-header", + "user_agent": "", + "is_bot": "", + "id": "ahasend-message-id" + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered.php new file mode 100644 index 0000000000000..7f802156ba3ef --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered.php @@ -0,0 +1,9 @@ +setRecipientEmail('someone@example.com'); +$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', '2024-10-27T19:37:30.928534Z')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered_headers.txt b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered_headers.txt new file mode 100644 index 0000000000000..960bf966c9c41 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/delivered_headers.txt @@ -0,0 +1,3 @@ +webhook-id:mA5gq7fS2T3jAVOpJZVHX077DYrpZv3IOe0CULKb2aBIqgAxZoRpuWYeEgZQdK3m +webhook-timestamp:1730057850 +webhook-signature:v1,vqb6WYaPO7TO47N1/X9TGUmPcoHfAM9TpBgCM3TmaQI= diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened.json new file mode 100644 index 0000000000000..d8ed7927b4098 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened.json @@ -0,0 +1,16 @@ +{ + "type": "message.opened", + "timestamp": "2024-10-28T18:30:01.797985335Z", + "data": { + "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87", + "event": "on_opened", + "from": "info@example.com", + "recipient": "someone@example.com", + "subject": "Sample email for testing webhooks", + "message_id_header": "message-id-header", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246", + "ip": "1.2.3.4", + "is_bot": "", + "id": "ahasend-message-id" + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened.php new file mode 100644 index 0000000000000..7c324b0c10390 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened.php @@ -0,0 +1,9 @@ +setRecipientEmail('someone@example.com'); +$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', '2024-10-28T18:30:01.797985Z')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened_headers.txt b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened_headers.txt new file mode 100644 index 0000000000000..9f87a715e4f5b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/opened_headers.txt @@ -0,0 +1,3 @@ +webhook-id:CbxBj2jz0iDsncSDo4cQROSnRG2UaArVNKRyd6vq8Ds7MbYf0Wnu9tBC0HbTTtNO +webhook-timestamp:1730140201 +webhook-signature:v1,qxyLy8OGBe0vs1T7ht/0XbMNlsPClvqQJL5Es7qQWu4= diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception.json new file mode 100644 index 0000000000000..6519b5c8f2f87 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception.json @@ -0,0 +1,15 @@ +{ + "type": "message.reception", + "timestamp": "2024-10-27T19:37:30.92621021Z", + "data": { + "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87", + "event": "on_reception", + "from": "info@example.com", + "recipient": "someone@example.com", + "subject": "Sample email for testing webhooks", + "message_id_header": "message-id-header", + "user_agent": "", + "is_bot": "", + "id": "ahasend-message-id" + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception.php new file mode 100644 index 0000000000000..03d3072a6cc25 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception.php @@ -0,0 +1,9 @@ +setRecipientEmail('someone@example.com'); +$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', '2024-10-27T19:37:30.926210Z')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception_headers.txt b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception_headers.txt new file mode 100644 index 0000000000000..b24ad37e17602 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/reception_headers.txt @@ -0,0 +1,3 @@ +webhook-id:0WLfjto3SldBI7vODvV6y9XTAFcNLQeyLzj0ZM2YEDeLWOI43L0ulHgXwtVNu0pG +webhook-timestamp:1730057850 +webhook-signature:v1,/ILaCCEKLOaeH8rL9TT8zkgeoWwMnRy41JdwlVo5ZSk= diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed.json new file mode 100644 index 0000000000000..1ebdaf6bbabed --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed.json @@ -0,0 +1,15 @@ +{ + "type": "message.suppressed", + "timestamp": "2024-10-27T19:37:30.935569544Z", + "data": { + "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87", + "event": "on_suppressed", + "from": "info@example.com", + "recipient": "someone@example.com", + "subject": "Sample email for testing webhooks", + "message_id_header": "message-id-header", + "user_agent": "", + "is_bot": "", + "id": "ahasend-message-id" + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed.php new file mode 100644 index 0000000000000..303bc6835113b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed.php @@ -0,0 +1,9 @@ +setRecipientEmail('someone@example.com'); +$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', '2024-10-27T19:37:30.935569Z')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed_headers.txt b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed_headers.txt new file mode 100644 index 0000000000000..b21c37890da56 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/suppressed_headers.txt @@ -0,0 +1,3 @@ +webhook-id:Gg09J0HB07f8gd7pi6B6JSh0tU6jVrc0j8JpVfXaTOV7w9bb7bcDc0upcqEsSTXd +webhook-timestamp:1730057850 +webhook-signature:v1,SkV4R0DJccOQJ0pzXadnNtIIDzH7rAduPVOaXvVk/Ss= diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error.json new file mode 100644 index 0000000000000..4639114498f7b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error.json @@ -0,0 +1,15 @@ +{ + "type": "message.transient_error", + "timestamp": "2024-10-27T19:37:30.929792119Z", + "data": { + "account_id": "4cdd7bdd-294e-4762-892f-83d40abf5a87", + "event": "on_transient_error", + "from": "info@example.com", + "recipient": "someone@example.com", + "subject": "Sample email for testing webhooks", + "message_id_header": "message-id-header", + "user_agent": "", + "is_bot": "", + "id": "ahasend-message-id" + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error.php new file mode 100644 index 0000000000000..9cc3b78a3f6f5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error.php @@ -0,0 +1,9 @@ +setRecipientEmail('someone@example.com'); +$wh->setDate(\DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', '2024-10-27T19:37:30.929792Z')); + +return $wh; diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error_headers.txt b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error_headers.txt new file mode 100644 index 0000000000000..3e8fed992f2a6 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Webhook/Fixtures/transient_error_headers.txt @@ -0,0 +1,3 @@ +webhook-id:2J5f1ZkzTKDOhfoVWkQzUn5cE0Vn0B22fJOnHfPXSmnL5i7lMi0CwE3x5DZQdtAt +webhook-timestamp:1730057850 +webhook-signature:v1,dTzgUl/tlyRltFj3HJ66m8qn4GngYJsafNiEks2hQWk= diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendApiTransport.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendApiTransport.php new file mode 100644 index 0000000000000..90ebc4957c771 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendApiTransport.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\AhaSend\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Bridge\AhaSend\Event\AhaSendDeliveryEvent; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\HttpTransportException; +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 Farhad Hedayatifard + */ +final class AhaSendApiTransport extends AbstractApiTransport +{ + private const HOST = 'send.ahasend.com'; + + public function __construct( + #[\SensitiveParameter] private readonly string $apiKey, + ?HttpClientInterface $client = null, + private ?EventDispatcherInterface $dispatcher = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($client, $dispatcher, $logger); + } + + public function __toString(): string + { + return \sprintf('ahasend+api://%s', $this->getEndpoint()); + } + + protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface + { + $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/v1/email/send', [ + 'json' => $this->getPayload($email, $envelope), + 'headers' => [ + 'X-Api-Key' => $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 AhaSend server.', $response, 0, $e); + } + + if (201 !== $statusCode) { + throw new HttpTransportException('Unable to send an email: '.$response->getContent(false).\sprintf(' (code %d).', $statusCode), $response); + } + + if ($result['fail_count'] > 0) { + if (null !== $this->dispatcher) { + foreach ($result['errors'] as $error) { + $this->dispatcher->dispatch(new AhaSendDeliveryEvent($error)); + } + } + } + return $response; + } + + /** + * @param Address[] $addresses + * + * @return list + */ + private function formatAddresses(array $addresses): array + { + return array_map(fn (Address $address) => $this->formatAddress($address), $addresses); + } + + private function getPayload(Email $email, Envelope $envelope): array + { + // "From" and "Subject" headers are handled by the message itself + $payload = [ + 'recipients' => $this->formatAddresses($envelope->getRecipients()), + 'from' => $this->formatAddress($envelope->getSender()), + 'content' => [ + 'subject' => $email->getSubject(), + ], + ]; + + + $text = $email->getTextBody(); + if (!empty($text)) { + $payload['content']['text_body'] = $text; + } + $html = $email->getHtmlBody(); + if (!empty($html)) { + $payload['content']['html_body'] = $html; + } + + $replyTo = $email->getReplyTo(); + if ($replyTo) { + $replyTo = array_pop($replyTo); + $payload['content']['reply_to'] = $this->formatAddress($replyTo); + } + + $headers = $this->prepareHeaders($email->getHeaders()); + if (!empty($headers)) { + $payload['content']['headers'] = $headers; + } + + if ($email->getAttachments()) { + $payload['content']['attachments'] = $this->getAttachments($email); + } + + return $payload; + } + + private function prepareHeaders(Headers $headers): array + { + $headersPrepared = []; + // AhaSend API does not accept these headers. + $headersToBypass = ['To', 'From', 'Subject', 'Reply-To']; + $tags = []; + foreach ($headers->all() as $header) { + if (\in_array($header->getName(), $headersToBypass, true)) { + continue; + } + + if ($header instanceof TagHeader) { + $tags[] = $header->getValue(); + $headers->remove($header->getName()); + continue; + } + + $headersPrepared[$header->getName()] = $header->getBodyAsString(); + } + if (!empty($tags)) { + $tagsStr = implode(",", $tags); + $headers->addTextHeader('AhaSend-Tags', $tagsStr); + $headersPrepared['AhaSend-Tags'] = $tagsStr; + } + + return $headersPrepared; + } + + private function getAttachments(Email $email): array + { + $attachments = []; + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + + $contentType = $headers->get('Content-Type')->getBody(); + $base64 = ('text/plain' !== $contentType); + $disposition = $headers->getHeaderBody('Content-Disposition'); + + if ($base64) { + $body = base64_encode($attachment->getBody()); + } else { + $body = $attachment->getBody(); + } + + $att = [ + 'content_type' => $headers->get('Content-Type')->getBody(), + 'file_name' => $attachment->getFilename(), + 'data' => $body, + 'base64' => $base64, + ]; + + + if ($attachment->hasContentId()) { + $att['content_id'] = $attachment->getContentId(); + } elseif ('inline' === $disposition) { + $att['content_id'] = $attachment->getFilename(); + } + + $attachments[] = $att; + } + + return $attachments; + } + + private function formatAddress(Address $address): array + { + $formattedAddress = ['email' => $address->getEncodedAddress()]; + + if ($address->getName()) { + $formattedAddress['name'] = $address->getName(); + } + + return $formattedAddress; + } + + private function getEndpoint(): ?string + { + return ($this->host ?: self::HOST).($this->port ? ':'.$this->port : ''); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendSmtpTransport.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendSmtpTransport.php new file mode 100644 index 0000000000000..70d66b8931112 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendSmtpTransport.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\AhaSend\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Header\MetadataHeader; +use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\RawMessage; + +/** + * @author Farhad Hedayatifard + */ +class AhaSendSmtpTransport extends EsmtpTransport +{ + public function __construct(#[\SensitiveParameter] string $username, #[\SensitiveParameter] string $password, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null) + { + parent::__construct('send.ahasend.com', 587, false, $dispatcher, $logger); + + $this->setUsername($username); + $this->setPassword($password); + } + + public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage + { + if ($message instanceof Message) { + $this->addAhaSendHeaders($message); + } + + return parent::send($message, $envelope); + } + + private function addAhaSendHeaders(Message $message): void + { + $headers = $message->getHeaders(); + + foreach ($headers->all() as $name => $header) { + if ($header instanceof TagHeader) { + $tags[] = $header->getValue(); + $headers->remove($name); + } + } + if (!empty($tags)) { + $headers->addTextHeader('AhaSend-Tags', implode(",", $tags)); + } + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendTransportFactory.php new file mode 100644 index 0000000000000..f64d3631ccc6a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendTransportFactory.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\AhaSend\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 Farhad Hedayatifard + */ +final class AhaSendTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $transport = null; + $scheme = $dsn->getScheme(); + $user = $this->getUser($dsn); + + if ('ahasend+api' === $scheme) { + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + $transport = (new AhaSendApiTransport($user, $this->client, $this->dispatcher, $this->logger))->setHost($host)->setPort($port); + } + + if ('ahasend+smtp' === $scheme || 'ahasend' === $scheme) { + $password = $this->getPassword($dsn); + $transport = new AhaSendSmtpTransport($user, $password, $this->dispatcher, $this->logger); + } + + if (null !== $transport) { + return $transport; + } + + throw new UnsupportedSchemeException($dsn, 'ahasend', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['ahasend', 'ahasend+api', 'ahasend+smtp']; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php new file mode 100644 index 0000000000000..761f929caae51 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\AhaSend\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\RequestMatcherInterface; +use Symfony\Component\Mailer\Bridge\AhaSend\RemoteEvent\AhaSendPayloadConverter; +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 AhaSendRequestParser extends AbstractRequestParser +{ + public function __construct( + private readonly AhaSendPayloadConverter $converter, + ) { + } + + protected function getRequestMatcher(): RequestMatcherInterface + { + return new ChainRequestMatcher([ + new MethodRequestMatcher('POST'), + new IsJsonRequestMatcher(), + ]); + } + + protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?AbstractMailerEvent + { + $payload = $request->toArray(); + $eventID = $request->headers->get('webhook-id'); + $signature = $request->headers->get('webhook-signature'); + $timestamp = $request->headers->get('webhook-timestamp'); + if (empty($eventID) || empty($signature) || empty($timestamp)) { + throw new RejectWebhookException(406, 'Signature is required.'); + } + if (!is_numeric($timestamp) || is_float($timestamp+0) || (int)$timestamp != $timestamp || (int)$timestamp <= 0) { + throw new RejectWebhookException(406, 'Invalid timestamp.'); + } + $expectedSignature = $this->sign($eventID, $timestamp, $request->getContent(), $secret); + if ($signature !== $expectedSignature) { + throw new RejectWebhookException(406, 'Invalid signature'); + } + if ( + !isset($payload['type']) + || !isset($payload['timestamp']) + || !(isset($payload['data'])) + ) { + throw new RejectWebhookException(406, 'Payload is malformed.'); + } + + try { + return $this->converter->convert($payload); + } catch (ParseException $e) { + throw new RejectWebhookException(406, $e->getMessage(), $e); + } + } + + private function sign(string $eventID, string $timestamp, string $payload, $secret) : string { + $signaturePayload = "{$eventID}.{$timestamp}.{$payload}"; + $hash = hash_hmac('sha256', $signaturePayload, $secret); + $signature = base64_encode(pack('H*', $hash)); + return "v1,{$signature}"; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json new file mode 100644 index 0000000000000..b4ef89db48857 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json @@ -0,0 +1,37 @@ +{ + "name": "symfony/ahasend-mailer", + "type": "symfony-mailer-bridge", + "description": "Symfony AhaSend Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Farhad Hedayatifard", + "email": "farhad@ahasend.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "symfony/mailer": "^7.2" + }, + "require-dev": { + "symfony/http-client": "^6.4|^7.0", + "symfony/webhook": "^6.4|^7.0" + }, + "conflict": { + "symfony/http-foundation": "<6.4" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\AhaSend\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/AhaSend/phpunit.xml.dist new file mode 100644 index 0000000000000..389258219b0c9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/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 5f25c8a0f609d..2239208cb4189 100644 --- a/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Mailer/Exception/UnsupportedSchemeException.php @@ -20,6 +20,10 @@ class UnsupportedSchemeException extends LogicException { private const SCHEME_TO_PACKAGE_MAP = [ + 'ahasend' => [ + 'class' => Bridge\AhaSend\Transport\AhaSendTransportFactory::class, + 'package' => 'symfony/ahasend-mailer', + ], 'azure' => [ 'class' => Bridge\Azure\Transport\AzureTransportFactory::class, 'package' => 'symfony/azure-mailer', diff --git a/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php b/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php index b4d00dd38b4f4..bfd1cb415c3c8 100644 --- a/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php +++ b/src/Symfony/Component/Mailer/Tests/Exception/UnsupportedSchemeExceptionTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ClassExistsMock; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendTransportFactory; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureTransportFactory; use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoTransportFactory; @@ -43,6 +44,7 @@ public static function setUpBeforeClass(): void { ClassExistsMock::register(__CLASS__); ClassExistsMock::withMockedClasses([ + AhaSendTransportFactory::class => false, AzureTransportFactory::class => false, BrevoTransportFactory::class => false, GmailTransportFactory::class => false, @@ -79,6 +81,7 @@ public function testMessageWhereSchemeIsPartOfSchemeToPackageMap(string $scheme, public static function messageWhereSchemeIsPartOfSchemeToPackageMapProvider(): \Generator { + yield ['ahasend', 'symfony/ahasend-mailer']; yield ['azure', 'symfony/azure-mailer']; yield ['brevo', 'symfony/brevo-mailer']; yield ['gmail', 'symfony/google-mailer']; diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php index 026e033af0525..370f692804364 100644 --- a/src/Symfony/Component/Mailer/Transport.php +++ b/src/Symfony/Component/Mailer/Transport.php @@ -13,6 +13,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendTransportFactory; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureTransportFactory; use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoTransportFactory; @@ -52,6 +53,7 @@ final class Transport { private const FACTORY_CLASSES = [ + AhaSendTransportFactory::class, AzureTransportFactory::class, BrevoTransportFactory::class, GmailTransportFactory::class, From 227eec9b22d85e2000d190ab787abe4562d36f3d Mon Sep 17 00:00:00 2001 From: Farhad Hedayatifard Date: Fri, 29 Nov 2024 10:53:57 +0100 Subject: [PATCH 2/6] Target Symfony 7.3 --- src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md | 2 +- src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md index 8ce83a620ba33..5c447af01bd7e 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md @@ -1,6 +1,6 @@ CHANGELOG ========= -7.2 +7.3 --- * Added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json b/src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json index b4ef89db48857..a10d223a65cf5 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json @@ -18,15 +18,12 @@ "require": { "php": ">=8.2", "psr/event-dispatcher": "^1", - "symfony/mailer": "^7.2" + "symfony/mailer": "^7.3" }, "require-dev": { "symfony/http-client": "^6.4|^7.0", "symfony/webhook": "^6.4|^7.0" }, - "conflict": { - "symfony/http-foundation": "<6.4" - }, "autoload": { "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\AhaSend\\": "" }, "exclude-from-classmap": [ From 38bbccb3f906819e700925456befa6dad194274e Mon Sep 17 00:00:00 2001 From: Farhad Hedayatifard Date: Fri, 29 Nov 2024 10:54:42 +0100 Subject: [PATCH 3/6] Fix coding style issues. --- .../FrameworkBundle/Resources/config/mailer_webhook.php | 2 +- src/Symfony/Component/Mailer/Bridge/AhaSend/README.md | 2 +- .../AhaSend/RemoteEvent/AhaSendPayloadConverter.php | 3 +-- .../Bridge/AhaSend/Transport/AhaSendApiTransport.php | 2 +- .../Bridge/AhaSend/Transport/AhaSendTransportFactory.php | 6 +++--- .../Bridge/AhaSend/Webhook/AhaSendRequestParser.php | 9 +++------ 6 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php index 278e2b81c3573..b815336b2528f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php @@ -90,7 +90,7 @@ ->set('mailer.payload_converter.ahasend', AhaSendPayloadConverter::class) ->set('mailer.webhook.request_parser.ahasend', AhaSendRequestParser::class) - ->args([service('mailer.payload_converter.ahasend')]) + ->args([service('mailer.payload_converter.ahasend')]) ->alias(AhaSendRequestParser::class, 'mailer.webhook.request_parser.ahasend') ->set('mailer.payload_converter.mailchimp', MailchimpPayloadConverter::class) diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/README.md b/src/Symfony/Component/Mailer/Bridge/AhaSend/README.md index 0d5dfc4b76813..8e118251522ac 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/README.md +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/README.md @@ -1,5 +1,5 @@ AhaSend Bridge -=============== +============== Provides AhaSend integration for Symfony Mailer. diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/RemoteEvent/AhaSendPayloadConverter.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/RemoteEvent/AhaSendPayloadConverter.php index ae677987e6855..5f411bdf670ef 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/RemoteEvent/AhaSendPayloadConverter.php +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/RemoteEvent/AhaSendPayloadConverter.php @@ -46,8 +46,7 @@ public function convert(array $payload): AbstractMailerEvent // AhaSend sends timestamps with 9 decimal places for nanosecond precision, // truncate to 6 decimal places for microseconds. $truncatedTimestamp = substr($payload['timestamp'], 0, 26).'Z'; - $date = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $payload['timestamp']) - ?: \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', $truncatedTimestamp); + $date = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::ATOM, $payload['timestamp']) ?: \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uT', $truncatedTimestamp); if (!$date) { throw new ParseException(\sprintf('Invalid date "%s".', $payload['timestamp'])); } diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendApiTransport.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendApiTransport.php index 90ebc4957c771..496557addf9ed 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendApiTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendApiTransport.php @@ -164,7 +164,7 @@ private function getAttachments(Email $email): array $headers = $attachment->getPreparedHeaders(); $contentType = $headers->get('Content-Type')->getBody(); - $base64 = ('text/plain' !== $contentType); + $base64 = 'text/plain' !== $contentType; $disposition = $headers->getHeaderBody('Content-Disposition'); if ($base64) { diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendTransportFactory.php index f64d3631ccc6a..d036cb50248b8 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendTransportFactory.php +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Transport/AhaSendTransportFactory.php @@ -39,11 +39,11 @@ public function create(Dsn $dsn): TransportInterface $transport = new AhaSendSmtpTransport($user, $password, $this->dispatcher, $this->logger); } - if (null !== $transport) { - return $transport; + if (null === $transport) { + throw new UnsupportedSchemeException($dsn, 'ahasend', $this->getSupportedSchemes()); } - throw new UnsupportedSchemeException($dsn, 'ahasend', $this->getSupportedSchemes()); + return $transport; } protected function getSupportedSchemes(): array diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php index 761f929caae51..773453be64c84 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php @@ -53,11 +53,7 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr if ($signature !== $expectedSignature) { throw new RejectWebhookException(406, 'Invalid signature'); } - if ( - !isset($payload['type']) - || !isset($payload['timestamp']) - || !(isset($payload['data'])) - ) { + if (!isset($payload['type']) || !isset($payload['timestamp']) || !(isset($payload['data']))) { throw new RejectWebhookException(406, 'Payload is malformed.'); } @@ -68,7 +64,8 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr } } - private function sign(string $eventID, string $timestamp, string $payload, $secret) : string { + private function sign(string $eventID, string $timestamp, string $payload, $secret) : string + { $signaturePayload = "{$eventID}.{$timestamp}.{$payload}"; $hash = hash_hmac('sha256', $signaturePayload, $secret); $signature = base64_encode(pack('H*', $hash)); From d148c09631ca58f6b617661dd1300cffa0f9a1fe Mon Sep 17 00:00:00 2001 From: Farhad Hedayatifard Date: Fri, 29 Nov 2024 10:56:05 +0100 Subject: [PATCH 4/6] Fix wording in changelog. --- src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md index 5c447af01bd7e..20bfa193845de 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md @@ -3,4 +3,4 @@ CHANGELOG 7.3 --- - * Added the bridge + * Add the bridge From aa92473f647be624666fc8f2fa6bf33f5da0f2fc Mon Sep 17 00:00:00 2001 From: Farhad Hedayatifard Date: Fri, 29 Nov 2024 11:49:39 +0100 Subject: [PATCH 5/6] Update tests. --- .../Transport/AhaSendApiTransportTest.php | 106 +++++++----------- 1 file changed, 43 insertions(+), 63 deletions(-) diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php index c32cea1254692..f84dcc7ac0137 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php @@ -63,76 +63,56 @@ public function testSend() ->html('

Test email body

') ->replyTo(new Address('bar2@example.com', 'Mr. Recipient')); - $response = $this->createMock(JsonMockResponse::class); - $response->method('toArray')->willReturn([ - 'success_count' => 2, - 'fail_count' => 0, - 'failed_recipients' => [], - 'errors' => [], - ]); - $response - ->expects($this->once()) - ->method('getStatusCode') - ->willReturn(201); - $httpClient = $this->createMock(MockHttpClient::class); - $httpClient - ->expects($this->once()) - ->method('request') - ->with('POST', 'https://send.ahasend.com/v1/email/send', [ - 'json' => [ - 'from' => [ - 'email' => 'foo@example.com', - 'name' => 'Ms. Foo Bar', - ], - 'recipients' => [ - [ - 'email' => 'bar@example.com', - 'name' => 'Mr. Recipient', - ], - [ - 'email' => 'baz@example.com', - ], - ], - 'content' => [ - 'subject' => 'An email', - 'text_body' => 'Test email body', - 'html_body' => '

Test email body

', - 'reply_to' => [ - 'email' => 'bar2@example.com', - 'name' => 'Mr. Recipient', - ], - 'headers' => [ - 'Bcc' => 'baz@example.com', - ], - ], - ], - 'headers' => [ - 'X-Api-Key' => 'foo', - ], - ]) - ->willReturn($response); - - $mailer = new AhaSendApiTransport('foo', $httpClient); - $mailer->send($email); - } - - public function testSendDeliveryEventIsDispatched() - { $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://send.ahasend.com/v1/email/send', $url); + $this->assertStringContainsString('X-Api-Key: foo', $options['headers'][0] ?? $options['request_headers'][0]); + + $body = json_decode($options['body'], true); + $this->assertSame('foo@example.com', $body['from']['email']); + $this->assertSame('Ms. Foo Bar', $body['from']['name']); + $this->assertSame('bar@example.com', $body['recipients'][0]['email']); + $this->assertSame('Mr. Recipient', $body['recipients'][0]['name']); + $this->assertSame('baz@example.com', $body['recipients'][1]['email']); + $this->assertArrayNotHasKey('name', $body['recipients'][1]); + $this->assertSame('An email', $body['content']['subject']); + $this->assertSame('Test email body', $body['content']['text_body']); + $this->assertSame('

Test email body

', $body['content']['html_body']); + $this->assertSame('bar2@example.com', $body['content']['reply_to']['email']); + $this->assertSame('Mr. Recipient', $body['content']['reply_to']['name']); + $this->assertSame('baz@example.com', $body['content']['headers']['Bcc']); + return new JsonMockResponse([ - 'success_count' => 0, - 'fail_count' => 1, - 'failed_recipients' => [ - 'someone@gmil.com', - ], - 'errors' => [ - 'someone@gmil.com: Invalid recipient', - ], + 'success_count' => 3, + 'fail_count' => 0, + 'failed_recipients' => [], + 'errors' => [], ], [ 'http_code' => 201, ]); }); + + $mailer = new AhaSendApiTransport('foo', $client); + $mailer->send($email); + } + + public function testSendDeliveryEventIsDispatched() + { + $responseFactory = new JsonMockResponse([ + 'success_count' => 0, + 'fail_count' => 1, + 'failed_recipients' => [ + 'someone@gmil.com', + ], + 'errors' => [ + 'someone@gmil.com: Invalid recipient', + ], + ], [ + 'http_code' => 201, + ]); + $client = new MockHttpClient($responseFactory); + $email = new Email(); $email->from(new Address('foo@example.com', 'Ms. Foo Bar')) ->to(new Address('someone@gmil.com', 'Mr. Someone')) From 30274754bef8fc9028b47ad038ce8877ae16285a Mon Sep 17 00:00:00 2001 From: Farhad Hedayatifard Date: Fri, 29 Nov 2024 11:53:17 +0100 Subject: [PATCH 6/6] Coding standard fix --- .../Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php index f84dcc7ac0137..2f9e09ede715a 100644 --- a/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php @@ -92,7 +92,6 @@ public function testSend() ]); }); - $mailer = new AhaSendApiTransport('foo', $client); $mailer->send($email); } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy