From 69f712b7132bb8e255c4c04a35bb7bebfdfaaffd Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 22 Jul 2025 03:00:16 +0800 Subject: [PATCH] feat: Add SMTP OAuth 2.0 support for Microsoft Exchange Add comprehensive OAuth 2.0 authentication support for SMTP to address Microsoft's Basic Authentication retirement in September 2025. Key features: - OAuth 2.0 SASL XOAUTH2 authentication mechanism - Microsoft Azure AD integration with client credentials flow - Backward compatible with existing basic authentication - Comprehensive configuration options in .env.example files - Enhanced SMTP client with dependency injection for better testability - Complete test coverage with proper mocking Configuration: - SMTP_AUTH_TYPE: Choose between 'basic' and 'oauth2' authentication - Microsoft OAuth 2.0 settings for Azure AD integration - Automatic token acquisition using client credentials flow Files changed: - Enhanced SMTP client with OAuth 2.0 support - New mail module structure under libs/mail/ - Updated configuration system with OAuth settings - Comprehensive documentation and setup instructions - Complete test suite for OAuth functionality This change ensures compatibility with Microsoft Exchange Online after Basic Authentication retirement. --- api/.env.example | 13 + api/configs/feature/__init__.py | 26 ++ api/extensions/ext_mail.py | 22 +- api/libs/mail/README.md | 276 +++++++++++++ api/libs/mail/__init__.py | 26 ++ api/libs/mail/oauth_email.py | 175 ++++++++ api/libs/mail/oauth_http_client.py | 45 +++ api/libs/mail/smtp.py | 164 ++++++++ api/libs/mail/smtp_connection.py | 79 ++++ api/libs/smtp.py | 59 --- .../unit_tests/libs/mail/test_oauth_email.py | 375 ++++++++++++++++++ api/tests/unit_tests/libs/mail/test_smtp.py | 368 +++++++++++++++++ docker/.env.example | 27 ++ 13 files changed, 1593 insertions(+), 62 deletions(-) create mode 100644 api/libs/mail/README.md create mode 100644 api/libs/mail/__init__.py create mode 100644 api/libs/mail/oauth_email.py create mode 100644 api/libs/mail/oauth_http_client.py create mode 100644 api/libs/mail/smtp.py create mode 100644 api/libs/mail/smtp_connection.py delete mode 100644 api/libs/smtp.py create mode 100644 api/tests/unit_tests/libs/mail/test_oauth_email.py create mode 100644 api/tests/unit_tests/libs/mail/test_smtp.py diff --git a/api/.env.example b/api/.env.example index 78a363e506..63df2182ad 100644 --- a/api/.env.example +++ b/api/.env.example @@ -379,6 +379,19 @@ SMTP_USERNAME=123 SMTP_PASSWORD=abc SMTP_USE_TLS=true SMTP_OPPORTUNISTIC_TLS=false + +# SMTP authentication type: 'basic' for username/password, 'oauth2' for Microsoft OAuth 2.0 +# Use 'oauth2' for Microsoft Exchange/Outlook due to Basic Auth retirement (September 2025) +SMTP_AUTH_TYPE=basic + +# Microsoft OAuth 2.0 configuration for SMTP authentication +# Required when SMTP_AUTH_TYPE=oauth2 and using Microsoft Exchange/Outlook +# Setup: Create Azure AD app → Add Mail.Send + SMTP.Send permissions → Get Client ID/Secret +# For Exchange Online: SMTP_SERVER=smtp.office365.com, SMTP_PORT=587, SMTP_USE_TLS=true +MICROSOFT_OAUTH2_CLIENT_ID= +MICROSOFT_OAUTH2_CLIENT_SECRET= +MICROSOFT_OAUTH2_TENANT_ID=common +MICROSOFT_OAUTH2_ACCESS_TOKEN= # Sendgid configuration SENDGRID_API_KEY= # Sentry configuration diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index b17f30210c..ffd5838279 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -791,6 +791,32 @@ class MailConfig(BaseSettings): default=False, ) + SMTP_AUTH_TYPE: str = Field( + description="SMTP authentication type ('basic' or 'oauth2')", + default="basic", + ) + + # Microsoft OAuth 2.0 configuration for SMTP + MICROSOFT_OAUTH2_CLIENT_ID: Optional[str] = Field( + description="Microsoft OAuth 2.0 client ID for SMTP authentication", + default=None, + ) + + MICROSOFT_OAUTH2_CLIENT_SECRET: Optional[str] = Field( + description="Microsoft OAuth 2.0 client secret for SMTP authentication", + default=None, + ) + + MICROSOFT_OAUTH2_TENANT_ID: str = Field( + description="Microsoft OAuth 2.0 tenant ID (use 'common' for multi-tenant)", + default="common", + ) + + MICROSOFT_OAUTH2_ACCESS_TOKEN: Optional[str] = Field( + description="Microsoft OAuth 2.0 access token for SMTP authentication", + default=None, + ) + EMAIL_SEND_IP_LIMIT_PER_MINUTE: PositiveInt = Field( description="Maximum number of emails allowed to be sent from the same IP address in a minute", default=50, diff --git a/api/extensions/ext_mail.py b/api/extensions/ext_mail.py index 042bf8cc47..bbfc4ad974 100644 --- a/api/extensions/ext_mail.py +++ b/api/extensions/ext_mail.py @@ -16,7 +16,7 @@ class Mail: def is_inited(self) -> bool: return self._client is not None - def init_app(self, app: Flask): + def init_app(self, _: Flask): mail_type = dify_config.MAIL_TYPE if not mail_type: logger.warning("MAIL_TYPE is not set") @@ -40,20 +40,36 @@ class Mail: resend.api_key = api_key self._client = resend.Emails case "smtp": - from libs.smtp import SMTPClient + from libs.mail import SMTPClient if not dify_config.SMTP_SERVER or not dify_config.SMTP_PORT: raise ValueError("SMTP_SERVER and SMTP_PORT are required for smtp mail type") if not dify_config.SMTP_USE_TLS and dify_config.SMTP_OPPORTUNISTIC_TLS: raise ValueError("SMTP_OPPORTUNISTIC_TLS is not supported without enabling SMTP_USE_TLS") + + # Validate OAuth 2.0 configuration if auth_type is oauth2 + oauth_access_token = None + + if dify_config.SMTP_AUTH_TYPE == "oauth2": + oauth_access_token = dify_config.MICROSOFT_OAUTH2_ACCESS_TOKEN + if not oauth_access_token: + # Try to get token using client credentials flow + if dify_config.MICROSOFT_OAUTH2_CLIENT_ID and dify_config.MICROSOFT_OAUTH2_CLIENT_SECRET: + oauth_access_token = self._get_oauth_token() + + if not oauth_access_token: + raise ValueError("OAuth 2.0 access token is required for oauth2 auth_type") + self._client = SMTPClient( server=dify_config.SMTP_SERVER, port=dify_config.SMTP_PORT, username=dify_config.SMTP_USERNAME or "", password=dify_config.SMTP_PASSWORD or "", - _from=dify_config.MAIL_DEFAULT_SEND_FROM or "", + from_addr=dify_config.MAIL_DEFAULT_SEND_FROM or "", use_tls=dify_config.SMTP_USE_TLS, opportunistic_tls=dify_config.SMTP_OPPORTUNISTIC_TLS, + oauth_access_token=oauth_access_token, + auth_type=dify_config.SMTP_AUTH_TYPE, ) case "sendgrid": from libs.sendgrid import SendGridClient diff --git a/api/libs/mail/README.md b/api/libs/mail/README.md new file mode 100644 index 0000000000..dcb5d1e01d --- /dev/null +++ b/api/libs/mail/README.md @@ -0,0 +1,276 @@ +# Email Module + +This module provides email functionality for Dify, including SMTP with OAuth 2.0 support for Microsoft Exchange/Outlook. + +## Features + +- Basic SMTP authentication +- OAuth 2.0 authentication for Microsoft Exchange/Outlook +- Multiple email providers: SMTP, SendGrid, Resend +- TLS/SSL support +- Microsoft Exchange compliance (Basic Auth retirement September 2025) + +## Configuration + +### Basic SMTP Configuration + +```env +MAIL_TYPE=smtp +MAIL_DEFAULT_SEND_FROM=your-email@company.com +SMTP_SERVER=smtp.company.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@company.com +SMTP_PASSWORD=your-password +SMTP_USE_TLS=true +SMTP_OPPORTUNISTIC_TLS=true +SMTP_AUTH_TYPE=basic +``` + +### Microsoft Exchange OAuth 2.0 Configuration + +For Microsoft Exchange/Outlook compatibility: + +```env +MAIL_TYPE=smtp +MAIL_DEFAULT_SEND_FROM=your-email@company.com +SMTP_SERVER=smtp.office365.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@company.com +SMTP_USE_TLS=true +SMTP_OPPORTUNISTIC_TLS=true +SMTP_AUTH_TYPE=oauth2 + +# Microsoft OAuth 2.0 Settings +MICROSOFT_OAUTH2_CLIENT_ID=your-azure-app-client-id +MICROSOFT_OAUTH2_CLIENT_SECRET=your-azure-app-client-secret +MICROSOFT_OAUTH2_TENANT_ID=your-tenant-id +``` + +## Microsoft Azure AD App Setup + +### 1. Create Azure AD Application + +1. Go to [Azure Portal](https://portal.azure.com) → Azure Active Directory → App registrations +2. Click "New registration" +3. Enter application name (e.g., "Dify Email Service") +4. Select "Accounts in this organizational directory only" +5. Click "Register" + +### 2. Configure API Permissions + +1. Go to "API permissions" +2. Click "Add a permission" → Microsoft Graph +3. Select "Application permissions" +4. Add these permissions: + - `Mail.Send` - Send mail as any user + - `SMTP.Send` - Send email via SMTP AUTH +5. Click "Grant admin consent" + +### 3. Create Client Secret + +1. Go to "Certificates & secrets" +2. Click "New client secret" +3. Enter description and expiration +4. Copy the secret value (you won't see it again) + +### 4. Get Configuration Values + +- **Client ID**: Application (client) ID from Overview page +- **Client Secret**: The secret value you just created +- **Tenant ID**: Directory (tenant) ID from Overview page + +## Usage Examples + +### Basic Usage + +The email service is automatically configured based on environment variables. Simply use the mail extension: + +```python +from extensions.ext_mail import mail + +# Send email +mail_data = { + "to": "recipient@example.com", + "subject": "Test Email", + "html": "

Hello World

" +} + +try: + mail._client.send(mail_data) + print("Email sent successfully") +except Exception as e: + print(f"Failed to send email: {e}") +``` + +### OAuth Token Management + +For service accounts using client credentials flow: + +```python +from libs.mail.oauth_email import MicrosoftEmailOAuth + +# Initialize OAuth client +oauth_client = MicrosoftEmailOAuth( + client_id="your-client-id", + client_secret="your-client-secret", + redirect_uri="", # Not needed for client credentials + tenant_id="your-tenant-id" +) + +# Get access token +try: + token_response = oauth_client.get_access_token_client_credentials() + access_token = token_response["access_token"] + print(f"Access token obtained: {access_token[:10]}...") +except Exception as e: + print(f"Failed to get OAuth token: {e}") +``` + +### Custom SMTP Client + +For direct SMTP usage with OAuth: + +```python +from libs.mail import SMTPClient + +# Create SMTP client with OAuth +client = SMTPClient( + server="smtp.office365.com", + port=587, + username="your-email@company.com", + password="", # Not used with OAuth + from_addr="your-email@company.com", + use_tls=True, + opportunistic_tls=True, + oauth_access_token="your-access-token", + auth_type="oauth2" +) + +# Send email +mail_data = { + "to": "recipient@example.com", + "subject": "OAuth Test", + "html": "

Sent via OAuth 2.0

" +} + +client.send(mail_data) +``` + +## Migration from Basic Auth + +### Microsoft Exchange Migration + +Microsoft is retiring Basic Authentication for Exchange Online in September 2025. Follow these steps to migrate: + +1. **Set up Azure AD Application** (see setup instructions above) +2. **Update configuration** to use OAuth 2.0: + + ```env + SMTP_AUTH_TYPE=oauth2 + MICROSOFT_OAUTH2_CLIENT_ID=your-client-id + MICROSOFT_OAUTH2_CLIENT_SECRET=your-client-secret + MICROSOFT_OAUTH2_TENANT_ID=your-tenant-id + ``` + +3. **Test the configuration** before the migration deadline +4. **Remove old password-based settings** once OAuth is working + +### Backward Compatibility + +The system maintains backward compatibility: + +- Existing Basic Auth configurations continue to work +- OAuth settings are optional and only used when `SMTP_AUTH_TYPE=oauth2` +- Gradual migration is supported + +## Troubleshooting + +### Common OAuth Issues + +1. **Token acquisition fails**: + - Verify Client ID and Secret are correct + - Check that admin consent was granted for API permissions + - Ensure Tenant ID is correct + +2. **SMTP authentication fails**: + - Verify the access token is valid and not expired + - Check that SMTP.Send permission is granted + - Ensure the user has Send As permissions + +3. **Configuration issues**: + - Verify all required environment variables are set + - Check SMTP server and port settings + - Ensure TLS settings match your server requirements + +### Testing Token Acquisition + +```python +from libs.mail.oauth_email import MicrosoftEmailOAuth + +def test_oauth_token(): + oauth_client = MicrosoftEmailOAuth( + client_id="your-client-id", + client_secret="your-client-secret", + redirect_uri="", + tenant_id="your-tenant-id" + ) + + try: + response = oauth_client.get_access_token_client_credentials() + print("✓ OAuth token acquired successfully") + print(f"Token type: {response.get('token_type')}") + print(f"Expires in: {response.get('expires_in')} seconds") + return True + except Exception as e: + print(f"✗ OAuth token acquisition failed: {e}") + return False + +if __name__ == "__main__": + test_oauth_token() +``` + +## Security Considerations + +### Token Management + +- Access tokens are automatically obtained when needed +- Tokens are not stored permanently +- Client credentials flow is used for service accounts +- Secrets should be stored securely in environment variables + +### Network Security + +- Always use TLS for SMTP connections (`SMTP_USE_TLS=true`) +- Use opportunistic TLS when supported (`SMTP_OPPORTUNISTIC_TLS=true`) +- Verify SMTP server certificates in production + +### Access Control + +- Grant minimum required permissions in Azure AD +- Use dedicated service accounts for email sending +- Regularly rotate client secrets +- Monitor access logs for suspicious activity + +## Dependencies + +The email module uses these internal components: + +- `libs.mail.smtp`: Core SMTP client with OAuth support +- `libs.mail.oauth_email`: Microsoft OAuth 2.0 implementation +- `libs.mail.oauth_http_client`: HTTP client abstraction +- `libs.mail.smtp_connection`: SMTP connection management +- `extensions.ext_mail`: Flask extension for email integration + +## Testing + +The module includes comprehensive tests with proper mocking: + +- `tests/unit_tests/libs/mail/test_oauth_email.py`: OAuth functionality tests +- `tests/unit_tests/libs/mail/test_smtp_enhanced.py`: SMTP client tests + +Run tests with: + +```bash +uv run pytest tests/unit_tests/libs/mail/test_oauth_email.py -v +uv run pytest tests/unit_tests/libs/mail/test_smtp_enhanced.py -v +``` diff --git a/api/libs/mail/__init__.py b/api/libs/mail/__init__.py new file mode 100644 index 0000000000..c61d62e5d3 --- /dev/null +++ b/api/libs/mail/__init__.py @@ -0,0 +1,26 @@ +"""Mail module for email functionality + +This module provides comprehensive email support including: +- SMTP clients with OAuth 2.0 support +- Microsoft Exchange/Outlook integration +- Email authentication and connection management +- Support for TLS/SSL encryption +""" + +from .oauth_email import EmailOAuth, MicrosoftEmailOAuth, OAuthUserInfo +from .oauth_http_client import OAuthHTTPClient, OAuthHTTPClientProtocol +from .smtp import SMTPAuthenticator, SMTPClient, SMTPMessageBuilder +from .smtp_connection import SMTPConnectionFactory, SMTPConnectionProtocol + +__all__ = [ + "EmailOAuth", + "MicrosoftEmailOAuth", + "OAuthHTTPClient", + "OAuthHTTPClientProtocol", + "OAuthUserInfo", + "SMTPAuthenticator", + "SMTPClient", + "SMTPConnectionFactory", + "SMTPConnectionProtocol", + "SMTPMessageBuilder", +] diff --git a/api/libs/mail/oauth_email.py b/api/libs/mail/oauth_email.py new file mode 100644 index 0000000000..0ece9aa865 --- /dev/null +++ b/api/libs/mail/oauth_email.py @@ -0,0 +1,175 @@ +"""Email OAuth implementation with dependency injection for better testability""" + +import base64 +import urllib.parse +from dataclasses import dataclass +from typing import Optional, Union + +from .oauth_http_client import OAuthHTTPClient, OAuthHTTPClientProtocol + + +@dataclass +class OAuthUserInfo: + id: str + name: str + email: str + + +class EmailOAuth: + """Base OAuth class with dependency injection""" + + def __init__( + self, + client_id: str, + client_secret: str, + redirect_uri: str, + http_client: Optional[OAuthHTTPClientProtocol] = None, + ): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.http_client = http_client or OAuthHTTPClient() + + def get_authorization_url(self): + raise NotImplementedError() + + def get_access_token(self, code: str): + raise NotImplementedError() + + def get_raw_user_info(self, token: str): + raise NotImplementedError() + + def get_user_info(self, token: str) -> OAuthUserInfo: + raw_info = self.get_raw_user_info(token) + return self._transform_user_info(raw_info) + + def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo: + raise NotImplementedError() + + +class MicrosoftEmailOAuth(EmailOAuth): + """Microsoft OAuth 2.0 implementation with dependency injection + + References: + - Microsoft identity platform OAuth 2.0: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow + - Microsoft Graph API permissions: https://learn.microsoft.com/en-us/graph/permissions-reference + - OAuth 2.0 client credentials flow: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow + - SMTP OAuth 2.0 authentication: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth + """ + + _AUTH_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize" + _TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + _USER_INFO_URL = "https://graph.microsoft.com/v1.0/me" + + def __init__( + self, + client_id: str, + client_secret: str, + redirect_uri: str, + tenant_id: str = "common", + http_client: Optional[OAuthHTTPClientProtocol] = None, + ): + super().__init__(client_id, client_secret, redirect_uri, http_client) + self.tenant_id = tenant_id + + def get_authorization_url(self, invite_token: Optional[str] = None) -> str: + """Generate OAuth authorization URL""" + params = { + "client_id": self.client_id, + "response_type": "code", + "redirect_uri": self.redirect_uri, + "scope": "https://outlook.office.com/SMTP.Send offline_access", + "response_mode": "query", + } + if invite_token: + params["state"] = invite_token + + auth_url = self._AUTH_URL.format(tenant=self.tenant_id) + return f"{auth_url}?{urllib.parse.urlencode(params)}" + + def get_access_token(self, code: str) -> dict[str, Union[str, int]]: + """Get access token using authorization code flow""" + data: dict[str, Union[str, int]] = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.redirect_uri, + "scope": "https://outlook.office.com/SMTP.Send offline_access", + } + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + token_url = self._TOKEN_URL.format(tenant=self.tenant_id) + response = self.http_client.post(token_url, data=data, headers=headers) + + if response["status_code"] != 200: + raise ValueError(f"Error in Microsoft OAuth: {response['json']}") + + json_response = response["json"] + if isinstance(json_response, dict): + return json_response + raise ValueError("Unexpected response format") + + def get_access_token_client_credentials( + self, scope: str = "https://outlook.office365.com/.default" + ) -> dict[str, Union[str, int]]: + """Get access token using client credentials flow (for service accounts)""" + data: dict[str, Union[str, int]] = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "client_credentials", + "scope": scope, + } + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + token_url = self._TOKEN_URL.format(tenant=self.tenant_id) + response = self.http_client.post(token_url, data=data, headers=headers) + + if response["status_code"] != 200: + raise ValueError(f"Error in Microsoft OAuth Client Credentials: {response['json']}") + + json_response = response["json"] + if isinstance(json_response, dict): + return json_response + raise ValueError("Unexpected response format") + + def refresh_access_token(self, refresh_token: str) -> dict[str, Union[str, int]]: + """Refresh access token using refresh token""" + data: dict[str, Union[str, int]] = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + "scope": "https://outlook.office.com/SMTP.Send offline_access", + } + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + token_url = self._TOKEN_URL.format(tenant=self.tenant_id) + response = self.http_client.post(token_url, data=data, headers=headers) + + if response["status_code"] != 200: + raise ValueError(f"Error refreshing Microsoft OAuth token: {response['json']}") + + json_response = response["json"] + if isinstance(json_response, dict): + return json_response + raise ValueError("Unexpected response format") + + def get_raw_user_info(self, token: str) -> dict[str, Union[str, int, dict, list]]: + """Get user info from Microsoft Graph API""" + headers = {"Authorization": f"Bearer {token}"} + return self.http_client.get(self._USER_INFO_URL, headers=headers) + + def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo: + """Transform raw user info to OAuthUserInfo""" + return OAuthUserInfo( + id=str(raw_info["id"]), + name=raw_info.get("displayName", ""), + email=raw_info.get("mail", raw_info.get("userPrincipalName", "")), + ) + + @staticmethod + def create_sasl_xoauth2_string(username: str, access_token: str) -> str: + """Create SASL XOAUTH2 authentication string for SMTP""" + auth_string = f"user={username}\x01auth=Bearer {access_token}\x01\x01" + return base64.b64encode(auth_string.encode()).decode() diff --git a/api/libs/mail/oauth_http_client.py b/api/libs/mail/oauth_http_client.py new file mode 100644 index 0000000000..d23dd04e90 --- /dev/null +++ b/api/libs/mail/oauth_http_client.py @@ -0,0 +1,45 @@ +"""HTTP client abstraction for OAuth requests""" + +from abc import ABC, abstractmethod +from typing import Optional, Union + +import requests + + +class OAuthHTTPClientProtocol(ABC): + """Abstract interface for OAuth HTTP operations""" + + @abstractmethod + def post( + self, url: str, data: dict[str, Union[str, int]], headers: Optional[dict[str, str]] = None + ) -> dict[str, Union[str, int, dict, list]]: + """Make a POST request""" + pass + + @abstractmethod + def get(self, url: str, headers: Optional[dict[str, str]] = None) -> dict[str, Union[str, int, dict, list]]: + """Make a GET request""" + pass + + +class OAuthHTTPClient(OAuthHTTPClientProtocol): + """Default implementation using requests library""" + + def post( + self, url: str, data: dict[str, Union[str, int]], headers: Optional[dict[str, str]] = None + ) -> dict[str, Union[str, int, dict, list]]: + """Make a POST request""" + response = requests.post(url, data=data, headers=headers or {}) + return { + "status_code": response.status_code, + "json": response.json() if response.headers.get("content-type", "").startswith("application/json") else {}, + "text": response.text, + "headers": dict(response.headers), + } + + def get(self, url: str, headers: Optional[dict[str, str]] = None) -> dict[str, Union[str, int, dict, list]]: + """Make a GET request""" + response = requests.get(url, headers=headers or {}) + response.raise_for_status() + json_data = response.json() + return dict(json_data) diff --git a/api/libs/mail/smtp.py b/api/libs/mail/smtp.py new file mode 100644 index 0000000000..33aebd7e11 --- /dev/null +++ b/api/libs/mail/smtp.py @@ -0,0 +1,164 @@ +"""Enhanced SMTP client with dependency injection for better testability""" + +import base64 +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Optional + +from .smtp_connection import ( + SMTPConnectionFactory, + SMTPConnectionProtocol, + SSLSMTPConnectionFactory, + StandardSMTPConnectionFactory, +) + + +class SMTPAuthenticator: + """Handles SMTP authentication logic""" + + @staticmethod + def create_sasl_xoauth2_string(username: str, access_token: str) -> str: + """Create SASL XOAUTH2 authentication string for SMTP OAuth2 + + References: + - SASL XOAUTH2 Mechanism: https://developers.google.com/gmail/imap/xoauth2-protocol + - Microsoft XOAUTH2 Format: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#sasl-xoauth2 + """ + auth_string = f"user={username}\x01auth=Bearer {access_token}\x01\x01" + return base64.b64encode(auth_string.encode()).decode() + + def authenticate_basic(self, connection: SMTPConnectionProtocol, username: str, password: str) -> None: + """Perform basic authentication""" + if username and password and username.strip() and password.strip(): + connection.login(username, password) + + def authenticate_oauth2(self, connection: SMTPConnectionProtocol, username: str, access_token: str) -> None: + """Perform OAuth 2.0 authentication using SASL XOAUTH2 mechanism + + References: + - Microsoft OAuth 2.0 and SMTP: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth + - SASL XOAUTH2 Mechanism: https://developers.google.com/gmail/imap/xoauth2-protocol + - RFC 4954 - SMTP AUTH: https://tools.ietf.org/html/rfc4954 + """ + if not username or not access_token: + raise ValueError("Username and OAuth access token are required for OAuth2 authentication") + + auth_string = self.create_sasl_xoauth2_string(username, access_token) + + try: + connection.docmd("AUTH", f"XOAUTH2 {auth_string}") + except smtplib.SMTPAuthenticationError as e: + logging.exception(f"OAuth2 authentication failed for user {username}") + raise ValueError(f"OAuth2 authentication failed: {str(e)}") + except Exception: + logging.exception(f"Unexpected error during OAuth2 authentication for user {username}") + raise + + +class SMTPMessageBuilder: + """Builds SMTP messages""" + + @staticmethod + def build_message(mail_data: dict[str, str], from_addr: str) -> MIMEMultipart: + """Build a MIME message from mail data""" + msg = MIMEMultipart() + msg["Subject"] = mail_data["subject"] + msg["From"] = from_addr + msg["To"] = mail_data["to"] + msg.attach(MIMEText(mail_data["html"], "html")) + return msg + + +class SMTPClient: + """SMTP client with OAuth 2.0 support and dependency injection for better testability""" + + def __init__( + self, + server: str, + port: int, + username: str, + password: str, + from_addr: str, + use_tls: bool = False, + opportunistic_tls: bool = False, + oauth_access_token: Optional[str] = None, + auth_type: str = "basic", + connection_factory: Optional[SMTPConnectionFactory] = None, + ssl_connection_factory: Optional[SMTPConnectionFactory] = None, + authenticator: Optional[SMTPAuthenticator] = None, + message_builder: Optional[SMTPMessageBuilder] = None, + ): + self.server = server + self.port = port + self.from_addr = from_addr + self.username = username + self.password = password + self.use_tls = use_tls + self.opportunistic_tls = opportunistic_tls + self.oauth_access_token = oauth_access_token + self.auth_type = auth_type + + # Use injected dependencies or create defaults + self.connection_factory = connection_factory or StandardSMTPConnectionFactory() + self.ssl_connection_factory = ssl_connection_factory or SSLSMTPConnectionFactory() + self.authenticator = authenticator or SMTPAuthenticator() + self.message_builder = message_builder or SMTPMessageBuilder() + + def _create_connection(self) -> SMTPConnectionProtocol: + """Create appropriate SMTP connection based on TLS settings""" + if self.use_tls and not self.opportunistic_tls: + return self.ssl_connection_factory.create_connection(self.server, self.port) + else: + return self.connection_factory.create_connection(self.server, self.port) + + def _setup_tls_if_needed(self, connection: SMTPConnectionProtocol) -> None: + """Setup TLS if opportunistic TLS is enabled""" + if self.use_tls and self.opportunistic_tls: + connection.ehlo(self.server) + connection.starttls() + connection.ehlo(self.server) + + def _authenticate(self, connection: SMTPConnectionProtocol) -> None: + """Authenticate with the SMTP server""" + if self.auth_type == "oauth2": + if not self.oauth_access_token: + raise ValueError("OAuth access token is required for oauth2 auth_type") + self.authenticator.authenticate_oauth2(connection, self.username, self.oauth_access_token) + else: + self.authenticator.authenticate_basic(connection, self.username, self.password) + + def send(self, mail: dict[str, str]) -> None: + """Send email using SMTP""" + connection = None + try: + # Create connection + connection = self._create_connection() + + # Setup TLS if needed + self._setup_tls_if_needed(connection) + + # Authenticate + self._authenticate(connection) + + # Build and send message + msg = self.message_builder.build_message(mail, self.from_addr) + connection.sendmail(self.from_addr, mail["to"], msg.as_string()) + + except smtplib.SMTPException: + logging.exception("SMTP error occurred") + raise + except TimeoutError: + logging.exception("Timeout occurred while sending email") + raise + except Exception: + logging.exception(f"Unexpected error occurred while sending email to {mail['to']}") + raise + finally: + if connection: + try: + connection.quit() + except Exception: + # Ignore errors during cleanup + pass diff --git a/api/libs/mail/smtp_connection.py b/api/libs/mail/smtp_connection.py new file mode 100644 index 0000000000..9c646c7d37 --- /dev/null +++ b/api/libs/mail/smtp_connection.py @@ -0,0 +1,79 @@ +"""SMTP connection abstraction for better testability""" + +import smtplib +from abc import ABC, abstractmethod +from typing import Protocol, Union + + +class SMTPConnectionProtocol(Protocol): + """Protocol defining SMTP connection interface""" + + def ehlo(self, name: str = "") -> tuple[int, bytes]: ... + + def starttls(self) -> tuple[int, bytes]: ... + + def login(self, user: str, password: str) -> tuple[int, bytes]: ... + + def docmd(self, cmd: str, args: str = "") -> tuple[int, bytes]: ... + + def sendmail(self, from_addr: str, to_addrs: str, msg: str) -> dict: ... + + def quit(self) -> tuple[int, bytes]: ... + + +class SMTPConnectionFactory(ABC): + """Abstract factory for creating SMTP connections""" + + @abstractmethod + def create_connection(self, server: str, port: int, timeout: int = 10) -> SMTPConnectionProtocol: + """Create an SMTP connection""" + pass + + +class SMTPConnectionWrapper: + """Wrapper to adapt smtplib.SMTP to our protocol""" + + def __init__(self, smtp_obj: Union[smtplib.SMTP, smtplib.SMTP_SSL]): + self._smtp = smtp_obj + + def ehlo(self, name: str = "") -> tuple[int, bytes]: + result = self._smtp.ehlo(name) + return (result[0], result[1]) + + def starttls(self) -> tuple[int, bytes]: + result = self._smtp.starttls() + return (result[0], result[1]) + + def login(self, user: str, password: str) -> tuple[int, bytes]: + result = self._smtp.login(user, password) + return (result[0], result[1]) + + def docmd(self, cmd: str, args: str = "") -> tuple[int, bytes]: + result = self._smtp.docmd(cmd, args) + return (result[0], result[1]) + + def sendmail(self, from_addr: str, to_addrs: str, msg: str) -> dict: + result = self._smtp.sendmail(from_addr, to_addrs, msg) + return dict(result) + + def quit(self) -> tuple[int, bytes]: + result = self._smtp.quit() + return (result[0], result[1]) + + +class StandardSMTPConnectionFactory(SMTPConnectionFactory): + """Factory for creating standard SMTP connections""" + + def create_connection(self, server: str, port: int, timeout: int = 10) -> SMTPConnectionProtocol: + """Create a standard SMTP connection""" + smtp_obj = smtplib.SMTP(server, port, timeout=timeout) + return SMTPConnectionWrapper(smtp_obj) + + +class SSLSMTPConnectionFactory(SMTPConnectionFactory): + """Factory for creating SSL SMTP connections""" + + def create_connection(self, server: str, port: int, timeout: int = 10) -> SMTPConnectionProtocol: + """Create an SSL SMTP connection""" + smtp_obj = smtplib.SMTP_SSL(server, port, timeout=timeout) + return SMTPConnectionWrapper(smtp_obj) diff --git a/api/libs/smtp.py b/api/libs/smtp.py deleted file mode 100644 index 4044c6f7ed..0000000000 --- a/api/libs/smtp.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -import smtplib -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText - -logger = logging.getLogger(__name__) - - -class SMTPClient: - def __init__( - self, server: str, port: int, username: str, password: str, _from: str, use_tls=False, opportunistic_tls=False - ): - self.server = server - self.port = port - self._from = _from - self.username = username - self.password = password - self.use_tls = use_tls - self.opportunistic_tls = opportunistic_tls - - def send(self, mail: dict): - smtp = None - try: - if self.use_tls: - if self.opportunistic_tls: - smtp = smtplib.SMTP(self.server, self.port, timeout=10) - # Send EHLO command with the HELO domain name as the server address - smtp.ehlo(self.server) - smtp.starttls() - # Resend EHLO command to identify the TLS session - smtp.ehlo(self.server) - else: - smtp = smtplib.SMTP_SSL(self.server, self.port, timeout=10) - else: - smtp = smtplib.SMTP(self.server, self.port, timeout=10) - - # Only authenticate if both username and password are non-empty - if self.username and self.password and self.username.strip() and self.password.strip(): - smtp.login(self.username, self.password) - - msg = MIMEMultipart() - msg["Subject"] = mail["subject"] - msg["From"] = self._from - msg["To"] = mail["to"] - msg.attach(MIMEText(mail["html"], "html")) - - smtp.sendmail(self._from, mail["to"], msg.as_string()) - except smtplib.SMTPException: - logger.exception("SMTP error occurred") - raise - except TimeoutError: - logger.exception("Timeout occurred while sending email") - raise - except Exception: - logger.exception("Unexpected error occurred while sending email to %s", mail["to"]) - raise - finally: - if smtp: - smtp.quit() diff --git a/api/tests/unit_tests/libs/mail/test_oauth_email.py b/api/tests/unit_tests/libs/mail/test_oauth_email.py new file mode 100644 index 0000000000..b502d0a3de --- /dev/null +++ b/api/tests/unit_tests/libs/mail/test_oauth_email.py @@ -0,0 +1,375 @@ +"""Comprehensive tests for email OAuth implementation""" + +import base64 +from typing import Optional, Union + +import pytest + +from libs.mail.oauth_email import MicrosoftEmailOAuth, OAuthUserInfo +from libs.mail.oauth_http_client import OAuthHTTPClientProtocol + + +class MockHTTPClient(OAuthHTTPClientProtocol): + """Mock HTTP client for testing OAuth without real network calls""" + + def __init__(self): + self.post_responses = [] + self.get_responses = [] + self.post_calls = [] + self.get_calls = [] + self.post_index = 0 + self.get_index = 0 + + def add_post_response(self, status_code: int, json_data: dict[str, Union[str, int]]): + """Add a mocked POST response""" + self.post_responses.append( + { + "status_code": status_code, + "json": json_data, + "text": str(json_data), + "headers": {"content-type": "application/json"}, + } + ) + + def add_get_response(self, json_data: dict[str, Union[str, int, dict, list]]): + """Add a mocked GET response""" + self.get_responses.append(json_data) + + def post( + self, url: str, data: dict[str, Union[str, int]], headers: Optional[dict[str, str]] = None + ) -> dict[str, Union[str, int, dict, list]]: + """Mock POST request""" + self.post_calls.append({"url": url, "data": data, "headers": headers}) + + if self.post_index < len(self.post_responses): + response = self.post_responses[self.post_index] + self.post_index += 1 + return response + + # Default error response + return { + "status_code": 500, + "json": {"error": "No mock response configured"}, + "text": "No mock response configured", + "headers": {}, + } + + def get(self, url: str, headers: Optional[dict[str, str]] = None) -> dict[str, Union[str, int, dict, list]]: + """Mock GET request""" + self.get_calls.append({"url": url, "headers": headers}) + + if self.get_index < len(self.get_responses): + response = self.get_responses[self.get_index] + self.get_index += 1 + return response + + # Default error response + raise Exception("No mock response configured") + + +class TestMicrosoftEmailOAuth: + """Test cases for MicrosoftEmailOAuth""" + + @pytest.fixture + def mock_http_client(self): + """Create a mock HTTP client""" + return MockHTTPClient() + + @pytest.fixture + def oauth_client(self, mock_http_client): + """Create OAuth client with mock HTTP client""" + return MicrosoftEmailOAuth( + client_id="test-client-id", + client_secret="test-client-secret", + redirect_uri="https://example.com/callback", + tenant_id="test-tenant", + http_client=mock_http_client, + ) + + def test_get_authorization_url(self, oauth_client): + """Test authorization URL generation""" + url = oauth_client.get_authorization_url() + + assert "login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize" in url + assert "client_id=test-client-id" in url + assert "response_type=code" in url + assert "redirect_uri=https%3A%2F%2Fexample.com%2Fcallback" in url + assert "scope=https%3A%2F%2Foutlook.office.com%2FSMTP.Send+offline_access" in url + assert "response_mode=query" in url + + def test_get_authorization_url_with_state(self, oauth_client): + """Test authorization URL with state parameter""" + url = oauth_client.get_authorization_url(invite_token="test-state-123") + + assert "state=test-state-123" in url + + def test_get_access_token_success(self, oauth_client, mock_http_client): + """Test successful access token retrieval""" + # Setup mock response + mock_http_client.add_post_response( + 200, + { + "access_token": "test-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "test-refresh-token", + }, + ) + + result = oauth_client.get_access_token("test-auth-code") + + # Verify result + assert result["access_token"] == "test-access-token" + assert result["token_type"] == "Bearer" + assert result["expires_in"] == 3600 + assert result["refresh_token"] == "test-refresh-token" + + # Verify HTTP call + assert len(mock_http_client.post_calls) == 1 + call = mock_http_client.post_calls[0] + assert "login.microsoftonline.com/test-tenant/oauth2/v2.0/token" in call["url"] + assert call["data"]["grant_type"] == "authorization_code" + assert call["data"]["code"] == "test-auth-code" + assert call["data"]["client_id"] == "test-client-id" + assert call["data"]["client_secret"] == "test-client-secret" + + def test_get_access_token_failure(self, oauth_client, mock_http_client): + """Test access token retrieval failure""" + # Setup mock error response + mock_http_client.add_post_response( + 400, {"error": "invalid_grant", "error_description": "The authorization code is invalid"} + ) + + with pytest.raises(ValueError, match="Error in Microsoft OAuth"): + oauth_client.get_access_token("bad-auth-code") + + def test_get_access_token_client_credentials_success(self, oauth_client, mock_http_client): + """Test successful client credentials flow""" + # Setup mock response + mock_http_client.add_post_response( + 200, {"access_token": "service-access-token", "token_type": "Bearer", "expires_in": 3600} + ) + + result = oauth_client.get_access_token_client_credentials() + + # Verify result + assert result["access_token"] == "service-access-token" + assert result["token_type"] == "Bearer" + + # Verify HTTP call + call = mock_http_client.post_calls[0] + assert call["data"]["grant_type"] == "client_credentials" + assert call["data"]["scope"] == "https://outlook.office365.com/.default" + + def test_get_access_token_client_credentials_custom_scope(self, oauth_client, mock_http_client): + """Test client credentials with custom scope""" + mock_http_client.add_post_response(200, {"access_token": "custom-scope-token", "token_type": "Bearer"}) + + result = oauth_client.get_access_token_client_credentials(scope="https://graph.microsoft.com/.default") + + assert result["access_token"] == "custom-scope-token" + + # Verify custom scope was used + call = mock_http_client.post_calls[0] + assert call["data"]["scope"] == "https://graph.microsoft.com/.default" + + def test_refresh_access_token_success(self, oauth_client, mock_http_client): + """Test successful token refresh""" + # Setup mock response + mock_http_client.add_post_response( + 200, + { + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + result = oauth_client.refresh_access_token("old-refresh-token") + + # Verify result + assert result["access_token"] == "new-access-token" + assert result["refresh_token"] == "new-refresh-token" + + # Verify HTTP call + call = mock_http_client.post_calls[0] + assert call["data"]["grant_type"] == "refresh_token" + assert call["data"]["refresh_token"] == "old-refresh-token" + + def test_refresh_access_token_failure(self, oauth_client, mock_http_client): + """Test token refresh failure""" + # Setup mock error response + mock_http_client.add_post_response( + 400, {"error": "invalid_grant", "error_description": "The refresh token has expired"} + ) + + with pytest.raises(ValueError, match="Error refreshing Microsoft OAuth token"): + oauth_client.refresh_access_token("expired-refresh-token") + + def test_get_raw_user_info(self, oauth_client, mock_http_client): + """Test getting user info from Microsoft Graph""" + # Setup mock response + mock_http_client.add_get_response( + { + "id": "12345", + "displayName": "Test User", + "mail": "test@contoso.com", + "userPrincipalName": "test@contoso.com", + } + ) + + result = oauth_client.get_raw_user_info("test-access-token") + + # Verify result + assert result["id"] == "12345" + assert result["displayName"] == "Test User" + assert result["mail"] == "test@contoso.com" + + # Verify HTTP call + call = mock_http_client.get_calls[0] + assert call["url"] == "https://graph.microsoft.com/v1.0/me" + assert call["headers"]["Authorization"] == "Bearer test-access-token" + + def test_get_user_info_complete_flow(self, oauth_client, mock_http_client): + """Test complete user info retrieval flow""" + # Setup mock response + mock_http_client.add_get_response( + { + "id": "67890", + "displayName": "John Doe", + "mail": "john.doe@contoso.com", + "userPrincipalName": "john.doe@contoso.com", + } + ) + + user_info = oauth_client.get_user_info("test-access-token") + + # Verify transformed user info + assert isinstance(user_info, OAuthUserInfo) + assert user_info.id == "67890" + assert user_info.name == "John Doe" + assert user_info.email == "john.doe@contoso.com" + + def test_transform_user_info_with_missing_mail(self, oauth_client): + """Test user info transformation when mail field is missing""" + raw_info = {"id": "99999", "displayName": "No Mail User", "userPrincipalName": "nomail@contoso.com"} + + user_info = oauth_client._transform_user_info(raw_info) + + # Should fall back to userPrincipalName + assert user_info.email == "nomail@contoso.com" + + def test_transform_user_info_with_no_display_name(self, oauth_client): + """Test user info transformation when displayName is missing""" + raw_info = {"id": "11111", "mail": "anonymous@contoso.com", "userPrincipalName": "anonymous@contoso.com"} + + user_info = oauth_client._transform_user_info(raw_info) + + # Should have empty name + assert user_info.name == "" + assert user_info.email == "anonymous@contoso.com" + + def test_create_sasl_xoauth2_string(self): + """Test static SASL XOAUTH2 string creation""" + username = "test@contoso.com" + access_token = "test-token-456" + + result = MicrosoftEmailOAuth.create_sasl_xoauth2_string(username, access_token) + + # Decode and verify format + decoded = base64.b64decode(result).decode() + expected = f"user={username}\x01auth=Bearer {access_token}\x01\x01" + assert decoded == expected + + def test_error_handling_with_non_json_response(self, oauth_client, mock_http_client): + """Test handling of non-JSON error responses""" + # Setup mock HTML error response + mock_http_client.post_responses.append( + { + "status_code": 500, + "json": {}, + "text": "Internal Server Error", + "headers": {"content-type": "text/html"}, + } + ) + + with pytest.raises(ValueError, match="Error in Microsoft OAuth"): + oauth_client.get_access_token("test-code") + + +class TestOAuthIntegration: + """Integration tests for OAuth with SMTP""" + + def test_oauth_token_flow_for_smtp(self): + """Test complete OAuth token flow for SMTP usage""" + # Create mock HTTP client + mock_http = MockHTTPClient() + + # Setup mock responses for complete flow + mock_http.add_post_response( + 200, + { + "access_token": "smtp-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "smtp-refresh-token", + "scope": "https://outlook.office.com/SMTP.Send offline_access", + }, + ) + + # Create OAuth client + oauth_client = MicrosoftEmailOAuth( + client_id="smtp-client-id", + client_secret="smtp-client-secret", + redirect_uri="https://app.example.com/oauth/callback", + tenant_id="contoso.onmicrosoft.com", + http_client=mock_http, + ) + + # Get authorization URL + auth_url = oauth_client.get_authorization_url() + assert "scope=https%3A%2F%2Foutlook.office.com%2FSMTP.Send+offline_access" in auth_url + + # Exchange code for token + token_response = oauth_client.get_access_token("auth-code-from-user") + assert token_response["access_token"] == "smtp-access-token" + + # Create SASL string for SMTP + access_token = str(token_response["access_token"]) + sasl_string = MicrosoftEmailOAuth.create_sasl_xoauth2_string("user@contoso.com", access_token) + + # Verify SASL string is valid base64 + try: + decoded = base64.b64decode(sasl_string) + assert b"user=user@contoso.com" in decoded + assert b"auth=Bearer smtp-access-token" in decoded + except Exception: + pytest.fail("SASL string is not valid base64") + + def test_service_account_flow(self): + """Test service account (client credentials) flow""" + mock_http = MockHTTPClient() + + # Setup mock response for client credentials + mock_http.add_post_response( + 200, {"access_token": "service-smtp-token", "token_type": "Bearer", "expires_in": 3600} + ) + + oauth_client = MicrosoftEmailOAuth( + client_id="service-client-id", + client_secret="service-client-secret", + redirect_uri="", # Not needed for service accounts + tenant_id="contoso.onmicrosoft.com", + http_client=mock_http, + ) + + # Get token using client credentials + token_response = oauth_client.get_access_token_client_credentials() + + assert token_response["access_token"] == "service-smtp-token" + + # Verify the request used correct grant type + call = mock_http.post_calls[0] + assert call["data"]["grant_type"] == "client_credentials" + assert "redirect_uri" not in call["data"] # Should not include redirect_uri diff --git a/api/tests/unit_tests/libs/mail/test_smtp.py b/api/tests/unit_tests/libs/mail/test_smtp.py new file mode 100644 index 0000000000..0be13b0f3a --- /dev/null +++ b/api/tests/unit_tests/libs/mail/test_smtp.py @@ -0,0 +1,368 @@ +"""Comprehensive tests for SMTP implementation with OAuth 2.0 support""" + +import base64 +import smtplib +from unittest.mock import MagicMock, Mock + +import pytest + +from libs.mail.smtp import SMTPAuthenticator, SMTPClient, SMTPMessageBuilder +from libs.mail.smtp_connection import SMTPConnectionFactory, SMTPConnectionProtocol + + +class MockSMTPConnection: + """Mock SMTP connection for testing""" + + def __init__(self): + self.ehlo_called = 0 + self.starttls_called = False + self.login_called = False + self.docmd_called = False + self.sendmail_called = False + self.quit_called = False + self.last_docmd_args = None + self.last_login_args = None + self.last_sendmail_args = None + + def ehlo(self, name: str = "") -> tuple: + self.ehlo_called += 1 + return (250, b"OK") + + def starttls(self) -> tuple: + self.starttls_called = True + return (220, b"TLS started") + + def login(self, user: str, password: str) -> tuple: + self.login_called = True + self.last_login_args = (user, password) + return (235, b"Authentication successful") + + def docmd(self, cmd: str, args: str = "") -> tuple: + self.docmd_called = True + self.last_docmd_args = (cmd, args) + return (235, b"Authentication successful") + + def sendmail(self, from_addr: str, to_addrs: str, msg: str) -> dict: + self.sendmail_called = True + self.last_sendmail_args = (from_addr, to_addrs, msg) + return {} + + def quit(self) -> tuple: + self.quit_called = True + return (221, b"Bye") + + +class MockSMTPConnectionFactory(SMTPConnectionFactory): + """Mock factory for creating mock SMTP connections""" + + def __init__(self, connection: MockSMTPConnection): + self.connection = connection + self.create_called = False + + def create_connection(self, server: str, port: int, timeout: int = 10) -> SMTPConnectionProtocol: + self.create_called = True + self.last_create_args = (server, port, timeout) + return self.connection + + +class TestSMTPAuthenticator: + """Test cases for SMTPAuthenticator""" + + def test_create_sasl_xoauth2_string(self): + """Test SASL XOAUTH2 string creation""" + authenticator = SMTPAuthenticator() + username = "test@example.com" + access_token = "test_token_123" + + result = authenticator.create_sasl_xoauth2_string(username, access_token) + + # Decode and verify + decoded = base64.b64decode(result).decode() + expected = f"user={username}\x01auth=Bearer {access_token}\x01\x01" + assert decoded == expected + + def test_authenticate_basic_with_valid_credentials(self): + """Test basic authentication with valid credentials""" + authenticator = SMTPAuthenticator() + connection = MockSMTPConnection() + + authenticator.authenticate_basic(connection, "user@example.com", "password123") + + assert connection.login_called + assert connection.last_login_args == ("user@example.com", "password123") + + def test_authenticate_basic_with_empty_credentials(self): + """Test basic authentication skips with empty credentials""" + authenticator = SMTPAuthenticator() + connection = MockSMTPConnection() + + authenticator.authenticate_basic(connection, "", "") + + assert not connection.login_called + + def test_authenticate_oauth2_success(self): + """Test successful OAuth2 authentication""" + authenticator = SMTPAuthenticator() + connection = MockSMTPConnection() + + authenticator.authenticate_oauth2(connection, "user@example.com", "oauth_token_123") + + assert connection.docmd_called + assert connection.last_docmd_args[0] == "AUTH" + assert connection.last_docmd_args[1].startswith("XOAUTH2 ") + + # Verify the auth string + auth_string = connection.last_docmd_args[1].split(" ")[1] + decoded = base64.b64decode(auth_string).decode() + assert "user=user@example.com" in decoded + assert "auth=Bearer oauth_token_123" in decoded + + def test_authenticate_oauth2_missing_credentials(self): + """Test OAuth2 authentication fails with missing credentials""" + authenticator = SMTPAuthenticator() + connection = MockSMTPConnection() + + with pytest.raises(ValueError, match="Username and OAuth access token are required"): + authenticator.authenticate_oauth2(connection, "", "token") + + with pytest.raises(ValueError, match="Username and OAuth access token are required"): + authenticator.authenticate_oauth2(connection, "user", "") + + def test_authenticate_oauth2_auth_failure(self): + """Test OAuth2 authentication handles auth errors""" + authenticator = SMTPAuthenticator() + connection = Mock() + connection.docmd.side_effect = smtplib.SMTPAuthenticationError(535, b"Authentication failed") + + with pytest.raises(ValueError, match="OAuth2 authentication failed"): + authenticator.authenticate_oauth2(connection, "user@example.com", "bad_token") + + +class TestSMTPMessageBuilder: + """Test cases for SMTPMessageBuilder""" + + def test_build_message(self): + """Test message building""" + builder = SMTPMessageBuilder() + mail_data = {"to": "recipient@example.com", "subject": "Test Subject", "html": "

Test HTML content

"} + from_addr = "sender@example.com" + + msg = builder.build_message(mail_data, from_addr) + + assert msg["To"] == "recipient@example.com" + assert msg["From"] == "sender@example.com" + assert msg["Subject"] == "Test Subject" + assert "

Test HTML content

" in msg.as_string() + + +class TestSMTPClient: + """Test cases for SMTPClient""" + + @pytest.fixture + def mock_connection(self): + """Create a mock SMTP connection""" + return MockSMTPConnection() + + @pytest.fixture + def mock_factories(self, mock_connection): + """Create mock connection factories""" + return { + "connection_factory": MockSMTPConnectionFactory(mock_connection), + "ssl_connection_factory": MockSMTPConnectionFactory(mock_connection), + } + + def test_basic_auth_send_success(self, mock_connection, mock_factories): + """Test successful email send with basic auth""" + client = SMTPClient( + server="smtp.example.com", + port=587, + username="user@example.com", + password="password123", + from_addr="sender@example.com", + use_tls=True, + opportunistic_tls=True, + auth_type="basic", + **mock_factories, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test Subject", "html": "

Test content

"} + + client.send(mail_data) + + # Verify connection sequence + assert mock_connection.ehlo_called == 2 # Before and after STARTTLS + assert mock_connection.starttls_called + assert mock_connection.login_called + assert mock_connection.last_login_args == ("user@example.com", "password123") + assert mock_connection.sendmail_called + assert mock_connection.quit_called + + def test_oauth2_send_success(self, mock_connection, mock_factories): + """Test successful email send with OAuth2""" + client = SMTPClient( + server="smtp.office365.com", + port=587, + username="user@contoso.com", + password="", + from_addr="sender@contoso.com", + use_tls=True, + opportunistic_tls=True, + oauth_access_token="oauth_token_123", + auth_type="oauth2", + **mock_factories, + ) + + mail_data = {"to": "recipient@example.com", "subject": "OAuth Test", "html": "

OAuth test content

"} + + client.send(mail_data) + + # Verify OAuth authentication was used + assert mock_connection.docmd_called + assert not mock_connection.login_called + assert mock_connection.sendmail_called + assert mock_connection.quit_called + + def test_ssl_connection_used_when_configured(self, mock_connection): + """Test SSL connection is used when configured""" + ssl_factory = MockSMTPConnectionFactory(mock_connection) + regular_factory = MockSMTPConnectionFactory(mock_connection) + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + from_addr="sender@example.com", + use_tls=True, + opportunistic_tls=False, # Use SSL, not STARTTLS + connection_factory=regular_factory, + ssl_connection_factory=ssl_factory, + ) + + mail_data = {"to": "recipient@example.com", "subject": "SSL Test", "html": "

SSL test content

"} + + client.send(mail_data) + + # Verify SSL factory was used + assert ssl_factory.create_called + assert not regular_factory.create_called + # No STARTTLS with SSL connection + assert not mock_connection.starttls_called + + def test_connection_cleanup_on_error(self, mock_connection, mock_factories): + """Test connection is cleaned up even on error""" + # Make sendmail fail + mock_connection.sendmail = Mock(side_effect=smtplib.SMTPException("Send failed")) + + client = SMTPClient( + server="smtp.example.com", + port=587, + username="user@example.com", + password="password123", + from_addr="sender@example.com", + **mock_factories, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Test

"} + + with pytest.raises(smtplib.SMTPException): + client.send(mail_data) + + # Verify quit was still called + assert mock_connection.quit_called + + def test_custom_authenticator_injection(self, mock_connection, mock_factories): + """Test custom authenticator can be injected""" + custom_authenticator = Mock(spec=SMTPAuthenticator) + + client = SMTPClient( + server="smtp.example.com", + port=587, + username="user@example.com", + password="password123", + from_addr="sender@example.com", + authenticator=custom_authenticator, + **mock_factories, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Test

"} + + client.send(mail_data) + + # Verify custom authenticator was used + custom_authenticator.authenticate_basic.assert_called_once() + + def test_custom_message_builder_injection(self, mock_connection, mock_factories): + """Test custom message builder can be injected""" + custom_builder = Mock(spec=SMTPMessageBuilder) + custom_msg = MagicMock() + custom_msg.as_string.return_value = "custom message" + custom_builder.build_message.return_value = custom_msg + + client = SMTPClient( + server="smtp.example.com", + port=587, + username="user@example.com", + password="password123", + from_addr="sender@example.com", + message_builder=custom_builder, + **mock_factories, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Test

"} + + client.send(mail_data) + + # Verify custom builder was used + custom_builder.build_message.assert_called_once_with(mail_data, "sender@example.com") + assert mock_connection.last_sendmail_args[2] == "custom message" + + +class TestIntegration: + """Integration tests showing how components work together""" + + def test_complete_oauth_flow_without_io(self): + """Test complete OAuth flow without any real I/O""" + # Create all mocks + mock_connection = MockSMTPConnection() + connection_factory = MockSMTPConnectionFactory(mock_connection) + + # Create client with OAuth + client = SMTPClient( + server="smtp.office365.com", + port=587, + username="test@contoso.com", + password="", + from_addr="test@contoso.com", + use_tls=True, + opportunistic_tls=True, + oauth_access_token="mock_oauth_token", + auth_type="oauth2", + connection_factory=connection_factory, + ssl_connection_factory=connection_factory, + ) + + # Send email + mail_data = { + "to": "recipient@example.com", + "subject": "OAuth Integration Test", + "html": "

Hello OAuth!

", + } + + client.send(mail_data) + + # Verify complete flow + assert connection_factory.create_called + assert mock_connection.ehlo_called == 2 + assert mock_connection.starttls_called + assert mock_connection.docmd_called + assert "XOAUTH2" in mock_connection.last_docmd_args[1] + assert mock_connection.sendmail_called + assert mock_connection.quit_called + + # Verify email data + from_addr, to_addr, msg_str = mock_connection.last_sendmail_args + assert from_addr == "test@contoso.com" + assert to_addr == "recipient@example.com" + assert "OAuth Integration Test" in msg_str + assert "Hello OAuth!" in msg_str diff --git a/docker/.env.example b/docker/.env.example index d4e8ab3beb..523f3ff3be 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -834,6 +834,33 @@ SMTP_PASSWORD= SMTP_USE_TLS=true SMTP_OPPORTUNISTIC_TLS=false +# SMTP authentication type: 'basic' for username/password, 'oauth2' for Microsoft OAuth 2.0 +# Use 'oauth2' for Microsoft Exchange/Outlook due to Basic Auth retirement (September 2025) +SMTP_AUTH_TYPE=basic + +# Microsoft OAuth 2.0 configuration for SMTP authentication +# Required when SMTP_AUTH_TYPE=oauth2 and SMTP_SERVER uses Microsoft Exchange/Outlook +# +# Setup instructions: +# 1. Go to Azure Portal (https://portal.azure.com) → Azure Active Directory → App registrations +# 2. Create new application registration +# 3. Add API permissions: Mail.Send, SMTP.Send (Application permissions) +# 4. Grant admin consent for the permissions +# 5. Create client secret in "Certificates & secrets" +# 6. Use the application's Client ID, Client Secret, and your Tenant ID below +# +# For Microsoft Exchange Online, use: +# SMTP_SERVER=smtp.office365.com +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_OPPORTUNISTIC_TLS=true +MICROSOFT_OAUTH2_CLIENT_ID= +MICROSOFT_OAUTH2_CLIENT_SECRET= +# Tenant ID from Azure AD (use 'common' for multi-tenant applications) +MICROSOFT_OAUTH2_TENANT_ID=common +# Optional: Pre-acquired access token (leave empty to auto-acquire using client credentials) +MICROSOFT_OAUTH2_ACCESS_TOKEN= + # Sendgid configuration SENDGRID_API_KEY=