From dec3110c5a9ccc6db3819ef6cd7d4849a1eef4a4 Mon Sep 17 00:00:00 2001 From: Rafael Villa Verde Date: Fri, 1 Dec 2023 01:03:58 -0300 Subject: [PATCH] [Mailer] Add Azure bridge --- .../FrameworkExtension.php | 1 + .../Mailer/Bridge/Azure/.gitattributes | 4 + .../Component/Mailer/Bridge/Azure/.gitignore | 2 + .../Mailer/Bridge/Azure/CHANGELOG.md | 7 + .../Component/Mailer/Bridge/Azure/LICENSE | 19 ++ .../Component/Mailer/Bridge/Azure/README.md | 28 ++ .../Tests/Transport/AzureApiTransportTest.php | 128 ++++++++ .../Transport/AzureTransportFactoryTest.php | 79 +++++ .../Azure/Transport/AzureApiTransport.php | 282 ++++++++++++++++++ .../Azure/Transport/AzureTransportFactory.php | 42 +++ .../Mailer/Bridge/Azure/composer.json | 32 ++ .../Mailer/Bridge/Azure/phpunit.xml.dist | 31 ++ src/Symfony/Component/Mailer/Transport.php | 2 + 13 files changed, 657 insertions(+) create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/.gitattributes create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/.gitignore create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/CHANGELOG.md create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/LICENSE create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/README.md create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/Tests/Transport/AzureApiTransportTest.php create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/Tests/Transport/AzureTransportFactoryTest.php create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureTransportFactory.php create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/composer.json create mode 100644 src/Symfony/Component/Mailer/Bridge/Azure/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 95c8d8fa11019..8272d24c7737c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2559,6 +2559,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co } $classToServices = [ + 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', MailerBridge\Infobip\Transport\InfobipTransportFactory::class => 'mailer.transport_factory.infobip', diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/.gitattributes b/src/Symfony/Component/Mailer/Bridge/Azure/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/.gitignore b/src/Symfony/Component/Mailer/Bridge/Azure/.gitignore new file mode 100644 index 0000000000000..d1502b087b4d4 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Azure/CHANGELOG.md new file mode 100644 index 0000000000000..5be39cbeeb951 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.1 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/LICENSE b/src/Symfony/Component/Mailer/Bridge/Azure/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-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/Azure/README.md b/src/Symfony/Component/Mailer/Bridge/Azure/README.md new file mode 100644 index 0000000000000..acd9cc25abb53 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/README.md @@ -0,0 +1,28 @@ +Microsoft Azure Mailer +====================== + +Provides [Azure Communication Services Email](https://learn.microsoft.com/en-us/azure/communication-services/concepts/email/email-overview) integration for Symfony Mailer. + +Configuration example: + +```env +# API +MAILER_DSN=azure+api://ACS_RESOURCE_NAME:KEY@default + +#API with options + +MAILER_DSN=azure+api://ACS_RESOURCE_NAME:KEY@default?api_version=2023-03-31&disable_tracking=false +``` + +where: + - `ACS_RESOURCE_NAME` is your Azure Communication Services endpoint resource name (https://ACS_RESOURCE_NAME.communication.azure.com) + - `KEY` is your Azure Communication Services Email API Key + +Resources +--------- + + * [Microsoft Azure (ACS) Email API Docs](https://learn.microsoft.com/en-us/rest/api/communication/dataplane/email/send) + * [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) \ No newline at end of file diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/Tests/Transport/AzureApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Azure/Tests/Transport/AzureApiTransportTest.php new file mode 100644 index 0000000000000..196cdb7b6b1b7 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/Tests/Transport/AzureApiTransportTest.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Azure\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureApiTransport; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Header\MetadataHeader; +use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class AzureApiTransportTest extends TestCase +{ + /** + * @dataProvider getTransportData + */ + public function testToString(AzureApiTransport $transport, string $expected) + { + $this->assertSame($expected, (string) $transport); + } + + public static function getTransportData(): array + { + return [ + [ + new AzureApiTransport('KEY', 'ACS_RESOURCE_NAME'), + 'azure+api://ACS_RESOURCE_NAME.communication.azure.com', + ], + ]; + } + + 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 AzureApiTransport('KEY', 'ACS_RESOURCE_NAME'); + $method = new \ReflectionMethod(AzureApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('headers', $payload); + $this->assertArrayHasKey('foo', $payload['headers']); + $this->assertEquals('bar', $payload['headers']['foo']); + } + + public function testSend() + { + $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://my-acs-resource.communication.azure.com/emails:send?api-version=2023-03-31', $url); + + $body = json_decode($options['body'], true); + + $message = $body['content']; + $this->assertSame('normal', $body['importance']); + // $this->assertSame('Fabien', $message['from_name']); + $this->assertSame('fabpot@symfony.com', $body['senderAddress']); + $this->assertSame('Saif Eddin', $body['recipients']['to'][0]['displayName']); + $this->assertSame('saif.gmati@symfony.com', $body['recipients']['to'][0]['address']); + $this->assertSame('Hello!', $message['subject']); + $this->assertSame('Hello There!', $message['plainText']); + + return new JsonMockResponse([ + 'id' => 'foobar', + ], [ + 'http_code' => 202, + ]); + }); + + $transport = new AzureApiTransport('KEY', 'my-acs-resource', true, '2023-03-31', $client); + + $mail = new Email(); + $mail->subject('Hello!') + ->to(new Address('saif.gmati@symfony.com', 'Saif Eddin')) + ->from(new Address('fabpot@symfony.com', 'Fabien')) + ->text('Hello There!'); + + $message = $transport->send($mail); + + $this->assertSame('foobar', $message->getMessageId()); + } + + public function testTagAndMetadataHeaders() + { + $email = new Email(); + $email->getHeaders()->add(new TagHeader('category-one')); + $email->getHeaders()->add(new MetadataHeader('Color', 'blue')); + $email->getHeaders()->add(new MetadataHeader('Client-ID', '12345')); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = new AzureApiTransport('KEY', 'ACS_RESOURCE_NAME'); + $method = new \ReflectionMethod(AzureApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertArrayHasKey('headers', $payload); + $this->assertArrayHasKey('X-Tag', $payload['headers']); + $this->assertArrayHasKey('X-Metadata-Color', $payload['headers']); + $this->assertArrayHasKey('X-Metadata-Client-ID', $payload['headers']); + + $this->assertCount(3, $payload['headers']); + + $this->assertSame('category-one', $payload['headers']['X-Tag']); + $this->assertSame('blue', $payload['headers']['X-Metadata-Color']); + $this->assertSame('12345', $payload['headers']['X-Metadata-Client-ID']); + } + + public function testItDoesNotAllowToAddResourceNameWithDot() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Resource name cannot contain or end with a dot'); + + new AzureApiTransport('KEY', 'ACS_RESOURCE_NAME.'); + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/Tests/Transport/AzureTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/Azure/Tests/Transport/AzureTransportFactoryTest.php new file mode 100644 index 0000000000000..4250ed6adfac6 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/Tests/Transport/AzureTransportFactoryTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Azure\Tests\Transport; + +use Psr\Log\NullLogger; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureApiTransport; +use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureTransportFactory; +use Symfony\Component\Mailer\Test\TransportFactoryTestCase; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; + +class AzureTransportFactoryTest extends TransportFactoryTestCase +{ + public function getFactory(): TransportFactoryInterface + { + return new AzureTransportFactory(null, new MockHttpClient(), new NullLogger()); + } + + public static function supportsProvider(): iterable + { + yield [ + new Dsn('azure', 'default'), + true, + ]; + + yield [ + new Dsn('azure+api', 'default'), + true, + ]; + } + + public static function createProvider(): iterable + { + yield [ + new Dsn('azure', 'default', self::USER, self::PASSWORD), + new AzureApiTransport(self::PASSWORD, self::USER, false, '2023-03-31', new MockHttpClient(), null, new NullLogger()), + ]; + yield [ + new Dsn('azure', 'ACS_RESOURCE_NAME', self::USER, self::PASSWORD), + (new AzureApiTransport(self::PASSWORD, self::USER, false, '2023-03-31', new MockHttpClient(), null, new NullLogger()))->setHost('ACS_RESOURCE_NAME'), + ]; + yield [ + new Dsn('azure+api', 'default', self::USER, self::PASSWORD), + new AzureApiTransport(self::PASSWORD, self::USER, false, '2023-03-31', new MockHttpClient(), null, new NullLogger()), + ]; + yield [ + new Dsn('azure+api', 'ACS_RESOURCE_NAME', self::USER, self::PASSWORD), + (new AzureApiTransport(self::PASSWORD, self::USER, false, '2023-03-31', new MockHttpClient(), null, new NullLogger()))->setHost('ACS_RESOURCE_NAME'), + ]; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield [ + new Dsn('azure+foo', 'default', self::USER, self::PASSWORD), + 'The "azure+foo" scheme is not supported; supported schemes for mailer "azure" are: "azure", "azure+api".', + ]; + } + + public static function incompleteDsnProvider(): iterable + { + yield [new Dsn('azure', 'default')]; + yield [new Dsn('azure', 'default', self::USER)]; + yield [new Dsn('azure', 'default', null, self::PASSWORD)]; + yield [new Dsn('azure+api', 'default')]; + yield [new Dsn('azure+api', 'default', self::USER)]; + yield [new Dsn('azure+api', 'default', null, self::PASSWORD)]; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php new file mode 100644 index 0000000000000..375976971155f --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php @@ -0,0 +1,282 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Azure\Transport; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\HttpTransportException; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractApiTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +final class AzureApiTransport extends AbstractApiTransport +{ + private const HOST = '%s.communication.azure.com'; + + /** + * User Access Key from Azure Communication Service (Primary or Secondary key). + */ + private string $key; + + /** + * The endpoint API URL to which to POST emails to Azure + * https://{acsResourceName}.communication.azure.com/. + */ + private string $resourceName; + + /** + * The version of API to invoke. + */ + private string $apiVersion; + + /** + * Indicates whether user engagement tracking should be disabled. + */ + private bool $disableTracking; + + public function __construct(string $key, string $resourceName, bool $disableTracking = false, string $apiVersion = '2023-03-31', HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) + { + if (str_contains($resourceName, '.') || str_ends_with($resourceName, '.')) { + throw new \Exception('Resource name cannot contain or end with a dot.'); + } + + $this->resourceName = $resourceName; + $this->key = $key; + $this->apiVersion = $apiVersion; + $this->disableTracking = $disableTracking; + parent::__construct($client, $dispatcher, $logger); + } + + public function __toString(): string + { + return sprintf('azure+api://%s', $this->getAzureCSEndpoint()); + } + + /** + * Queues an email message to be sent to one or more recipients. + */ + protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface + { + $endpoint = $this->getAzureCSEndpoint().'/emails:send?api-version='.$this->apiVersion; + $payload = $this->getPayload($email, $envelope); + + $response = $this->client->request('POST', 'https://'.$endpoint, [ + 'body' => json_encode($payload), + 'headers' => $this->getSignedHeaders($payload, $email), + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new HttpTransportException('Could not reach the remote Azure server.', $response, 0, $e); + } + + if (202 !== $statusCode) { + try { + $result = $response->toArray(false); + throw new HttpTransportException('Unable to send an email (.'.$result['error']['code'].'): '.$result['error']['message'], $response, $statusCode); + } catch (DecodingExceptionInterface $e) { + throw new HttpTransportException('Unable to send an email: '.$response->getContent(false).sprintf(' (code %d).', $statusCode), $response, 0, $e); + } + } + + $sentMessage->setMessageId(json_decode($response->getContent(false), true)['id']); + + return $response; + } + + /** + * Get the message request body. + */ + private function getPayload(Email $email, Envelope $envelope): array + { + $addressStringifier = function (Address $address) { + $stringified = ['address' => $address->getAddress()]; + + if ($address->getName()) { + $stringified['displayName'] = $address->getName(); + } + + return $stringified; + }; + + $data = [ + 'content' => [ + 'html' => $email->getHtmlBody(), + 'plainText' => $email->getTextBody(), + 'subject' => $email->getSubject(), + ], + 'recipients' => [ + 'to' => array_map($addressStringifier, $this->getRecipients($email, $envelope)), + ], + 'senderAddress' => $envelope->getSender()->getAddress(), + 'attachments' => $this->getMessageAttachments($email), + 'userEngagementTrackingDisabled' => $this->disableTracking, + 'headers' => empty($headers = $this->getMessageCustomHeaders($email)) ? null : $headers, + 'importance' => $this->getPriorityLevel($email->getPriority()), + ]; + + if ($emails = array_map($addressStringifier, $email->getCc())) { + $data['recipients']['cc'] = $emails; + } + + if ($emails = array_map($addressStringifier, $email->getBcc())) { + $data['recipients']['bcc'] = $emails; + } + + if ($emails = array_map($addressStringifier, $email->getReplyTo())) { + $data['replyTo'] = $emails; + } + + return $data; + } + + /** + * List of attachments. Please note that the service limits the total size + * of an email request (which includes attachments) to 10 MB. + */ + private function getMessageAttachments(Email $email): array + { + $attachments = []; + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); + $disposition = $headers->getHeaderBody('Content-Disposition'); + + $att = [ + 'name' => $filename, + 'contentInBase64' => base64_encode(str_replace("\r\n", '', $attachment->bodyToString())), + 'contentType' => $headers->get('Content-Type')->getBody(), + ]; + + if ('inline' === $disposition) { + $att['content_id'] = $filename; + } + + $attachments[] = $att; + } + + return $attachments; + } + + /** + * The communication domain host, for example my-acs-resource-name.communication.azure.com. + */ + private function getAzureCSEndpoint(): string + { + return !empty($this->host) ? $this->host : sprintf(self::HOST, $this->resourceName); + } + + private function generateContentHash(string $content): string + { + return base64_encode(hash('sha256', $content, true)); + } + + /** + * Generate sha256 hash and encode to base64 to produces the digest string. + */ + private function generateAuthenticationSignature(string $content): string + { + $key = base64_decode($this->key); + $hashedBytes = hash_hmac('sha256', mb_convert_encoding($content, 'UTF-8'), $key, true); + + return base64_encode($hashedBytes); + } + + /** + * Get authenticated headers for signed request,. + */ + private function getSignedHeaders(array $payload, Email $message): array + { + // HTTP Method verb (uppercase) + $verb = 'POST'; + + // Request time + $datetime = new \DateTime('now', new \DateTimeZone('UTC')); + $utcNow = $datetime->format('D, d M Y H:i:s \G\M\T'); + + // Content hash signature + $contentHash = $this->generateContentHash(json_encode($payload)); + + // ACS Endpoint + $host = str_replace('https://', '', $this->getAzureCSEndpoint()); + + // Sendmail endpoint from communication email delivery service + $urlPathAndQuery = '/emails:send?api-version='.$this->apiVersion; + + // Signed request headers + $stringToSign = "{$verb}\n{$urlPathAndQuery}\n{$utcNow};{$host};{$contentHash}"; + + // Authenticate headers with ACS primary or secondary key + $signature = $this->generateAuthenticationSignature($stringToSign); + + // get GUID part of message id to identify the long running operation + $messageId = $this->generateMessageId(); + + return [ + 'Content-Type' => 'application/json', + 'repeatability-request-id' => $messageId, + 'Operation-Id' => $messageId, + 'repeatability-first-sent' => $utcNow, + 'x-ms-date' => $utcNow, + 'x-ms-content-sha256' => $contentHash, + 'x-ms-client-request-id' => $messageId, + 'Authorization' => "HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature={$signature}", + ]; + } + + /** + * Can be used to identify the long running operation. + */ + private function generateMessageId(): string + { + $data = random_bytes(16); + \assert(16 == \strlen($data)); + $data[6] = \chr(\ord($data[6]) & 0x0F | 0x40); + $data[8] = \chr(\ord($data[8]) & 0x3F | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + private function getMessageCustomHeaders(Email $email): array + { + $headers = []; + + $headersToBypass = ['x-ms-client-request-id', 'operation-id', 'authorization', 'x-ms-content-sha256', 'received', 'dkim-signature', 'content-transfer-encoding', 'from', 'to', 'cc', 'bcc', 'subject', 'content-type', 'reply-to']; + + foreach ($email->getHeaders()->all() as $name => $header) { + if (\in_array($name, $headersToBypass, true)) { + continue; + } + $headers[$header->getName()] = $header->getBodyAsString(); + } + + return $headers; + } + + private function getPriorityLevel(string $priority): ?string + { + return match ((int) $priority) { + Email::PRIORITY_HIGHEST => 'highest', + Email::PRIORITY_HIGH => 'high', + Email::PRIORITY_NORMAL => 'normal', + Email::PRIORITY_LOW => 'low', + Email::PRIORITY_LOWEST => 'lowest', + }; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureTransportFactory.php new file mode 100644 index 0000000000000..71128c120a652 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureTransportFactory.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Azure\Transport; + +use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; +use Symfony\Component\Mailer\Transport\AbstractTransportFactory; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportInterface; + +final class AzureTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if (!\in_array($scheme, ['azure+api', 'azure'], true)) { + throw new UnsupportedSchemeException($dsn, 'azure', $this->getSupportedSchemes()); + } + + $user = $this->getUser($dsn); // resourceName + $password = $this->getPassword($dsn); // apiKey + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $apiVersion = $dsn->getOption('api_version', '2023-03-31'); + $disableTracking = (bool) $dsn->getOption('disable_tracking', false); + + return (new AzureApiTransport($password, $user, $disableTracking, $apiVersion, $this->client, $this->dispatcher, $this->logger))->setHost($host); + } + + protected function getSupportedSchemes(): array + { + return ['azure', 'azure+api']; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/composer.json b/src/Symfony/Component/Mailer/Bridge/Azure/composer.json new file mode 100644 index 0000000000000..a031a1f9be9f2 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/composer.json @@ -0,0 +1,32 @@ +{ + "name": "symfony/azure-mailer", + "type": "symfony-mailer-bridge", + "description": "Symfony Microsoft Azure Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Rafael Villa Verde", + "homepage": "https://github.com/hafael" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/mailer": "^6.2.7|^7.0" + }, + "require-dev": { + "symfony/http-client": "^6.0|^7.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Azure\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Mailer/Bridge/Azure/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/Azure/phpunit.xml.dist new file mode 100644 index 0000000000000..806393ddcd0bd --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/Azure/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + + diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php index 2bbaff28a8676..fae3adf3ca862 100644 --- a/src/Symfony/Component/Mailer/Transport.php +++ b/src/Symfony/Component/Mailer/Transport.php @@ -14,6 +14,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; +use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureTransportFactory; use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoTransportFactory; use Symfony\Component\Mailer\Bridge\Google\Transport\GmailTransportFactory; use Symfony\Component\Mailer\Bridge\Infobip\Transport\InfobipTransportFactory; @@ -45,6 +46,7 @@ final class Transport { private const FACTORY_CLASSES = [ + AzureTransportFactory::class, BrevoTransportFactory::class, GmailTransportFactory::class, InfobipTransportFactory::class,