From fb6a2c70ec9becf26cfc6121334bba0489e295e5 Mon Sep 17 00:00:00 2001 From: themylogin Date: Mon, 2 Dec 2024 15:54:26 +0100 Subject: [PATCH] `XOAUTH2` support for Outlook SMTP (#15062) --- src/middlewared/middlewared/plugins/mail.py | 19 +++--- .../middlewared/plugins/mail_/gmail.py | 2 +- .../middlewared/plugins/mail_/outlook.py | 67 +++++++++++++++++++ 3 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 src/middlewared/middlewared/plugins/mail_/outlook.py diff --git a/src/middlewared/middlewared/plugins/mail.py b/src/middlewared/middlewared/plugins/mail.py index 8ec96feb7c650..e09be97ad0d08 100644 --- a/src/middlewared/middlewared/plugins/mail.py +++ b/src/middlewared/middlewared/plugins/mail.py @@ -28,15 +28,13 @@ class DenyNetworkActivity(Exception): pass -class QueueItem(object): - +class QueueItem: def __init__(self, message): self.attempts = 0 self.message = message -class MailQueue(object): - +class MailQueue: MAX_ATTEMPTS = 3 MAX_QUEUE_LIMIT = 20 @@ -73,10 +71,7 @@ class MailModel(sa.Model): class MailService(ConfigService): - mail_queue = MailQueue() - oauth_access_token = None - oauth_access_token_expires_at = None class Config: datastore = 'system.email' @@ -96,6 +91,7 @@ class Config: Password('pass', null=True, required=True), Dict( 'oauth', + Str('provider'), Str('client_id'), Str('client_secret'), Password('refresh_token'), @@ -119,6 +115,7 @@ async def mail_extend(self, cfg): ( 'replace', Dict( 'oauth', + Str('provider'), Str('client_id', required=True), Str('client_secret', required=True), Password('refresh_token', required=True), @@ -370,7 +367,7 @@ def read_json(): msg[key] = val try: - if config['oauth']: + if config['oauth'] and config['oauth']['provider'] == 'gmail': self.middleware.call_sync('mail.gmail_send', msg, config) else: server = self._get_smtp_server(config, message['timeout'], local_hostname=local_hostname) @@ -429,7 +426,9 @@ def _get_smtp_server(self, config, timeout=300, local_hostname=None): local_hostname=local_hostname) if config['security'] == 'TLS': server.starttls() - if config['smtp']: + if config['oauth'] and config['oauth']['provider'] == 'outlook': + self.middleware.call_sync('mail.outlook_xoauth2', server, config) + elif config['smtp']: server.login(config['user'], config['pass']) return server @@ -441,7 +440,7 @@ def send_mail_queue(self): for queue in list(mq.queue): try: config = self.middleware.call_sync('mail.config') - if config['oauth']: + if config['oauth'] and config['oauth']['provider'] == 'gmail': self.middleware.call_sync('mail.gmail_send', queue.message, config) else: server = self._get_smtp_server(config) diff --git a/src/middlewared/middlewared/plugins/mail_/gmail.py b/src/middlewared/middlewared/plugins/mail_/gmail.py index e211742fe4544..549fccf26000d 100644 --- a/src/middlewared/middlewared/plugins/mail_/gmail.py +++ b/src/middlewared/middlewared/plugins/mail_/gmail.py @@ -59,7 +59,7 @@ def gmail_initialize(self): @private def gmail_build_service(self, config): - if config["oauth"]: + if config["oauth"] and config["oauth"]["provider"] == "gmail": return GmailService(config) return None diff --git a/src/middlewared/middlewared/plugins/mail_/outlook.py b/src/middlewared/middlewared/plugins/mail_/outlook.py new file mode 100644 index 0000000000000..40c97e951c9ae --- /dev/null +++ b/src/middlewared/middlewared/plugins/mail_/outlook.py @@ -0,0 +1,67 @@ +import base64 +from dataclasses import dataclass +from smtplib import SMTP +import time + +import requests + +from middlewared.service import CallError, private, Service + + +@dataclass +class OutlookToken: + token: str + expires_at: float + + +class MailService(Service): + outlook_tokens: dict[str, OutlookToken] = {} + + @private + def outlook_xoauth2(self, server: SMTP, config: dict): + server.ehlo() + + if token := self._get_outlook_token(config["fromemail"], config["oauth"]["refresh_token"]): + code, response = self._do_xoauth2(server, config["fromemail"], token) + if 200 <= code <= 299: + return + + self.logger.warning("Outlook XOAUTH2 failed: %r %r. Refreshing access token", code, response) + + self.logger.debug("Requesting Outlook access token") + r = requests.post( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + data={ + "grant_type": "refresh_token", + "client_id": config["oauth"]["client_id"], + "client_secret": config["oauth"]["client_secret"], + "refresh_token": config["oauth"]["refresh_token"], + "scope": "https://outlook.office.com/SMTP.Send openid offline_access", + } + ) + r.raise_for_status() + response = r.json() + + token = response["access_token"] + self._set_outlook_token(config["fromemail"], config["oauth"]["refresh_token"], token, response["expires_in"]) + + code, response = self._do_xoauth2(server, config["fromemail"], token) + if 200 <= code <= 299: + return + + raise CallError("Outlook XOAUTH2 failed: %r %r" % (code, response)) + + def _get_outlook_token(self, email: str, refresh_token: str) -> str | None: + for key, token in list(self.outlook_tokens.items()): + if token.expires_at < time.monotonic() - 5: + self.outlook_tokens.pop(key) + + if token := self.outlook_tokens.get(email + refresh_token): + return token.token + + def _set_outlook_token(self, email: str, refresh_token: str, token: str, expires_in: int): + self.outlook_tokens[email + refresh_token] = OutlookToken(token, time.monotonic() + expires_in) + + def _do_xoauth2(self, server: SMTP, email: str, access_token: str): + auth_string = f"user={email}\1auth=Bearer {access_token}\1\1" + return server.docmd("AUTH XOAUTH2", base64.b64encode(auth_string.encode()).decode())