Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save beerendlauwers/03acb112c80af0bf91546a05f790057a to your computer and use it in GitHub Desktop.
Save beerendlauwers/03acb112c80af0bf91546a05f790057a to your computer and use it in GitHub Desktop.
(Drupal) Send emails with Symfony Mailer through Outlook / office365 with OAuth

Code from https://gist.github.com/dbu/3094d7569aebfc94788b164bd7e59acc, adapted for Drupal's Symfony Mailer.

Module structure:

symfony_mailer_office365
|-- /src/Plugin/MailerTransport/Office365Transport.php
|-- /src/Transport/Smtp/Auth/XOAuth2Authenticator.php
|-- /src/Transport/OAuthEsmtpTransportFactoryDecorator.php
|-- /src/Office365OAuthTokenProvider.php
|-- symfony_mailer_office365.info.yml
|-- symfony_mailer_office365.services.yml

<?php
namespace Drupal\symfony_mailer_office365\Transport;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\Smtp\Auth\AuthenticatorInterface;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransportFactory;
use Symfony\Component\Mailer\Transport\TransportFactoryInterface;
use Symfony\Component\Mailer\Transport\TransportInterface;
class OAuthEsmtpTransportFactoryDecorator implements TransportFactoryInterface
{
public function __construct(
private readonly EsmtpTransportFactory $inner,
private readonly AuthenticatorInterface $authenticator,
) {
}
public function create(Dsn $dsn): TransportInterface
{
$transport = $this->inner->create($this->convertDsn($dsn));
if (!$transport instanceof EsmtpTransport) {
return $transport;
}
// This transport only supports our custom OAuth 2.0 authenticator.
$transport->setAuthenticators([$this->authenticator]);
return $transport;
}
protected function convertDsn(Dsn $dsn): Dsn
{
if ('office365' === $dsn->getScheme()) {
return new Dsn(
scheme: 'smtp',
host: $dsn->getHost(),
user: $dsn->getUser(),
password: $dsn->getPassword(),
port: $dsn->getPort(),
options: [],
);
}
return $dsn;
}
public function supports(Dsn $dsn): bool
{
return $this->inner->supports($this->convertDsn($dsn));
}
}
<?php
namespace Drupal\symfony_mailer_office365;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Site\Settings;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
final class Office365OAuthTokenProvider
{
private const OAUTH_URL = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token';
private const SCOPE = 'https://outlook.office365.com/.default';
private const GRANT_TYPE = 'client_credentials';
private const CACHE_KEY = 'email-token';
private readonly string $tenant;
private readonly string $clientId;
private readonly string $clientSecret;
public function __construct(
protected readonly ClientInterface $httpClient,
protected readonly ServerRequestFactoryInterface $serverRequestFactory,
protected readonly StreamFactoryInterface $streamFactory,
protected readonly CacheBackendInterface $cache,
protected readonly LoggerInterface $logger,
) {
if ($credentials = Settings::get('oauth_credentials', [])) {
$this->tenant = $credentials['tenant'];
$this->clientId = $credentials['client_id'];
$this->clientSecret = $credentials['client_secret'];
} else {
throw new \RuntimeException('oauth_credentials settings are not set.');
}
}
public function getToken(): string
{
return $this->cache->get(self::CACHE_KEY)?->data ?? $this->fetchToken();
}
public function fetchToken(): string
{
$data = [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'scope' => self::SCOPE,
'grant_type' => self::GRANT_TYPE,
];
$oAuthUrl = str_replace('{tenant}', $this->tenant, self::OAUTH_URL);
$body = $this->streamFactory->createStream(http_build_query($data));
$request = $this->serverRequestFactory->createServerRequest('POST', $oAuthUrl)
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
->withBody($body);
try {
$response = $this->httpClient->sendRequest($request);
if (200 !== $response->getStatusCode()) {
throw new \RuntimeException('Failed to fetch oauth token from Microsoft: ' . $response->getBody());
}
$auth = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
$accessToken = $auth['access_token'] ?? null;
if (!$accessToken) {
throw new \RuntimeException('Received empty access token from Microsoft: ' . $response->getBody());
}
$expiresIn = $auth['expires_in'] ?? time() + 300;
// Subtracting 60 seconds from the TTL
// as a safety margin to certainly not use an expiring token.
$expiryTime = $expiresIn - 60;
$this->cache->set(self::CACHE_KEY, $accessToken, $expiryTime);
return $accessToken;
} catch (\Throwable $exception) {
$this->logger->alert($exception->getMessage());
}
return '';
}
}
<?php
namespace Drupal\symfony_mailer_office365\Plugin\MailerTransport;
use Drupal\Core\Form\FormStateInterface;
use Drupal\symfony_mailer\Plugin\MailerTransport\TransportBase;
/**
* Defines the Office365 Mail Transport plug-in.
*
* @MailerTransport(
* id = "office365",
* label = @Translation("Office 365 - Modern Authentication (OAuth 2.0)"),
* description = @Translation("Use Office 365 with OAuth 2.0 to send emails."),
* )
*/
class Office365Transport extends TransportBase
{
/**
* {@inheritdoc}
*/
public function defaultConfiguration()
{
// tenant, client_id and client_secret
// are currently managed in settings, but could
// be handled here in the future.
return [
'user' => '',
'tenant' => '',
'client_id' => '',
'client_secret' => '',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(
array $form,
FormStateInterface $form_state
) {
$form['user'] = [
'#type' => 'textfield',
'#title' => $this->t('User name'),
'#default_value' => $this->configuration['user'],
'#description' => $this->t('User name to log in.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(
array &$form,
FormStateInterface $form_state
) {
$this->configuration['user'] = $form_state->getValue('user');
$this->configuration['tenant'] = $form_state->getValue('tenant');
$this->configuration['client_id'] = $form_state->getValue('client_id');
$this->configuration['client_secret'] = $form_state->getValue('client_secret');
}
}
name: 'symfony_mailer_office365 Mails'
type: module
description: "Site-specific Mail logic."
core: 8.x
core_version_requirement: ^10
dependencies:
- symfony_mailer:symfony_mailer
services:
symfony_mailer_office365.office_365_token_provider:
class: Drupal\symfony_mailer_office365\Office365OAuthTokenProvider
arguments: [
'@http_client',
'@psr17.server_request_factory',
'@psr17.stream_factory',
'@cache.symfony_mailer_office365',
'@logger.channel.symfony_mailer_office365'
]
symfony_mailer_office365.oauth2_authenticator:
class: Drupal\symfony_mailer_office365\Transport\Smtp\Auth\XOAuth2Authenticator
arguments: [ '@symfony_mailer_office365.office_365_token_provider' ]
cache.symfony_mailer_office365:
class: Drupal\Core\Cache\CacheBackendInterface
tags:
- { name: cache.bin }
factory: [ '@cache_factory', 'get' ]
arguments: [ symfony_mailer_office365 ]
logger.channel.symfony_mailer_office365:
class: Drupal\Core\Logger\LoggerChannel
factory: logger.factory:get
arguments: [ 'symfony_mailer_office365' ]
symfony_mailer_office365.oauth_esmtp_transport_factory_decorator:
class: Drupal\symfony_mailer_office365\Transport\OAuthEsmtpTransportFactoryDecorator
decorates: mailer.transport_factory.smtp
arguments: [
'@.inner',
'@symfony_mailer_office365.oauth2_authenticator' ]
<?php
namespace Drupal\symfony_mailer_office365\Transport\Smtp\Auth;
use Drupal\symfony_mailer_office365\Office365OAuthTokenProvider;
use Symfony\Component\Mailer\Transport\Smtp\Auth\AuthenticatorInterface;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
/**
* Adapted from Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator but getting the token dynamically.
*/
class XOAuth2Authenticator implements AuthenticatorInterface
{
public function __construct(
private readonly Office365OAuthTokenProvider $tokenProvider,
) {
}
public function getAuthKeyword(): string
{
return 'XOAUTH2';
}
/**
* @see https://developers.google.com/google-apps/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism
*/
public function authenticate(EsmtpTransport $client): void
{
$client->executeCommand('AUTH XOAUTH2 ' . base64_encode('user=' . $client->getUsername() . "\1auth=Bearer " . $this->tokenProvider->getToken() . "\1\1") . "\r\n", [235]);
}
}
@mrshowerman
Copy link

Thanks for providing this Gist!
I created a custom module based on your code. On activation, I got this error:

The service "symfony_mailer_office365.oauth_esmtp_transport_factory_decorator" has a dependency on a non-existent service "mailer.transport_factory.smtp".

Maybe a missing dependency? The Symfony Mailer module is installed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment