From a45205e0a02c0f89ee792f186cd1190d8bc417c9 Mon Sep 17 00:00:00 2001 From: Kevin NGUYEN Date: Thu, 9 Nov 2023 13:41:35 +0100 Subject: [PATCH 1/5] Initiate repository --- .../Bridge/MicrosoftGraph/.gitattributes | 4 ++ .../Mailer/Bridge/MicrosoftGraph/.gitignore | 3 ++ .../Mailer/Bridge/MicrosoftGraph/CHANGELOG.md | 7 +++ .../Mailer/Bridge/MicrosoftGraph/LICENSE | 19 +++++++ .../Mailer/Bridge/MicrosoftGraph/README.md | 54 +++++++++++++++++++ .../Bridge/MicrosoftGraph/composer.json | 39 ++++++++++++++ 6 files changed, 126 insertions(+) create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/.gitattributes create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/.gitignore create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/.gitattributes b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/.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/MicrosoftGraph/.gitignore b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md new file mode 100644 index 0000000000000..b465b6c2e3df6 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +6.4.0 +----- + + * Added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE new file mode 100644 index 0000000000000..f37c76b591dbd --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019-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/MicrosoftGraph/README.md b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md new file mode 100644 index 0000000000000..d7ccc60b1534a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md @@ -0,0 +1,54 @@ +Microsoft Graph API Mailer +============= + +Provides Microsoft Graph API integration for Symfony Mailer. + + +Prerequisites +--------- +You will need to: + * Register an application in your Microsoft Azure portal, + * Grant this application the Microsoft Graph `Mail.Send` permission, + * Create a secret for that app. + + +Configuration example +--------- + +```env +# MAILER +MAILER_DSN=microsoft+graph://CLIENT_APP_ID:SECRET@default?tenant=TENANT_ID +``` + +If you need to use third parties operated or specific regions Microsoft services (China, US Government, etc.), you can specify Auth Endpoint and Graph Endpoint. + +```env +# MAILER e.g. for China +MAILER_DSN=microsoft+graph://CLIENT_APP_ID:SECRET@login.partner.microsoftonline.cn?tenant=TENANT_ID&graphEndpoint=https://microsoftgraph.chinacloudapi.cn +``` + +| | Authentication endpoint | Graph Endpoint | +|------------------------|------------------------------------------|-----------------------------------------| +| Global (default) | https://login.microsoftonline.com | https://graph.microsoft.com | +| US Government L4 | https://login.microsoftonline.us | https://graph.microsoft.us | +| US Government L5 (DOD) | https://login.microsoftonline.us | https://dod-graph.microsoft.us | +| China | https://login.partner.microsoftonline.cn | https://microsoftgraph.chinacloudapi.cn | + +More details can be found in the Microsoft documentation : + * [Auth Endpoints](https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud#microsoft-entra-authentication-endpoints) + * [Grpah Endpoints](https://learn.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints) + + +Troubleshooting +-------- +//TODO : erreur stack trace +Beware that the sender email address needs to be an address of an account inside your tenant. + + +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/MicrosoftGraph/composer.json b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json new file mode 100644 index 0000000000000..3469b13bb5427 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json @@ -0,0 +1,39 @@ +{ + "name": "symfony/microsoft-graph-mailer", + "type": "symfony-mailer-bridge", + "description": "Symfony Microsoft Graph Mailer Bridge", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Kevin Nguyen", + "homepage": "https://github.com/nguyenk" + }, + { + "name": "The Coding Machine", + "homepage": "https://github.com/thecodingmachine" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/mailer": "^5.4|^6.4|^7.0", + "thecodingmachine/safe": "^2.5.0", + "microsoft/microsoft-graph": "^1.109.0" + }, + "require-dev": { + "symfony/http-client": "^5.4|^6.4|^7.0", + "symfony/cache": "6.4.x-dev" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\MicrosoftGraph\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} From cb3646aab30c4fc9ff5b0048be5d353b0d9619e6 Mon Sep 17 00:00:00 2001 From: Kevin NGUYEN Date: Thu, 9 Nov 2023 13:46:15 +0100 Subject: [PATCH 2/5] Implement Transport --- .../Exception/SendMailException.php | 8 + .../Exception/SenderNotFoundException.php | 8 + .../Exception/UnAuthorizedException.php | 8 + .../Transport/MicrosoftGraphTransport.php | 222 ++++++++++++++++++ .../MicrosoftGraphTransportFactory.php | 57 +++++ 5 files changed, 303 insertions(+) create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SenderNotFoundException.php create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/UnAuthorizedException.php create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php new file mode 100644 index 0000000000000..9d2eac4cabed6 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php @@ -0,0 +1,8 @@ +graph = new Graph(); + $this->graph->setBaseUrl($graphEndpoint); + } + + public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage + { + $envelope = $envelope !== null ? clone $envelope : Envelope::create($message); + + if (!$message instanceof Email) { + throw new SendEmailError( + \sprintf( + "This mailer can only handle mails of class '%s' or it's subclasses, instance of %s passed", + Email::class, + $message::class + ), + ); + } + + $this->auth(); + + try { + $this->sendMail($message); + } catch (UnAuthorizedException $exception) { + // Token may have expired, we need to refresh the token and try again + $this->auth(refresh: true); + $this->sendMail($message); + } + + return new SentMessage($message, $envelope); + } + + private function sendMail(Email $message): void + { + $message = $this->convertEmailToGraphMessage($message); + + //Make sure $message->getFrom()->getEmailAddress()->getAddress() returns the email of an account in the tenant + $senderAddress = $message->getFrom()->getEmailAddress()->getAddress(); + try{ + $this->graph->createRequest('POST', '/users/' . $senderAddress . '/sendMail') + ->attachBody(['message' => $message]) + ->execute(); + }catch (ClientException $clientException){ + $statusCode = $clientException->getCode(); + if ($statusCode === 401) { + throw new UnAuthorizedException("Send mail request failed: received 401 - Unauthorized", $statusCode, $clientException); + }else if ($statusCode === 404){ + $responseBody = $clientException->getResponse()->getBody()->getContents(); + try{ + $responseBody = json_decode($responseBody, true); + if ($responseBody['error']['code'] === 'ErrorInvalidUser') { + var_export($responseBody); + throw new SenderNotFoundException("Sender email address '" . $senderAddress . "' could not be found when calling the Graph API. This is usually because the email address doesn't exist in the tenant.", 404, $clientException); + } + }catch (JsonException){ + // no JSON content, silently ignore, will bubble up at the end + } + } + throw new SendMailException("Something went wrong while sending email", $statusCode, $clientException); + } + } + + private function auth(bool $refresh = false): void + { + if ($refresh) { + $this->cache->delete(key: self::AUTH_TOKEN_CACHE_KEY); + } + + $accessToken = $this->cache->get( + key: self::AUTH_TOKEN_CACHE_KEY, + callback: function (ItemInterface $item) { + $guzzle = new Client(); + $response = $guzzle->post($this->authEndpoint, [ + 'form_params' => [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'scope' => $this->graphEndpoint . '/.default', + 'grant_type' => 'client_credentials', + ], + ])->getBody()->getContents(); + $token = json_decode($response, true); + + $item->expiresAfter(new DateInterval('PT' . ($token['expires_in'] - 60) . 'S')); + + return $token['access_token']; + } + ); + + $this->graph->setAccessToken($accessToken); + } + + private function convertEmailToGraphMessage(Email $source): Message + { + $message = new Message(); + + // From + if (count($source->getFrom()) === 0) { + throw new SendEmailError("Cannot send mail without 'From'"); + } + + $message->setFrom(self::convertAddressToGraphRecipient($source->getFrom()[0])); + + // to + $message->setToRecipients(array_map( + static fn(Address $address) => self::convertAddressToGraphRecipient($address), + $source->getTo() + )); + + // CC + $message->setCcRecipients(array_map( + static fn(Address $address) => self::convertAddressToGraphRecipient($address), + $source->getCc() + )); + + // BCC + $message->setBccRecipients(array_map( + static fn(Address $address) => self::convertAddressToGraphRecipient($address), + $source->getBcc() + )); + + // Subject & body + $message->setSubject($source->getSubject() ?? 'No subject'); + $message->setBody( + (new ItemBody())->setContent((string)$source->getHtmlBody()) + ->setContentType((new BodyType(BodyType::HTML))) + ); + + $message->setAttachments(array_map( + static fn(DataPart $attachment) => self::convertAttachmentGraphAttachment($attachment), + $source->getAttachments() + )); + + return $message; + } + + private static function convertAddressToGraphRecipient(Address $source): Recipient + { + return (new Recipient()) + ->setEmailAddress((new EmailAddress()) + ->setAddress($source->getAddress()) + ->setName($source->getName())); + } + + private static function convertAttachmentGraphAttachment(DataPart $source): FileAttachment + { + $attachment = new FileAttachment(); + + $contentDisposition = $source->getPreparedHeaders()->get('content-disposition'); + assert($contentDisposition instanceof ParameterizedHeader); + $filename = $contentDisposition->getParameter('filename'); + + $fileStream = Utils::streamFor($source->bodyToString()); + assert($fileStream instanceof Stream); + + $attachment->setContentBytes($fileStream) + ->setContentType($source->getMediaType() . '/' . $source->getMediaSubtype()) + ->setName($filename) + ->setODataType('#microsoft.graph.fileAttachment'); + + return $attachment; + } + + public function __toString(): string + { + return 'microsoft_graph://oauth_mail'; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php new file mode 100644 index 0000000000000..30088561a887a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php @@ -0,0 +1,57 @@ +getScheme()) { + throw new UnsupportedSchemeException($dsn, 'microsoft graph', $this->getSupportedSchemes()); + } + $tenantId = $dsn->getOption('tenant'); + if ($tenantId === null){ + throw new IncompleteDsnException("Transport 'microsoft+graph' requires the 'tenant' option"); + } + + $graphEndpoint = $dsn->getOption('graphEndpoint', 'https://graph.microsoft.com'); + $authHost = 'default' === $dsn->getHost() ? 'https://login.microsoftonline.com' : $dsn->getHost(); + // This parses the MAILER_DSN containing Microsoft Graph API credentials + return new MicrosoftGraphTransport( + $this->getUser($dsn), + $this->getPassword($dsn), + $authHost . '/' . $tenantId . '/oauth2/v2.0/token', + $graphEndpoint, + $this->cache + ); + } +} From e5d9593b25b2b0f1d324f9ab1a6bea2dfbb9f0d3 Mon Sep 17 00:00:00 2001 From: Kevin NGUYEN Date: Thu, 9 Nov 2023 13:46:29 +0100 Subject: [PATCH 3/5] Add tests --- .../Mailer/Bridge/MicrosoftGraph/LICENSE | 2 +- .../MicrosoftGraphTransportFactoryTest.php | 69 +++++++++++++++++++ .../Bridge/MicrosoftGraph/phpunit.xml.dist | 29 ++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE index f37c76b591dbd..f79715fa86ee1 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-present Fabien Potencier +Copyright (c) 2023-present Kevin Nguyen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php new file mode 100644 index 0000000000000..4e4cfdc48bbdf --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Bridge\Google\Tests\Transport; + +use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport\MicrosoftGraphTransport; +use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport\MicrosoftGraphTransportFactory; +use Symfony\Component\Mailer\Test\TransportFactoryTestCase; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; + +class MicrosoftGraphTransportFactoryTest extends TransportFactoryTestCase +{ + protected const TENANT = 'tenantId'; + + public function getFactory(): TransportFactoryInterface + { + return new MicrosoftGraphTransportFactory(new NullAdapter()); + } + + public static function supportsProvider(): iterable + { + yield [ + new Dsn('microsoft+graph', 'default'), + true, + ]; + + yield [ + new Dsn('microsoft+graph', 'example.com'), + true, + ]; + } + + public static function createProvider(): iterable + { + yield [ + new Dsn('microsoft+graph', 'default', self::USER, self::PASSWORD, null, ["tenant" => self::TENANT]), + new MicrosoftGraphTransport(self::USER, self::PASSWORD, 'https://login.microsoftonline.com/tenantId/oauth2/v2.0/token', 'https://graph.microsoft.com', new NullAdapter()), + ]; + yield [ + new Dsn('microsoft+graph', 'https://example.com', self::USER, self::PASSWORD, null, ["tenant" => self::TENANT, "graphEndpoint" => "https://another-example.com"]), + new MicrosoftGraphTransport(self::USER, self::PASSWORD, 'https://example.com/tenantId/oauth2/v2.0/token', 'https://another-example.com', new NullAdapter()), + ]; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield [ + new Dsn('microsoft+smtp', 'default', self::USER, self::PASSWORD), + 'The "microsoft+smtp" scheme is not supported; supported schemes for mailer "microsoft graph" are: "microsoft+graph".', + ]; + } + + public static function incompleteDsnProvider(): iterable + { + yield [new Dsn('microsoft+graph', 'default', self::USER)]; + + yield [new Dsn('microsoft+graph', 'default', null, self::PASSWORD)]; + } +} diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist new file mode 100644 index 0000000000000..31b1f3e0ed7a9 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + From 72b6175bdc644c14a574243df482745dcde61b0c Mon Sep 17 00:00:00 2001 From: Kevin NGUYEN Date: Thu, 9 Nov 2023 14:46:32 +0100 Subject: [PATCH 4/5] fix CI --- src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE | 2 +- .../Tests/Transport/MicrosoftGraphTransportFactoryTest.php | 2 +- .../Component/Mailer/Bridge/MicrosoftGraph/composer.json | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE index f79715fa86ee1..3ed9f412ce53d 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2023-present Kevin Nguyen +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 diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php index 4e4cfdc48bbdf..9cda8049f31d4 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Mailer\Bridge\Google\Tests\Transport; +namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Tests\Transport; use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport\MicrosoftGraphTransport; diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json index 3469b13bb5427..17f5ac5f05a06 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json @@ -23,11 +23,12 @@ "php": ">=8.1", "symfony/mailer": "^5.4|^6.4|^7.0", "thecodingmachine/safe": "^2.5.0", - "microsoft/microsoft-graph": "^1.109.0" + "microsoft/microsoft-graph": "^1.109.0", + "symfony/cache": "6.4.x-dev" }, "require-dev": { "symfony/http-client": "^5.4|^6.4|^7.0", - "symfony/cache": "6.4.x-dev" + "microsoft/microsoft-graph": "^1.109.0" }, "autoload": { "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\MicrosoftGraph\\": "" }, From 3ed6380a7b01799f1594ebf3db3f9ff10c34d4e0 Mon Sep 17 00:00:00 2001 From: Kevin NGUYEN Date: Thu, 9 Nov 2023 14:52:06 +0100 Subject: [PATCH 5/5] fix CI --- .../Exception/SendMailException.php | 10 ++- .../Exception/SenderNotFoundException.php | 10 ++- .../Exception/UnAuthorizedException.php | 10 ++- .../MicrosoftGraphTransportFactoryTest.php | 4 +- .../Transport/MicrosoftGraphTransport.php | 89 +++++++++---------- .../MicrosoftGraphTransportFactory.php | 8 +- 6 files changed, 76 insertions(+), 55 deletions(-) diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php index 9d2eac4cabed6..816d36331f715 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php @@ -1,8 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception; class SendMailException extends \RuntimeException { - } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SenderNotFoundException.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SenderNotFoundException.php index fa1e18e0675e4..6627ec9ec626d 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SenderNotFoundException.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SenderNotFoundException.php @@ -1,8 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception; class SenderNotFoundException extends SendMailException { - } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/UnAuthorizedException.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/UnAuthorizedException.php index 0e3f9eb692905..2823064923eb8 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/UnAuthorizedException.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/UnAuthorizedException.php @@ -1,8 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception; class UnAuthorizedException extends SendMailException { - } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php index 9cda8049f31d4..fa1a21a71c829 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -43,11 +43,11 @@ public static function supportsProvider(): iterable public static function createProvider(): iterable { yield [ - new Dsn('microsoft+graph', 'default', self::USER, self::PASSWORD, null, ["tenant" => self::TENANT]), + new Dsn('microsoft+graph', 'default', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), new MicrosoftGraphTransport(self::USER, self::PASSWORD, 'https://login.microsoftonline.com/tenantId/oauth2/v2.0/token', 'https://graph.microsoft.com', new NullAdapter()), ]; yield [ - new Dsn('microsoft+graph', 'https://example.com', self::USER, self::PASSWORD, null, ["tenant" => self::TENANT, "graphEndpoint" => "https://another-example.com"]), + new Dsn('microsoft+graph', 'https://example.com', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT, 'graphEndpoint' => 'https://another-example.com']), new MicrosoftGraphTransport(self::USER, self::PASSWORD, 'https://example.com/tenantId/oauth2/v2.0/token', 'https://another-example.com', new NullAdapter()), ]; } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php index 54af7c7051d7b..09c2c3027df15 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php @@ -1,15 +1,18 @@ + * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport; -use DateInterval; use GuzzleHttp\Client; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Stream; @@ -36,9 +39,6 @@ use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; -use function array_map; -use function assert; -use function count; use function Safe\json_decode; class MicrosoftGraphTransport implements TransportInterface @@ -47,29 +47,22 @@ class MicrosoftGraphTransport implements TransportInterface private Graph $graph; public function __construct( - private readonly string $clientId, - private readonly string $clientSecret, - private readonly string $authEndpoint, - private readonly string $graphEndpoint, + private readonly string $clientId, + private readonly string $clientSecret, + private readonly string $authEndpoint, + private readonly string $graphEndpoint, private readonly CacheInterface $cache, - ) - { + ) { $this->graph = new Graph(); $this->graph->setBaseUrl($graphEndpoint); } - public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage + public function send(RawMessage $message, Envelope $envelope = null): ?SentMessage { - $envelope = $envelope !== null ? clone $envelope : Envelope::create($message); + $envelope = null !== $envelope ? clone $envelope : Envelope::create($message); if (!$message instanceof Email) { - throw new SendEmailError( - \sprintf( - "This mailer can only handle mails of class '%s' or it's subclasses, instance of %s passed", - Email::class, - $message::class - ), - ); + throw new SendEmailError(sprintf("This mailer can only handle mails of class '%s' or it's subclasses, instance of %s passed", Email::class, $message::class)); } $this->auth(); @@ -89,29 +82,29 @@ private function sendMail(Email $message): void { $message = $this->convertEmailToGraphMessage($message); - //Make sure $message->getFrom()->getEmailAddress()->getAddress() returns the email of an account in the tenant + // Make sure $message->getFrom()->getEmailAddress()->getAddress() returns the email of an account in the tenant $senderAddress = $message->getFrom()->getEmailAddress()->getAddress(); - try{ - $this->graph->createRequest('POST', '/users/' . $senderAddress . '/sendMail') + try { + $this->graph->createRequest('POST', '/users/'.$senderAddress.'/sendMail') ->attachBody(['message' => $message]) ->execute(); - }catch (ClientException $clientException){ + } catch (ClientException $clientException) { $statusCode = $clientException->getCode(); - if ($statusCode === 401) { - throw new UnAuthorizedException("Send mail request failed: received 401 - Unauthorized", $statusCode, $clientException); - }else if ($statusCode === 404){ + if (401 === $statusCode) { + throw new UnAuthorizedException('Send mail request failed: received 401 - Unauthorized', $statusCode, $clientException); + } elseif (404 === $statusCode) { $responseBody = $clientException->getResponse()->getBody()->getContents(); - try{ + try { $responseBody = json_decode($responseBody, true); - if ($responseBody['error']['code'] === 'ErrorInvalidUser') { + if ('ErrorInvalidUser' === $responseBody['error']['code']) { var_export($responseBody); - throw new SenderNotFoundException("Sender email address '" . $senderAddress . "' could not be found when calling the Graph API. This is usually because the email address doesn't exist in the tenant.", 404, $clientException); + throw new SenderNotFoundException("Sender email address '".$senderAddress."' could not be found when calling the Graph API. This is usually because the email address doesn't exist in the tenant.", 404, $clientException); } - }catch (JsonException){ + } catch (JsonException) { // no JSON content, silently ignore, will bubble up at the end } } - throw new SendMailException("Something went wrong while sending email", $statusCode, $clientException); + throw new SendMailException('Something went wrong while sending email', $statusCode, $clientException); } } @@ -129,13 +122,13 @@ private function auth(bool $refresh = false): void 'form_params' => [ 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, - 'scope' => $this->graphEndpoint . '/.default', + 'scope' => $this->graphEndpoint.'/.default', 'grant_type' => 'client_credentials', ], ])->getBody()->getContents(); - $token = json_decode($response, true); + $token = json_decode($response, true); - $item->expiresAfter(new DateInterval('PT' . ($token['expires_in'] - 60) . 'S')); + $item->expiresAfter(new \DateInterval('PT'.($token['expires_in'] - 60).'S')); return $token['access_token']; } @@ -149,39 +142,39 @@ private function convertEmailToGraphMessage(Email $source): Message $message = new Message(); // From - if (count($source->getFrom()) === 0) { + if (0 === \count($source->getFrom())) { throw new SendEmailError("Cannot send mail without 'From'"); } $message->setFrom(self::convertAddressToGraphRecipient($source->getFrom()[0])); // to - $message->setToRecipients(array_map( - static fn(Address $address) => self::convertAddressToGraphRecipient($address), + $message->setToRecipients(\array_map( + static fn (Address $address) => self::convertAddressToGraphRecipient($address), $source->getTo() )); // CC - $message->setCcRecipients(array_map( - static fn(Address $address) => self::convertAddressToGraphRecipient($address), + $message->setCcRecipients(\array_map( + static fn (Address $address) => self::convertAddressToGraphRecipient($address), $source->getCc() )); // BCC - $message->setBccRecipients(array_map( - static fn(Address $address) => self::convertAddressToGraphRecipient($address), + $message->setBccRecipients(\array_map( + static fn (Address $address) => self::convertAddressToGraphRecipient($address), $source->getBcc() )); // Subject & body $message->setSubject($source->getSubject() ?? 'No subject'); $message->setBody( - (new ItemBody())->setContent((string)$source->getHtmlBody()) - ->setContentType((new BodyType(BodyType::HTML))) + (new ItemBody())->setContent((string) $source->getHtmlBody()) + ->setContentType(new BodyType(BodyType::HTML)) ); - $message->setAttachments(array_map( - static fn(DataPart $attachment) => self::convertAttachmentGraphAttachment($attachment), + $message->setAttachments(\array_map( + static fn (DataPart $attachment) => self::convertAttachmentGraphAttachment($attachment), $source->getAttachments() )); @@ -201,14 +194,14 @@ private static function convertAttachmentGraphAttachment(DataPart $source): File $attachment = new FileAttachment(); $contentDisposition = $source->getPreparedHeaders()->get('content-disposition'); - assert($contentDisposition instanceof ParameterizedHeader); + \assert($contentDisposition instanceof ParameterizedHeader); $filename = $contentDisposition->getParameter('filename'); $fileStream = Utils::streamFor($source->bodyToString()); - assert($fileStream instanceof Stream); + \assert($fileStream instanceof Stream); $attachment->setContentBytes($fileStream) - ->setContentType($source->getMediaType() . '/' . $source->getMediaSubtype()) + ->setContentType($source->getMediaType().'/'.$source->getMediaSubtype()) ->setName($filename) ->setODataType('#microsoft.graph.fileAttachment'); diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php index 30088561a887a..920ae77e2d0b8 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php @@ -1,9 +1,12 @@ + * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ @@ -39,17 +42,18 @@ public function create(Dsn $dsn): TransportInterface throw new UnsupportedSchemeException($dsn, 'microsoft graph', $this->getSupportedSchemes()); } $tenantId = $dsn->getOption('tenant'); - if ($tenantId === null){ + if (null === $tenantId) { throw new IncompleteDsnException("Transport 'microsoft+graph' requires the 'tenant' option"); } $graphEndpoint = $dsn->getOption('graphEndpoint', 'https://graph.microsoft.com'); $authHost = 'default' === $dsn->getHost() ? 'https://login.microsoftonline.com' : $dsn->getHost(); + // This parses the MAILER_DSN containing Microsoft Graph API credentials return new MicrosoftGraphTransport( $this->getUser($dsn), $this->getPassword($dsn), - $authHost . '/' . $tenantId . '/oauth2/v2.0/token', + $authHost.'/'.$tenantId.'/oauth2/v2.0/token', $graphEndpoint, $this->cache );