Skip to content

Instantly share code, notes, and snippets.

@dbu
Last active April 18, 2024 13:11
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • 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]);
}
}
@renttek
Copy link

renttek commented Nov 13, 2023

I like this solution. Only a small suggestion: adding the #[\SensitiveParameter] attribute to the $clientId and $clientSecret parameters of Office365OAuthTokenProvider

@QuentinCvl
Copy link

Hi, first of all thanks you ! I search for a solution from 2 days ...

When and how you call your service for sending the mail ?

@dbu
Copy link
Author

dbu commented Nov 15, 2023

When and how you call your service for sending the mail ?

with this setup, i use the normal mailer service of symfony, as documented here: https://symfony.com/doc/current/mailer.html#creating-sending-messages

@QuentinCvl
Copy link

Strange, I keep getting this following error message:

Symfony\Component\Mailer\Exception\TransportException: Failed to authenticate on SMTP server with username "email@domain.com" using the following authenticators: "XOAUTH2". Authenticator "XOAUTH2" returned "Expected response code "235" but got code "535", with message "535 5.7.3 Authentication unsuccessful [PR0P264CA0086.FRAP264.PROD.OUTLOOK.COM 2023-11-15T08:38:47.821Z 08DBE564E9B2FD67]

@dbu
Copy link
Author

dbu commented Nov 15, 2023

strange. so you do get the access_token, but then actually sending the email fails? sorry, i am no expert on this either. once i managed to get the token from microsoft, it worked for me.

@QuentinCvl
Copy link

When I add some debug, I found in logs :

[2023-11-15T11:00:17.013157+01:00] mailer.DEBUG: Email transport "Symfony\Component\Mailer\Transport\Smtp\SmtpTransport" starting [] []
[2023-11-15T11:00:17.197746+01:00] cache.INFO: Lock acquired, now computing item "email-token" {"key":"email-token"} []
[2023-11-15T11:00:17.198456+01:00] http_client.INFO: Request: "POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" [] []
[2023-11-15T11:00:17.483973+01:00] http_client.INFO: Response: "200 https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" [] []
[2023-11-15T11:00:17.484609+01:00] app.INFO: Token fetched successfully: {token} [] []
[2023-11-15T11:00:23.894482+01:00] app.ERROR: Error during XOAUTH2 authentication: Expected response code "235" but got code "535", with message "535 5.7.3 Authentication unsuccessful [PAZP264CA0065.FRAP264.PROD.OUTLOOK.COM 2023-11-15T10:00:23.749Z 08DBE561A3079421]". [] []

Its so strange ! The token is correctly received but the authentication fails =(
Oh my acces_token is excessively long : CHARACTERS 1479 WORDS 1 SENTENCES 3 PARAGRAPHS 1 SPACES 0

@dbu
Copy link
Author

dbu commented Nov 15, 2023

that looks excessive indeed. could maybe be an error response used as token? i was able to run the code locally and thats how i debugged. do you really post to {tenant} or is that replaced with your tenant id?

@QuentinCvl
Copy link

do you really post to {tenant} or is that replaced with your tenant id?

No I replaced it to publish here, I use the right tenant is my app

could maybe be an error response used as token?

it really looks like a token, here is a small part :

eyJ0eXAiOiJKV1QiLCJub25jZSI6Ik13NWxhcmNpUnZaRU5NRjZQZjYzZFRCRzNaNWlGMWhIal9PejdfUUlVWVkiLCJhbGciOiJSUzI1NiIsIng1dCI6IjlHbW55RlBraGMzaE91UjIybXZTdmduTG83WSIsImtpZCI6IjlHbW55RlBraGMzaE91UjIybXZTdmduTG83WSJ9.eyJhdWQiOiJodHRwczovL291dGxvb2sub2ZmaWNlMzY1LmNvbSIsImlzcyI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0LzgyZTE5Nzc4LWJjOWItNDVhOS04YjcwLTg5NmUxYmFlMDAyYi8iL

@beerendlauwers
Copy link

Here's an adapted version of the code for Drupal's Symfony Mailer module: https://gist.github.com/beerendlauwers/03acb112c80af0bf91546a05f790057a

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