Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save AuNguyen1999/122af301843cae6afac612f02368ef08 to your computer and use it in GitHub Desktop.
Save AuNguyen1999/122af301843cae6afac612f02368ef08 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 Drupal\symfony_mailer_office365\Transport\Smtp\Auth\XOAuth2Authenticator;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Sentry\HttpClient\HttpClientInterface;
use Symfony\Component\Mailer\Transport\AbstractTransportFactory;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\TransportInterface;
/**
* Factory class for creating OAuth-enabled ESMTP transport.
*
* This class is responsible for constructing EsmtpTransport instances
* with XOAUTH2 authentication using the Microsoft OAuth2 service.
*/
class OAuthEsmtpTransportFactory extends AbstractTransportFactory {
/**
* The XOAUTH2 authenticator instance.
*
* @var \Drupal\symfony_mailer_office365\Transport\Smtp\Auth\XOAuth2Authenticator
*/
protected $authenticator;
/**
* Constructs the OAuthEsmtpTransportFactory object.
*
* @param \Drupal\symfony_mailer_office365\Transport\Smtp\Auth\XOAuth2Authenticator $authenticator
* The XOAUTH2 authenticator used for creating transport.
* @param \Psr\EventDispatcher\EventDispatcherInterface|null $dispatcher
* Optional event dispatcher.
* @param \Sentry\HttpClient\HttpClientInterface|null $client
* Optional HTTP client.
* @param \Psr\Log\LoggerInterface|null $logger
* Optional logger interface.
*/
public function __construct(
XOAuth2Authenticator $authenticator,
?EventDispatcherInterface $dispatcher = NULL,
?HttpClientInterface $client = NULL,
?LoggerInterface $logger = NULL,
) {
parent::__construct($dispatcher, $client, $logger);
$this->authenticator = $authenticator;
}
/**
* Creates a new transport using the provided DSN.
*
* @param \Symfony\Component\Mailer\Transport\Dsn $dsn
* The DSN object containing transport configuration.
*
* @return \Symfony\Component\Mailer\Transport\TransportInterface
* The transport interface for sending emails via SMTP with OAuth.
*/
public function create(Dsn $dsn): TransportInterface {
// Extract the user and tenant ID from the DSN.
$user = $dsn->getOption('user');
$tenantId = $dsn->getOption('tenant_id');
// Set client ID, secret, and tenant ID for the authenticator.
$this->authenticator->setClientId($dsn->getUser());
$this->authenticator->setClientSecret($dsn->getPassword());
$this->authenticator->setTenantId($tenantId);
// Create the EsmtpTransport with the authenticator.
$transport = new EsmtpTransport(
host: $dsn->getHost(),
port: $dsn->getPort(),
authenticators: [$this->authenticator],
);
// Set username and password for the SMTP transport.
$transport->setUsername($user);
$transport->setPassword($dsn->getPassword());
return $transport;
}
/**
* Returns the supported schemes for this transport factory.
*
* @return string[]
* An array of supported schemes (e.g., 'microsoft').
*/
protected function getSupportedSchemes(): array {
return ['microsoft'];
}
}
<?php
namespace Drupal\symfony_mailer_office365;
use Drupal\Core\Cache\CacheBackendInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
/**
* Provides OAuth2 token retrieval functionality for Microsoft Office 365.
*
* This class handles the retrieval and caching of OAuth2 tokens for
* authenticating with Microsoft Office 365 services. It fetches the token
* using client credentials and caches the token for future use.
*/
final class Office365OAuthTokenProvider {
private const OAUTH_URL = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token';
private const SCOPE = 'https://outlook.office.com/.default';
private const GRANT_TYPE = 'client_credentials';
private const CACHE_KEY = 'email-token';
/**
* Constructs an Office365OAuthTokenProvider object.
*
* @param \Psr\Http\Client\ClientInterface $httpClient
* The HTTP client used to make requests to the OAuth2 server.
* @param \Psr\Http\Message\ServerRequestFactoryInterface $serverRequestFactory
* The request factory for creating server requests.
* @param \Psr\Http\Message\StreamFactoryInterface $streamFactory
* The stream factory for creating the request body stream.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend used to store the access token.
* @param \Psr\Log\LoggerInterface $logger
* Logger interface for logging any issues.
*/
public function __construct(
protected readonly ClientInterface $httpClient,
protected readonly ServerRequestFactoryInterface $serverRequestFactory,
protected readonly StreamFactoryInterface $streamFactory,
protected readonly CacheBackendInterface $cache,
protected readonly LoggerInterface $logger,
) {
}
/**
* Retrieves the OAuth2 token.
*
* @param string $tenantId
* The tenant ID for the Microsoft Office 365 instance.
* @param string $clientId
* The client ID for the OAuth2 application.
* @param string $clientSecret
* The client secret for the OAuth2 application.
*
* @return string
* The OAuth2 access token.
*/
public function getToken(string $tenantId, string $clientId, string $clientSecret): string {
return $this->cache->get(self::CACHE_KEY)?->data ?? $this->fetchToken($tenantId, $clientId, $clientSecret);
}
/**
* Fetches a new OAuth2 token from Microsoft Office 365.
*
* @param string $tenantId
* The tenant ID for the Microsoft Office 365 instance.
* @param string $clientId
* The client ID for the OAuth2 application.
* @param string $clientSecret
* The client secret for the OAuth2 application.
*
* @return string
* The newly fetched OAuth2 access token,
* or an empty string if an error occurred.
*
* @throws \RuntimeException
* Throws an exception if the request to fetch the token fails or
* if an invalid token is returned.
*/
public function fetchToken(string $tenantId, string $clientId, string $clientSecret): string {
$data = [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'scope' => self::SCOPE,
'grant_type' => self::GRANT_TYPE,
];
$oAuthUrl = str_replace('{tenant}', $tenantId, 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 = time() + ($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() {
return [
'host' => 'smtp.office365.com',
'port' => '',
'client_id' => '',
'client_secret' => '',
'user' => '',
'tenant_id' => '',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['host'] = [
'#type' => 'textfield',
'#title' => $this->t('SMTP Host'),
'#default_value' => $this->configuration['host'],
'#required' => TRUE,
];
$form['port'] = [
'#type' => 'number',
'#title' => $this->t('SMTP Port'),
'#default_value' => $this->configuration['port'],
'#required' => TRUE,
];
$form['user'] = [
'#type' => 'textfield',
'#title' => $this->t('User'),
'#default_value' => $this->configuration['user'] ?? '',
'#required' => TRUE,
];
$form['client_id'] = [
'#type' => 'textfield',
'#title' => $this->t('Client ID'),
'#default_value' => $this->configuration['client_id'],
'#required' => TRUE,
];
$form['tenant_id'] = [
'#type' => 'textfield',
'#title' => $this->t('Tenant ID'),
'#default_value' => $this->configuration['tenant_id'],
'#required' => TRUE,
];
$form['client_secret'] = [
'#type' => 'password',
'#title' => $this->t('Client Secret'),
'#default_value' => $this->configuration['client_secret'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['user'] = $form_state->getValue('user');
if (!empty($form_state->getValue('client_secret'))) {
$this->configuration['client_secret'] = $form_state->getValue('client_secret');
}
$this->configuration['host'] = $form_state->getValue('host');
$this->configuration['port'] = $form_state->getValue('port');
$this->configuration['client_id'] = $form_state->getValue('client_id');
$this->configuration['tenant_id'] = $form_state->getValue('tenant_id');
}
/**
* {@inheritdoc}
*/
public function getDsn() {
$tenant_id = $this->configuration['tenant_id'];
$user = $this->configuration['user'];
$cfg = $this->configuration;
$query = [
'tenant_id' => $tenant_id,
'user' => $user,
];
return 'microsoft://' .
(!empty($cfg['client_id']) ? urlencode($cfg['client_id']) : '') .
(!empty($cfg['client_secret']) ? ':' . urlencode($cfg['client_secret']) : '') .
('@' . urlencode($cfg['host'] ?? 'default')) .
(isset($cfg['port']) ? ':' . $cfg['port'] : '') .
($query ? '?' . http_build_query($query) : '');
}
}
name: 'Symfony mailer - office365'
type: module
description: 'Site-specific Mail logic for integrating Office 365.'
core_version_requirement: ^10.3 || ^11
package: Mail
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:
class: Drupal\symfony_mailer_office365\Transport\OAuthEsmtpTransportFactory
arguments: ['@symfony_mailer_office365.oauth2_authenticator']
tags:
- { name: mailer.transport_factory }
<?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;
/**
* Handles XOAUTH2 authentication for the SMTP transport.
*
* This class manages XOAUTH2 authentication for SMTP connections, retrieving
* OAuth2 tokens from the Office365OAuthTokenProvider and using them to
* authenticate the client via XOAUTH2.
*/
class XOAuth2Authenticator implements AuthenticatorInterface {
/**
* The tenant ID used for Office365 authentication.
*
* @var string
*/
private string $tenantId;
/**
* The client ID used for Office365 authentication.
*
* @var string
*/
private string $clientId;
/**
* The client secret used for Office365 authentication.
*
* @var string
*/
private string $clientSecret;
/**
* Sets the tenant ID for Office365 authentication.
*
* @param string $tenantId
* The tenant ID to be used for authentication.
*/
public function setTenantId(string $tenantId): void {
$this->tenantId = $tenantId;
}
/**
* Sets the client ID for Office365 authentication.
*
* @param string $clientId
* The client ID to be used for authentication.
*/
public function setClientId(string $clientId): void {
$this->clientId = $clientId;
}
/**
* Sets the client secret for Office365 authentication.
*
* @param string $clientSecret
* The client secret to be used for authentication.
*/
public function setClientSecret(string $clientSecret): void {
$this->clientSecret = $clientSecret;
}
/**
* Constructs the XOAuth2Authenticator object.
*
* @param \Drupal\symfony_mailer_office365\Office365OAuthTokenProvider $tokenProvider
* The OAuth token provider service to retrieve access tokens.
*/
public function __construct(private readonly Office365OAuthTokenProvider $tokenProvider) {
}
/**
* {@inheritDoc}
*
* Returns the keyword for the XOAUTH2 authentication mechanism.
*
* @return string
* The XOAUTH2 authentication keyword.
*/
public function getAuthKeyword(): string {
return 'XOAUTH2';
}
/**
* {@inheritDoc}
*
* Authenticates the SMTP client using the XOAUTH2 mechanism.
*
* Retrieves an OAuth2 token from the token provider and constructs the
* required authentication command to be sent to the SMTP server. This
* command uses the retrieved token for XOAUTH2-based authentication.
*
* @param \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport $client
* The SMTP client being authenticated.
*
* @throws \RuntimeException
* Throws an exception if token retrieval or authentication fails.
*/
public function authenticate(EsmtpTransport $client): void {
// Fetch the OAuth2 token using tenant, client ID, and client secret.
$token = $this->tokenProvider->getToken($this->tenantId, $this->clientId, $this->clientSecret);
// Create the AUTH XOAUTH2 command with token and send to the SMTP server.
$command = sprintf("AUTH XOAUTH2 %s\r\n", base64_encode("user=" . getenv('EMAIL_ADDRESS') . "\x01auth=Bearer " . $token . "\x01\x01"));
$client->executeCommand($command, [250]
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment