From 5388c84dcedac15c5688958cf89b27e3380e00d1 Mon Sep 17 00:00:00 2001 From: Kevin NGUYEN Date: Fri, 10 Nov 2023 18:48:50 +0100 Subject: [PATCH 01/13] initial commit --- composer.json | 1 + .../Bridge/MicrosoftGraph/.gitattributes | 4 + .../Mailer/Bridge/MicrosoftGraph/.gitignore | 3 + .../Mailer/Bridge/MicrosoftGraph/CHANGELOG.md | 7 + .../Exception/SendMailException.php | 16 ++ .../Exception/SenderNotFoundException.php | 16 ++ .../Exception/UnAuthorizedException.php | 16 ++ .../Mailer/Bridge/MicrosoftGraph/LICENSE | 19 ++ .../Mailer/Bridge/MicrosoftGraph/README.md | 54 ++++++ .../MicrosoftGraphTransportFactoryTest.php | 100 ++++++++++ .../Transport/MicrosoftGraphTransport.php | 172 ++++++++++++++++++ .../MicrosoftGraphTransportFactory.php | 72 ++++++++ .../Bridge/MicrosoftGraph/composer.json | 44 +++++ .../Bridge/MicrosoftGraph/phpunit.xml.dist | 29 +++ 14 files changed, 553 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/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/LICENSE create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.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 create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json create mode 100644 src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist diff --git a/composer.json b/composer.json index 6fb094c569fa8..20b44583dd88f 100644 --- a/composer.json +++ b/composer.json @@ -141,6 +141,7 @@ "league/html-to-markdown": "^5.0", "league/uri": "^6.5|^7.0", "masterminds/html5": "^2.7.2", + "microsoft/microsoft-graph": "^2", "monolog/monolog": "^1.25.1|^2", "nyholm/psr7": "^1.0", "pda/pheanstalk": "^4.0", 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/Exception/SendMailException.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php new file mode 100644 index 0000000000000..816d36331f715 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SendMailException.php @@ -0,0 +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 new file mode 100644 index 0000000000000..6627ec9ec626d --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/SenderNotFoundException.php @@ -0,0 +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 new file mode 100644 index 0000000000000..2823064923eb8 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Exception/UnAuthorizedException.php @@ -0,0 +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/LICENSE b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/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/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/Tests/Transport/MicrosoftGraphTransportFactoryTest.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php new file mode 100644 index 0000000000000..6e4f29b48a690 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.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\MicrosoftGraph\Tests\Transport; + +use Microsoft\Graph\Core\NationalCloud; +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\Exception\InvalidArgumentException; +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(NationalCloud::GLOBAL, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + ]; + + yield [ + new Dsn('microsoft+graph', 'germany', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), + new MicrosoftGraphTransport(NationalCloud::GERMANY, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + ]; + + yield [ + new Dsn('microsoft+graph', 'china', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), + new MicrosoftGraphTransport(NationalCloud::CHINA, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + ]; + + yield [ + new Dsn('microsoft+graph', 'us-gov', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), + new MicrosoftGraphTransport(NationalCloud::US_GOV, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + ]; + + yield [ + new Dsn('microsoft+graph', 'us-dod', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), + new MicrosoftGraphTransport(NationalCloud::US_DOD, self::TENANT, self::USER, self::PASSWORD, 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', self::USER, self::PASSWORD)]; + + yield [new Dsn('microsoft+graph', 'default', null, self::PASSWORD)]; + + yield [new Dsn('microsoft+graph', 'default', null, null)]; + } + + public function testInvalidDsnHost(): void + { + $factory = $this->getFactory(); + + $this->expectException(InvalidArgumentException::class); + $factory->create(new Dsn('microsoft+graph', 'some-wrong-national-cloud', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT])); + } + +} diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php new file mode 100644 index 0000000000000..bcf538674fc0a --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php @@ -0,0 +1,172 @@ + + * + * 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 GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Psr7\Stream; +use GuzzleHttp\Psr7\Utils; +use Microsoft\Graph\Generated\Models\BodyType; +use Microsoft\Graph\Generated\Models\EmailAddress; +use Microsoft\Graph\Generated\Models\FileAttachment; +use Microsoft\Graph\Generated\Models\ItemBody; +use Microsoft\Graph\Generated\Models\Message; +use Microsoft\Graph\Generated\Models\ODataErrors\ODataError; +use Microsoft\Graph\Generated\Models\Recipient; +use Microsoft\Graph\Generated\Users\Item\SendMail\SendMailPostRequestBody; +use Microsoft\Graph\GraphServiceClient; +use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext; +use Safe\Exceptions\JsonException; +use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception\SenderNotFoundException; +use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception\SendMailException; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Header\ParameterizedHeader; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\RawMessage; +use Symfony\Contracts\Cache\CacheInterface; + +use function Safe\json_decode; + +class MicrosoftGraphTransport implements TransportInterface +{ + private GraphServiceClient $graphServiceClient; + + public function __construct( + private readonly string $nationalCloud, + private readonly string $tenantId, + private readonly string $clientId, + private readonly string $clientSecret, + private readonly CacheInterface $cache, + ) { + $tokenRequestContext = new ClientCredentialContext( + $this->tenantId, + $this->clientId, + $this->clientSecret + ); + $this->graphServiceClient = new GraphServiceClient($tokenRequestContext, [], $this->nationalCloud); + } + + public function send(RawMessage $message, Envelope $envelope = null): ?SentMessage + { + $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)); + } + + + $this->sendMail($message); + + return new SentMessage($message, $envelope); + } + + private function sendMail(Email $message): void + { + $message = $this->convertEmailToGraphMessage($message); + $body = new SendMailPostRequestBody(); + $body->setMessage($message); + // Make sure $senderAddress is the email of an account in the tenant + $senderAddress = $message->getFrom()->getEmailAddress()->getAddress(); + + try { + $this->graphServiceClient->users()->byUserId($senderAddress)->sendMail()->post($body)->wait(); + } catch (ODataError $error) { + if ('ErrorInvalidUser' === $error->getError()->getCode()){ + 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, $error); + } + throw new SendMailException('Something went wrong while sending email', $error->getCode(), $error); + } + } + + private function convertEmailToGraphMessage(Email $source): Message + { + $message = new Message(); + + // From + 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), + $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'); + $itemBody = new ItemBody(); + $itemBody->setContent((string) $source->getHtmlBody()); + $itemBody->setContentType(new BodyType(BodyType::HTML)); + $message->setBody($itemBody); + + $message->setAttachments(\array_map( + static fn (DataPart $attachment) => self::convertAttachmentGraphAttachment($attachment), + $source->getAttachments() + )); + + return $message; + } + + private static function convertAddressToGraphRecipient(Address $source): Recipient + { + $recipient = new Recipient(); + $emailAddress = new EmailAddress(); + $emailAddress->setAddress($source->getAddress()); + $emailAddress->setName($source->getName()); + $recipient->setEmailAddress($emailAddress); + return $recipient; + } + + 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..b5206a52db7c7 --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php @@ -0,0 +1,72 @@ + + * + * 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 Microsoft\Graph\Core\NationalCloud; +use phpDocumentor\Reflection\Exception\PcreException; +use Symfony\Component\Mailer\Exception\IncompleteDsnException; +use Symfony\Component\Mailer\Exception\InvalidArgumentException; +use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; +use Symfony\Component\Mailer\Transport\AbstractTransportFactory; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Contracts\Cache\CacheInterface; + +final class MicrosoftGraphTransportFactory extends AbstractTransportFactory +{ + private const CLOUD_MAP = [ + 'default' => NationalCloud::GLOBAL, + 'germany' => NationalCloud::GERMANY, + 'china' => NationalCloud::CHINA, + 'us-dod' => NationalCloud::US_DOD, + 'us-gov' => NationalCloud::US_GOV, + ]; + + public function __construct( + private readonly CacheInterface $cache, + ) { + parent::__construct(); + } + + /** + * @return string[] + */ + protected function getSupportedSchemes(): array + { + return ['microsoft+graph']; + } + + public function create(Dsn $dsn): TransportInterface + { + if ('microsoft+graph' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'microsoft graph', $this->getSupportedSchemes()); + } + $tenantId = $dsn->getOption('tenant'); + if (null === $tenantId) { + throw new IncompleteDsnException("Transport 'microsoft+graph' requires the 'tenant' option"); + } + if (!isset(self::CLOUD_MAP[$dsn->getHost()])){ + throw new InvalidArgumentException(sprintf("Transport 'microsoft+graph' one of these hosts : '%s'", implode(", ", self::CLOUD_MAP))); + } + + // This parses the MAILER_DSN containing Microsoft Graph API credentials + return new MicrosoftGraphTransport( + self::CLOUD_MAP[$dsn->getHost()], + $tenantId, + $this->getUser($dsn), + $this->getPassword($dsn), + $this->cache + ); + } +} 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..9aa3dc580499b --- /dev/null +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json @@ -0,0 +1,44 @@ +{ + "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": "^2.0.0", + "symfony/cache": "6.4.x-dev" + }, + "require-dev": { + "symfony/http-client": "^5.4|^6.4|^7.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\MicrosoftGraph\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "config": { + "allow-plugins": { + "php-http/discovery": true + } + } +} 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 5dd6b58e1ac83668c92a71632b326a0d4c7e1881 Mon Sep 17 00:00:00 2001 From: Kevin NGUYEN Date: Fri, 10 Nov 2023 19:41:38 +0100 Subject: [PATCH 02/13] fix CI --- .../MicrosoftGraphTransportFactoryTest.php | 3 +-- .../Transport/MicrosoftGraphTransport.php | 16 ++++++---------- .../Transport/MicrosoftGraphTransportFactory.php | 11 +++++------ 3 files changed, 12 insertions(+), 18 deletions(-) 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 6e4f29b48a690..168e5f42bca0e 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -89,12 +89,11 @@ public static function incompleteDsnProvider(): iterable yield [new Dsn('microsoft+graph', 'default', null, null)]; } - public function testInvalidDsnHost(): void + public function testInvalidDsnHost() { $factory = $this->getFactory(); $this->expectException(InvalidArgumentException::class); $factory->create(new Dsn('microsoft+graph', 'some-wrong-national-cloud', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT])); } - } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php index bcf538674fc0a..7124443ca5b2e 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php @@ -13,7 +13,6 @@ namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport; -use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Stream; use GuzzleHttp\Psr7\Utils; use Microsoft\Graph\Generated\Models\BodyType; @@ -26,7 +25,6 @@ use Microsoft\Graph\Generated\Users\Item\SendMail\SendMailPostRequestBody; use Microsoft\Graph\GraphServiceClient; use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext; -use Safe\Exceptions\JsonException; use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception\SenderNotFoundException; use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception\SendMailException; use Symfony\Component\Mailer\Envelope; @@ -39,8 +37,6 @@ use Symfony\Component\Mime\RawMessage; use Symfony\Contracts\Cache\CacheInterface; -use function Safe\json_decode; - class MicrosoftGraphTransport implements TransportInterface { private GraphServiceClient $graphServiceClient; @@ -68,7 +64,6 @@ public function send(RawMessage $message, Envelope $envelope = null): ?SentMessa 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->sendMail($message); return new SentMessage($message, $envelope); @@ -85,7 +80,7 @@ private function sendMail(Email $message): void try { $this->graphServiceClient->users()->byUserId($senderAddress)->sendMail()->post($body)->wait(); } catch (ODataError $error) { - if ('ErrorInvalidUser' === $error->getError()->getCode()){ + if ('ErrorInvalidUser' === $error->getError()->getCode()) { 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, $error); } throw new SendMailException('Something went wrong while sending email', $error->getCode(), $error); @@ -104,19 +99,19 @@ private function convertEmailToGraphMessage(Email $source): Message $message->setFrom(self::convertAddressToGraphRecipient($source->getFrom()[0])); // to - $message->setToRecipients(\array_map( + $message->setToRecipients(array_map( static fn (Address $address) => self::convertAddressToGraphRecipient($address), $source->getTo() )); // CC - $message->setCcRecipients(\array_map( + $message->setCcRecipients(array_map( static fn (Address $address) => self::convertAddressToGraphRecipient($address), $source->getCc() )); // BCC - $message->setBccRecipients(\array_map( + $message->setBccRecipients(array_map( static fn (Address $address) => self::convertAddressToGraphRecipient($address), $source->getBcc() )); @@ -128,7 +123,7 @@ private function convertEmailToGraphMessage(Email $source): Message $itemBody->setContentType(new BodyType(BodyType::HTML)); $message->setBody($itemBody); - $message->setAttachments(\array_map( + $message->setAttachments(array_map( static fn (DataPart $attachment) => self::convertAttachmentGraphAttachment($attachment), $source->getAttachments() )); @@ -143,6 +138,7 @@ private static function convertAddressToGraphRecipient(Address $source): Recipie $emailAddress->setAddress($source->getAddress()); $emailAddress->setName($source->getName()); $recipient->setEmailAddress($emailAddress); + return $recipient; } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php index b5206a52db7c7..9ac0030b70b46 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php @@ -14,7 +14,6 @@ namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport; use Microsoft\Graph\Core\NationalCloud; -use phpDocumentor\Reflection\Exception\PcreException; use Symfony\Component\Mailer\Exception\IncompleteDsnException; use Symfony\Component\Mailer\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; @@ -28,9 +27,9 @@ final class MicrosoftGraphTransportFactory extends AbstractTransportFactory private const CLOUD_MAP = [ 'default' => NationalCloud::GLOBAL, 'germany' => NationalCloud::GERMANY, - 'china' => NationalCloud::CHINA, - 'us-dod' => NationalCloud::US_DOD, - 'us-gov' => NationalCloud::US_GOV, + 'china' => NationalCloud::CHINA, + 'us-dod' => NationalCloud::US_DOD, + 'us-gov' => NationalCloud::US_GOV, ]; public function __construct( @@ -56,8 +55,8 @@ public function create(Dsn $dsn): TransportInterface if (null === $tenantId) { throw new IncompleteDsnException("Transport 'microsoft+graph' requires the 'tenant' option"); } - if (!isset(self::CLOUD_MAP[$dsn->getHost()])){ - throw new InvalidArgumentException(sprintf("Transport 'microsoft+graph' one of these hosts : '%s'", implode(", ", self::CLOUD_MAP))); + if (!isset(self::CLOUD_MAP[$dsn->getHost()])) { + throw new InvalidArgumentException(sprintf("Transport 'microsoft+graph' one of these hosts : '%s'", implode(', ', self::CLOUD_MAP))); } // This parses the MAILER_DSN containing Microsoft Graph API credentials From e086c8f99a649fd5654b6bbe2fc39be9225d545d Mon Sep 17 00:00:00 2001 From: creiner Date: Tue, 13 May 2025 08:57:03 +0200 Subject: [PATCH 03/13] Improved MicrosoftGraphMailer after comments - added mail text body handling - removed unused properties - added composer deps --- .../MicrosoftGraphTransportFactoryTest.php | 16 ++++--- .../Transport/MicrosoftGraphTransport.php | 43 +++++++++---------- .../MicrosoftGraphTransportFactory.php | 18 +++----- .../Bridge/MicrosoftGraph/composer.json | 3 +- .../Bridge/MicrosoftGraph/phpunit.xml.dist | 2 +- 5 files changed, 37 insertions(+), 45 deletions(-) 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 168e5f42bca0e..101e99cd7a9c2 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -12,7 +12,9 @@ namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Tests\Transport; use Microsoft\Graph\Core\NationalCloud; -use Symfony\Component\Cache\Adapter\NullAdapter; +use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext; +use Psr\Log\NullLogger; +use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport\MicrosoftGraphTransport; use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport\MicrosoftGraphTransportFactory; use Symfony\Component\Mailer\Exception\InvalidArgumentException; @@ -26,7 +28,7 @@ class MicrosoftGraphTransportFactoryTest extends TransportFactoryTestCase public function getFactory(): TransportFactoryInterface { - return new MicrosoftGraphTransportFactory(new NullAdapter()); + return new MicrosoftGraphTransportFactory(null, new MockHttpClient(), new NullLogger()); } public static function supportsProvider(): iterable @@ -46,27 +48,27 @@ public static function createProvider(): iterable { yield [ new Dsn('microsoft+graph', 'default', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), - new MicrosoftGraphTransport(NationalCloud::GLOBAL, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + new MicrosoftGraphTransport(NationalCloud::GLOBAL, new ClientCredentialContext(self::TENANT, self::USER, self::PASSWORD)), ]; yield [ new Dsn('microsoft+graph', 'germany', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), - new MicrosoftGraphTransport(NationalCloud::GERMANY, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + new MicrosoftGraphTransport(NationalCloud::GERMANY, new ClientCredentialContext(self::TENANT, self::USER, self::PASSWORD)), ]; yield [ new Dsn('microsoft+graph', 'china', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), - new MicrosoftGraphTransport(NationalCloud::CHINA, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + new MicrosoftGraphTransport(NationalCloud::CHINA, new ClientCredentialContext(self::TENANT, self::USER, self::PASSWORD)), ]; yield [ new Dsn('microsoft+graph', 'us-gov', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), - new MicrosoftGraphTransport(NationalCloud::US_GOV, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + new MicrosoftGraphTransport(NationalCloud::US_GOV, new ClientCredentialContext(self::TENANT, self::USER, self::PASSWORD)), ]; yield [ new Dsn('microsoft+graph', 'us-dod', self::USER, self::PASSWORD, null, ['tenant' => self::TENANT]), - new MicrosoftGraphTransport(NationalCloud::US_DOD, self::TENANT, self::USER, self::PASSWORD, new NullAdapter()), + new MicrosoftGraphTransport(NationalCloud::US_DOD, new ClientCredentialContext(self::TENANT, self::USER, self::PASSWORD)), ]; } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php index 7124443ca5b2e..40db153a3f3af 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php @@ -35,25 +35,16 @@ use Symfony\Component\Mime\Header\ParameterizedHeader; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\RawMessage; -use Symfony\Contracts\Cache\CacheInterface; class MicrosoftGraphTransport implements TransportInterface { private GraphServiceClient $graphServiceClient; public function __construct( - private readonly string $nationalCloud, - private readonly string $tenantId, - private readonly string $clientId, - private readonly string $clientSecret, - private readonly CacheInterface $cache, + readonly string $nationalCloud, + readonly ClientCredentialContext $clientCredentialContext, ) { - $tokenRequestContext = new ClientCredentialContext( - $this->tenantId, - $this->clientId, - $this->clientSecret - ); - $this->graphServiceClient = new GraphServiceClient($tokenRequestContext, [], $this->nationalCloud); + $this->graphServiceClient = new GraphServiceClient($clientCredentialContext, [], $this->nationalCloud); } public function send(RawMessage $message, Envelope $envelope = null): ?SentMessage @@ -61,7 +52,7 @@ public function send(RawMessage $message, Envelope $envelope = null): ?SentMessa $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 SendMailException(sprintf("This mailer can only handle mails of class '%s' or it's subclasses, instance of %s passed", Email::class, $message::class)); } $this->sendMail($message); @@ -93,12 +84,12 @@ private function convertEmailToGraphMessage(Email $source): Message // From if (0 === \count($source->getFrom())) { - throw new SendEmailError("Cannot send mail without 'From'"); + throw new SendMailException("Cannot send mail without 'From'"); } $message->setFrom(self::convertAddressToGraphRecipient($source->getFrom()[0])); - // to + // To $message->setToRecipients(array_map( static fn (Address $address) => self::convertAddressToGraphRecipient($address), $source->getTo() @@ -117,12 +108,18 @@ private function convertEmailToGraphMessage(Email $source): Message )); // Subject & body - $message->setSubject($source->getSubject() ?? 'No subject'); + $message->setSubject($source->getSubject() ?? ''); + $itemBody = new ItemBody(); - $itemBody->setContent((string) $source->getHtmlBody()); - $itemBody->setContentType(new BodyType(BodyType::HTML)); - $message->setBody($itemBody); + if ($source->getHtmlBody()) { + $itemBody->setContent((string) $source->getHtmlBody()); + $itemBody->setContentType(new BodyType(BodyType::HTML)); + } else { + $itemBody->setContent((string) $source->getTextBody()); + $itemBody->setContentType(new BodyType(BodyType::TEXT)); + } + $message->setBody($itemBody); $message->setAttachments(array_map( static fn (DataPart $attachment) => self::convertAttachmentGraphAttachment($attachment), $source->getAttachments() @@ -153,10 +150,10 @@ private static function convertAttachmentGraphAttachment(DataPart $source): File $fileStream = Utils::streamFor($source->bodyToString()); \assert($fileStream instanceof Stream); - $attachment->setContentBytes($fileStream) - ->setContentType($source->getMediaType().'/'.$source->getMediaSubtype()) - ->setName($filename) - ->setODataType('#microsoft.graph.fileAttachment'); + $attachment->setContentBytes($fileStream); + $attachment->setContentType($source->getMediaType().'/'.$source->getMediaSubtype()); + $attachment->setName($filename); + $attachment->setODataType('#microsoft.graph.fileAttachment'); return $attachment; } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php index 9ac0030b70b46..d1fc8a46fa670 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php @@ -14,13 +14,13 @@ namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport; use Microsoft\Graph\Core\NationalCloud; +use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext; use Symfony\Component\Mailer\Exception\IncompleteDsnException; use Symfony\Component\Mailer\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; use Symfony\Component\Mailer\Transport\AbstractTransportFactory; use Symfony\Component\Mailer\Transport\Dsn; use Symfony\Component\Mailer\Transport\TransportInterface; -use Symfony\Contracts\Cache\CacheInterface; final class MicrosoftGraphTransportFactory extends AbstractTransportFactory { @@ -32,12 +32,6 @@ final class MicrosoftGraphTransportFactory extends AbstractTransportFactory 'us-gov' => NationalCloud::US_GOV, ]; - public function __construct( - private readonly CacheInterface $cache, - ) { - parent::__construct(); - } - /** * @return string[] */ @@ -59,13 +53,13 @@ public function create(Dsn $dsn): TransportInterface throw new InvalidArgumentException(sprintf("Transport 'microsoft+graph' one of these hosts : '%s'", implode(', ', self::CLOUD_MAP))); } - // This parses the MAILER_DSN containing Microsoft Graph API credentials return new MicrosoftGraphTransport( self::CLOUD_MAP[$dsn->getHost()], - $tenantId, - $this->getUser($dsn), - $this->getPassword($dsn), - $this->cache + new ClientCredentialContext( + $tenantId, + $this->getUser($dsn), + $this->getPassword($dsn) + ) ); } } diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json index 9aa3dc580499b..fce27d3c5cf2b 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/composer.json @@ -22,9 +22,8 @@ "require": { "php": ">=8.1", "symfony/mailer": "^5.4|^6.4|^7.0", - "thecodingmachine/safe": "^2.5.0", "microsoft/microsoft-graph": "^2.0.0", - "symfony/cache": "6.4.x-dev" + "guzzlehttp/psr7": "^2.0" }, "require-dev": { "symfony/http-client": "^5.4|^6.4|^7.0" diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist index 31b1f3e0ed7a9..c1500a2ddbd42 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/phpunit.xml.dist @@ -1,7 +1,7 @@ Date: Tue, 13 May 2025 08:57:29 +0200 Subject: [PATCH 04/13] Added MicrosoftGraphTransportFactory to the mailer transports --- .../FrameworkBundle/Resources/config/mailer_transports.php | 5 +++++ src/Symfony/Component/Mailer/Transport.php | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php index ed6e644a56982..1927a5d98c798 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php @@ -20,6 +20,7 @@ use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; use Symfony\Component\Mailer\Bridge\MailPace\Transport\MailPaceTransportFactory; +use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport\MicrosoftGraphTransportFactory; use Symfony\Component\Mailer\Bridge\OhMySmtp\Transport\OhMySmtpTransportFactory; use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory; use Symfony\Component\Mailer\Bridge\Scaleway\Transport\ScalewayTransportFactory; @@ -78,6 +79,10 @@ ->parent('mailer.transport_factory.abstract') ->tag('mailer.transport_factory') + ->set('mailer.transport_factory.microsoftgraph', MicrosoftGraphTransportFactory::class) + ->parent('mailer.transport_factory.abstract') + ->tag('mailer.transport_factory') + ->set('mailer.transport_factory.postmark', PostmarkTransportFactory::class) ->parent('mailer.transport_factory.abstract') ->tag('mailer.transport_factory') diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php index 8543ebbea09d1..a032f55046697 100644 --- a/src/Symfony/Component/Mailer/Transport.php +++ b/src/Symfony/Component/Mailer/Transport.php @@ -21,6 +21,7 @@ use Symfony\Component\Mailer\Bridge\MailerSend\Transport\MailerSendTransportFactory; use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory; use Symfony\Component\Mailer\Bridge\Mailjet\Transport\MailjetTransportFactory; +use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Transport\MicrosoftGraphTransportFactory; use Symfony\Component\Mailer\Bridge\OhMySmtp\Transport\OhMySmtpTransportFactory; use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory; use Symfony\Component\Mailer\Bridge\Scaleway\Transport\ScalewayTransportFactory; @@ -54,6 +55,7 @@ final class Transport MailgunTransportFactory::class, MailjetTransportFactory::class, MandrillTransportFactory::class, + MicrosoftGraphTransportFactory::class, OhMySmtpTransportFactory::class, PostmarkTransportFactory::class, ScalewayTransportFactory::class, From 2c0d2e9bfbe32916d364eda9d1381066e208bcdf Mon Sep 17 00:00:00 2001 From: creiner Date: Tue, 13 May 2025 09:15:24 +0200 Subject: [PATCH 05/13] fixed ci --- .../MicrosoftGraphTransportFactoryTest.php | 2 ++ .../Transport/MicrosoftGraphTransport.php | 14 +++++++------- .../Transport/MicrosoftGraphTransportFactory.php | 6 +++--- src/Symfony/Component/Mailer/Transport.php | 8 ++++---- 4 files changed, 16 insertions(+), 14 deletions(-) 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 101e99cd7a9c2..7741bd7747a38 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -9,6 +9,8 @@ * file that was distributed with this source code. */ +declare(strict_types=1); + namespace Symfony\Component\Mailer\Bridge\MicrosoftGraph\Tests\Transport; use Microsoft\Graph\Core\NationalCloud; diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php index 40db153a3f3af..b6a72c5dd163b 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php @@ -1,7 +1,5 @@ graphServiceClient = new GraphServiceClient($clientCredentialContext, [], $this->nationalCloud); } - public function send(RawMessage $message, Envelope $envelope = null): ?SentMessage + public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage { $envelope = null !== $envelope ? clone $envelope : Envelope::create($message); if (!$message instanceof Email) { - throw new SendMailException(sprintf("This mailer can only handle mails of class '%s' or it's subclasses, instance of %s passed", Email::class, $message::class)); + throw new SendMailException(\sprintf("This mailer can only handle mails of class '%s' or it's subclasses, instance of %s passed", Email::class, $message::class)); } $this->sendMail($message); @@ -72,9 +72,9 @@ private function sendMail(Email $message): void $this->graphServiceClient->users()->byUserId($senderAddress)->sendMail()->post($body)->wait(); } catch (ODataError $error) { if ('ErrorInvalidUser' === $error->getError()->getCode()) { - 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, $error); + 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, $error); } - throw new SendMailException('Something went wrong while sending email', $error->getCode(), $error); + throw new SendMailException('Something went wrong while sending email.', $error->getCode(), $error); } } @@ -84,7 +84,7 @@ private function convertEmailToGraphMessage(Email $source): Message // From if (0 === \count($source->getFrom())) { - throw new SendMailException("Cannot send mail without 'From'"); + throw new SendMailException("Cannot send mail without 'From'."); } $message->setFrom(self::convertAddressToGraphRecipient($source->getFrom()[0])); diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php index d1fc8a46fa670..b9a7ebd82cf11 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php @@ -1,7 +1,5 @@ getHost()])) { - throw new InvalidArgumentException(sprintf("Transport 'microsoft+graph' one of these hosts : '%s'", implode(', ', self::CLOUD_MAP))); + throw new InvalidArgumentException(\sprintf("Transport 'microsoft+graph' one of these hosts : '%s'", implode(', ', self::CLOUD_MAP))); } return new MicrosoftGraphTransport( diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php index a032f55046697..2a5aa3803625d 100644 --- a/src/Symfony/Component/Mailer/Transport.php +++ b/src/Symfony/Component/Mailer/Transport.php @@ -66,14 +66,14 @@ final class Transport private iterable $factories; - public static function fromDsn(#[\SensitiveParameter] string $dsn, EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null, LoggerInterface $logger = null): TransportInterface + public static function fromDsn(#[\SensitiveParameter] string $dsn, ?EventDispatcherInterface $dispatcher = null, ?HttpClientInterface $client = null, ?LoggerInterface $logger = null): TransportInterface { $factory = new self(iterator_to_array(self::getDefaultFactories($dispatcher, $client, $logger))); return $factory->fromString($dsn); } - public static function fromDsns(#[\SensitiveParameter] array $dsns, EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null, LoggerInterface $logger = null): TransportInterface + public static function fromDsns(#[\SensitiveParameter] array $dsns, ?EventDispatcherInterface $dispatcher = null, ?HttpClientInterface $client = null, ?LoggerInterface $logger = null): TransportInterface { $factory = new self(iterator_to_array(self::getDefaultFactories($dispatcher, $client, $logger))); @@ -144,7 +144,7 @@ private function parseDsn(#[\SensitiveParameter] string $dsn, int $offset = 0): } if (preg_match('{(\w+)\(}A', $dsn, $matches, 0, $offset)) { - throw new InvalidArgumentException(sprintf('The "%s" keyword is not valid (valid ones are "%s"), ', $matches[1], implode('", "', array_keys($keywords)))); + throw new InvalidArgumentException(\sprintf('The "%s" keyword is not valid (valid ones are "%s"), ', $matches[1], implode('", "', array_keys($keywords)))); } if ($pos = strcspn($dsn, ' )', $offset)) { @@ -169,7 +169,7 @@ public function fromDsnObject(Dsn $dsn): TransportInterface /** * @return \Traversable */ - public static function getDefaultFactories(EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null, LoggerInterface $logger = null): \Traversable + public static function getDefaultFactories(?EventDispatcherInterface $dispatcher = null, ?HttpClientInterface $client = null, ?LoggerInterface $logger = null): \Traversable { foreach (self::FACTORY_CLASSES as $factoryClass) { if (class_exists($factoryClass)) { From ecb1f3fb580563a5c0be2e5eb3973572ce2793fc Mon Sep 17 00:00:00 2001 From: creiner Date: Tue, 13 May 2025 09:17:29 +0200 Subject: [PATCH 06/13] second round of ci fixes --- .../Tests/Transport/MicrosoftGraphTransportFactoryTest.php | 5 +++-- .../MicrosoftGraph/Transport/MicrosoftGraphTransport.php | 7 ++++--- .../Transport/MicrosoftGraphTransportFactory.php | 7 ++++--- 3 files changed, 11 insertions(+), 8 deletions(-) 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 7741bd7747a38..b13c8ccec2d35 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -1,5 +1,8 @@ sendMail($message); diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php index b9a7ebd82cf11..e690089a95604 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php @@ -1,5 +1,8 @@ getOption('tenant'); if (null === $tenantId) { - throw new IncompleteDsnException("Transport 'microsoft+graph' requires the 'tenant' option"); + throw new IncompleteDsnException("Transport 'microsoft+graph' requires the 'tenant' option."); } if (!isset(self::CLOUD_MAP[$dsn->getHost()])) { throw new InvalidArgumentException(\sprintf("Transport 'microsoft+graph' one of these hosts : '%s'", implode(', ', self::CLOUD_MAP))); From 383a1dc3ceec88dc3229eccb7713e6efa26e21b1 Mon Sep 17 00:00:00 2001 From: creiner Date: Tue, 13 May 2025 09:18:57 +0200 Subject: [PATCH 07/13] Removed empty line --- .../Tests/Transport/MicrosoftGraphTransportFactoryTest.php | 1 - .../Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php | 1 - .../MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php | 1 - 3 files changed, 3 deletions(-) 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 b13c8ccec2d35..3e659c4563563 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -1,6 +1,5 @@ Date: Tue, 13 May 2025 09:20:54 +0200 Subject: [PATCH 08/13] Removed declare strict types --- .../Tests/Transport/MicrosoftGraphTransportFactoryTest.php | 2 -- .../Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php | 2 -- .../MicrosoftGraph/Transport/MicrosoftGraphTransportFactory.php | 2 -- 3 files changed, 6 deletions(-) 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 3e659c4563563..101e99cd7a9c2 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Tests/Transport/MicrosoftGraphTransportFactoryTest.php @@ -1,7 +1,5 @@ Date: Tue, 13 May 2025 09:30:06 +0200 Subject: [PATCH 09/13] fixed readme and changelog --- .../Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md | 2 +- src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md index b465b6c2e3df6..468c504deecb2 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/CHANGELOG.md @@ -1,7 +1,7 @@ CHANGELOG ========= -6.4.0 +7.3.0 ----- * Added the bridge diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md index d7ccc60b1534a..4c1ed7ca16611 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md @@ -36,12 +36,11 @@ MAILER_DSN=microsoft+graph://CLIENT_APP_ID:SECRET@login.partner.microsoftonline. 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) + * [Graph 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. From cc4448260259366e60c6f75d619855c40b8e214a Mon Sep 17 00:00:00 2001 From: creiner Date: Tue, 13 May 2025 14:13:24 +0200 Subject: [PATCH 10/13] removed ms-graph from main composer.json --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 20b44583dd88f..6fb094c569fa8 100644 --- a/composer.json +++ b/composer.json @@ -141,7 +141,6 @@ "league/html-to-markdown": "^5.0", "league/uri": "^6.5|^7.0", "masterminds/html5": "^2.7.2", - "microsoft/microsoft-graph": "^2", "monolog/monolog": "^1.25.1|^2", "nyholm/psr7": "^1.0", "pda/pheanstalk": "^4.0", From 17d2660489ee096daf4ddb1f48c1a3848329f927 Mon Sep 17 00:00:00 2001 From: creiner Date: Tue, 13 May 2025 14:10:33 +0200 Subject: [PATCH 11/13] Apply suggestions from code review Co-authored-by: Alexandre Daubois <2144837+alexandre-daubois@users.noreply.github.com> --- .../Component/Mailer/Bridge/MicrosoftGraph/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md index 4c1ed7ca16611..84f20d22e0b7d 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/README.md @@ -1,11 +1,12 @@ 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, @@ -13,7 +14,7 @@ You will need to: Configuration example ---------- +--------------------- ```env # MAILER @@ -40,9 +41,9 @@ More details can be found in the Microsoft documentation : Troubleshooting --------- -Beware that the sender email address needs to be an address of an account inside your tenant. +--------------- +Beware that the sender email address needs to be an address of an account inside your tenant. Resources --------- From 4908410e6817fdb67e3af99af880551cbdb0a259 Mon Sep 17 00:00:00 2001 From: creiner Date: Tue, 13 May 2025 14:14:06 +0200 Subject: [PATCH 12/13] Removed readonly --- .../MicrosoftGraph/Transport/MicrosoftGraphTransport.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php index 7c8b00f702f32..d47f65b46aa5d 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php @@ -39,10 +39,10 @@ class MicrosoftGraphTransport implements TransportInterface private GraphServiceClient $graphServiceClient; public function __construct( - readonly string $nationalCloud, - readonly ClientCredentialContext $clientCredentialContext, + string $nationalCloud, + ClientCredentialContext $clientCredentialContext, ) { - $this->graphServiceClient = new GraphServiceClient($clientCredentialContext, [], $this->nationalCloud); + $this->graphServiceClient = new GraphServiceClient($clientCredentialContext, [], $nationalCloud); } public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage From f1857f86097306a178dfc8800cfb8c1d15e24eae Mon Sep 17 00:00:00 2001 From: creiner Date: Tue, 13 May 2025 14:32:42 +0200 Subject: [PATCH 13/13] Improved error handling --- .../MicrosoftGraph/Transport/MicrosoftGraphTransport.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php index d47f65b46aa5d..f46336139f2f1 100644 --- a/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/MicrosoftGraph/Transport/MicrosoftGraphTransport.php @@ -25,6 +25,7 @@ use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext; use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception\SenderNotFoundException; use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception\SendMailException; +use Symfony\Component\Mailer\Bridge\MicrosoftGraph\Exception\UnAuthorizedException; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\TransportInterface; @@ -73,6 +74,11 @@ private function sendMail(Email $message): void 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, $error); } throw new SendMailException('Something went wrong while sending email.', $error->getCode(), $error); + } catch (\Exception $exception) { + if ('unauthorized_client' === $exception->getMessage()) { + throw new UnAuthorizedException('Unauthorized to send email. Check your credentials.', 401, $exception); + } + throw new SendMailException('Something went wrong while sending email.', 0, $exception); } }