Skip to content

Instantly share code, notes, and snippets.

@dbu
Last active August 6, 2024 11:25
Show Gist options
  • Save dbu/3094d7569aebfc94788b164bd7e59acc to your computer and use it in GitHub Desktop.
Save dbu/3094d7569aebfc94788b164bd7e59acc to your computer and use it in GitHub Desktop.
Send emails with Symfony Mailer through Outlook / office365 with OAuth

Update: We are trying to improve the setup in Symfony to make most of this gist hopefully not needed anymore (except the token provider): symfony/symfony#52585

I banged my head against this for a while, but finally got it to work.

What you need to set this up:

  • the user name (= email address) of your email account
  • tenant id for your email account (a uuid)
  • client id for your email account (a uuid)
  • a secret token for oauth (for me that was 40 characters long)

I then set up the following services (let me know if there is a more elegant way of setting this up with symfony mailer - i did not see how else i can dynamically do the oauth2 login to get a fresh token)

services:
    App\Infrastructure\Email\Office365OAuthTokenProvider:
        $tenant: '%env(resolve:EMAIL_TENANT)%'
        $clientId: '%env(resolve:EMAIL_CLIENT_ID)%'
        $clientSecret: '%env(resolve:EMAIL_CLIENT_SECRET)%'

    App\Infrastructure\Email\OAuthEsmtpTransportFactoryDecorator:
        decorates: mailer.transport_factory.smtp
        arguments:
            $inner: '@.inner'
            $authenticator: '@App\Infrastructure\Email\XOAuth2Authenticator'

and in .env set up the variables:

### symfony/mailer ###
# Username is the full email address. Need to urlencode the "@" in the username.
MAILER_DSN=smtp://email%40domain.com:@smtp.office365.com:587
###< symfony/mailer ###
EMAIL_TENANT=cafebabe-cafe-babe-cafe-babecafebabe
EMAIL_CLIENT_ID=cafebabe-cafe-babe-cafe-babecafebabe
EMAIL_CLIENT_SECRET=

And at runtime inject the right secret token.

<?php
declare(strict_types=1);
namespace App\Infrastructure\Email;
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;
readonly class OAuthEsmtpTransportFactoryDecorator implements TransportFactoryInterface
{
public function __construct(
private EsmtpTransportFactory $inner,
private AuthenticatorInterface $authenticator,
) {
}
public function create(Dsn $dsn): TransportInterface
{
$transport = $this->inner->create($dsn);
if (!$transport instanceof EsmtpTransport) {
return $transport;
}
$transport->setAuthenticators([$this->authenticator]);
return $transport;
}
public function supports(Dsn $dsn): bool
{
return $this->inner->supports($dsn);
}
}
<?php
declare(strict_types=1);
namespace App\Infrastructure\Email;
use GuzzleHttp\UriTemplate\UriTemplate;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Client\ClientInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Contracts\Cache\CacheInterface;
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';
public function __construct(
private readonly ClientInterface $httpClient,
private readonly Psr17Factory $psr17Factory,
private readonly string $tenant,
private readonly string $clientId,
#[\SensitiveParameter]
private readonly string $clientSecret,
// set up some persistent cache for this, e.g. redis - to avoid having to re-authenticate with oauth2 all the time
private readonly CacheInterface $cache,
) {
}
public function getToken(): string
{
return $this->cache->get(self::CACHE_KEY, [$this, 'fetchToken']);
}
public function fetchToken(CacheItem $cacheItem): string
{
$data = [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'scope' => self::SCOPE,
'grant_type' => self::GRANT_TYPE,
];
$body = $this->psr17Factory->createStream(http_build_query($data));
$request = $this->psr17Factory->createRequest('POST', UriTemplate::expand(self::OAUTH_URL, [
'tenant' => $this->tenant,
]))
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
->withBody($body)
;
$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);
$cacheItem->expiresAfter($auth['expires_in'] - 60); // substracting 60 seconds from the TTL as a safety margin to certainly not use an expiring token.
return $auth['access_token'];
}
}
<?php
declare(strict_types=1);
namespace App\Infrastructure\Email;
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.
*/
readonly class XOAuth2Authenticator implements AuthenticatorInterface
{
public function __construct(
private 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]);
}
}
@Soviann
Copy link

Soviann commented Jun 19, 2024

@dbu Hello, for some reason I don't know, Symfony tries to send the mail on its own before reaching the code you provided. Do you have any idea why ?
Can you be more specific about the IDs used ? I created an application on Azure, set the IDs in the env variables, but I still get an authentication unsuccessful.

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