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..b815336b2528f 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..20bfa193845de --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/CHANGELOG.md @@ -0,0 +1,6 @@ +CHANGELOG +========= + +7.3 +--- + * Add 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..8e118251522ac --- /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..5f411bdf670ef --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/RemoteEvent/AhaSendPayloadConverter.php @@ -0,0 +1,58 @@ + + * + * 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..2f9e09ede715a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Tests/Transport/AhaSendApiTransportTest.php @@ -0,0 +1,306 @@ + + * + * 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')); + + $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' => 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')) + ->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..496557addf9ed --- /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..d036cb50248b8 --- /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) { + throw new UnsupportedSchemeException($dsn, 'ahasend', $this->getSupportedSchemes()); + } + + return $transport; + } + + 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..773453be64c84 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/Webhook/AhaSendRequestParser.php @@ -0,0 +1,74 @@ + + * + * 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..a10d223a65cf5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/AhaSend/composer.json @@ -0,0 +1,34 @@ +{ + "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.3" + }, + "require-dev": { + "symfony/http-client": "^6.4|^7.0", + "symfony/webhook": "^6.4|^7.0" + }, + "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, 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