Compare commits

...

14 Commits

Author SHA1 Message Date
-LAN-
9dadecdd42 Merge branch 'main' into feature/smtp-oauth2-support 2025-10-06 02:26:00 +08:00
-LAN-
ee04f0d250 Merge branch 'main' into feature/smtp-oauth2-support 2025-09-29 18:18:48 +08:00
-LAN-
16e9ea44a9 Merge branch 'main' into feature/smtp-oauth2-support 2025-09-28 17:51:19 +08:00
-LAN-
4a8ac18879 Merge branch 'main' into feature/smtp-oauth2-support 2025-09-26 17:35:12 +08:00
-LAN-
1fc4844beb Merge branch 'main' into feature/smtp-oauth2-support 2025-09-24 03:53:37 +08:00
-LAN-
bccd18b838 test(smtp): fix patch path 2025-09-24 03:29:56 +08:00
autofix-ci[bot]
f486d1bcee [autofix.ci] apply automated fixes 2025-09-23 06:31:26 +00:00
-LAN-
2c2069f77c Merge branch 'main' into feature/smtp-oauth2-support 2025-09-23 14:29:26 +08:00
autofix-ci[bot]
cf222ecfed [autofix.ci] apply automated fixes (attempt 3/3) 2025-09-22 17:31:59 +00:00
autofix-ci[bot]
2c343e98cc [autofix.ci] apply automated fixes (attempt 2/3) 2025-09-23 01:30:06 +08:00
autofix-ci[bot]
2d4d4b6b8a [autofix.ci] apply automated fixes 2025-09-23 01:30:06 +08:00
-LAN-
25ae492247 refactor: replace f-strings with % formatting in logging statements
- Replace f-strings in logging.exception() and logging.warning() calls
- Use % formatting for better performance when log level is disabled
- Follows Python logging best practices
2025-09-23 01:30:06 +08:00
-LAN-
16d30fbd60 chore(docker-compose): Update docker-compose.yaml
Signed-off-by: -LAN- <laipz8200@outlook.com>
2025-09-23 01:30:06 +08:00
-LAN-
69f712b713 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.
2025-09-23 01:30:06 +08:00
15 changed files with 1640 additions and 73 deletions

View File

@@ -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

View File

@@ -821,6 +821,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: str | None = Field(
description="Microsoft OAuth 2.0 client ID for SMTP authentication",
default=None,
)
MICROSOFT_OAUTH2_CLIENT_SECRET: str | None = 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: str | None = 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,

View File

@@ -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
@@ -67,6 +83,33 @@ class Mail:
case _:
raise ValueError(f"Unsupported mail type {mail_type}")
def _get_oauth_token(self) -> str | None:
"""Get OAuth access token using client credentials flow"""
try:
from libs.mail.oauth_email import MicrosoftEmailOAuth
client_id = dify_config.MICROSOFT_OAUTH2_CLIENT_ID
client_secret = dify_config.MICROSOFT_OAUTH2_CLIENT_SECRET
tenant_id = dify_config.MICROSOFT_OAUTH2_TENANT_ID or "common"
if not client_id or not client_secret:
return None
oauth_client = MicrosoftEmailOAuth(
client_id=client_id,
client_secret=client_secret,
redirect_uri="", # Not needed for client credentials flow
tenant_id=tenant_id,
)
token_response = oauth_client.get_access_token_client_credentials()
access_token = token_response.get("access_token")
return str(access_token) if access_token is not None else None
except Exception as e:
logging.warning("Failed to obtain OAuth 2.0 access token: %s", str(e))
return None
def send(self, to: str, subject: str, html: str, from_: str | None = None):
if not self._client:
raise ValueError("Mail client is not initialized")

281
api/libs/mail/README.md Normal file
View File

@@ -0,0 +1,281 @@
# 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
1. Click "New registration"
1. Enter application name (e.g., "Dify Email Service")
1. Select "Accounts in this organizational directory only"
1. Click "Register"
### 2. Configure API Permissions
1. Go to "API permissions"
1. Click "Add a permission" → Microsoft Graph
1. Select "Application permissions"
1. Add these permissions:
- `Mail.Send` - Send mail as any user
- `SMTP.Send` - Send email via SMTP AUTH
1. Click "Grant admin consent"
### 3. Create Client Secret
1. Go to "Certificates & secrets"
1. Click "New client secret"
1. Enter description and expiration
1. 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": "<h1>Hello World</h1>"
}
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": "<p>Sent via OAuth 2.0</p>"
}
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)
1. **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
```
1. **Test the configuration** before the migration deadline
1. **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
1. **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
1. **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
```

26
api/libs/mail/__init__.py Normal file
View File

@@ -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",
]

View File

@@ -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 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: OAuthHTTPClientProtocol | None = 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: OAuthHTTPClientProtocol | None = None,
):
super().__init__(client_id, client_secret, redirect_uri, http_client)
self.tenant_id = tenant_id
def get_authorization_url(self, invite_token: str | None = 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()

View File

@@ -0,0 +1,45 @@
"""HTTP client abstraction for OAuth requests"""
from abc import ABC, abstractmethod
from typing import 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: dict[str, str] | None = None
) -> dict[str, Union[str, int, dict, list]]:
"""Make a POST request"""
pass
@abstractmethod
def get(self, url: str, headers: dict[str, str] | None = 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: dict[str, str] | None = 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: dict[str, str] | None = 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)

163
api/libs/mail/smtp.py Normal file
View File

@@ -0,0 +1,163 @@
"""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 .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("OAuth2 authentication failed for user %s", username)
raise ValueError(f"OAuth2 authentication failed: {str(e)}")
except Exception:
logging.exception("Unexpected error during OAuth2 authentication for user %s", 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: str | None = None,
auth_type: str = "basic",
connection_factory: SMTPConnectionFactory | None = None,
ssl_connection_factory: SMTPConnectionFactory | None = None,
authenticator: SMTPAuthenticator | None = None,
message_builder: SMTPMessageBuilder | None = 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("Unexpected error occurred while sending email to %s", mail["to"])
raise
finally:
if connection:
try:
connection.quit()
except Exception:
# Ignore errors during cleanup
pass

View File

@@ -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)

View File

@@ -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()

View File

@@ -0,0 +1,375 @@
"""Comprehensive tests for email OAuth implementation"""
import base64
from typing import 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: dict[str, str] | None = 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: dict[str, str] | None = 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": "<html>Internal Server Error</html>",
"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

View File

@@ -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": "<p>Test HTML content</p>"}
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 "<p>Test HTML content</p>" 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": "<p>Test content</p>"}
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": "<p>OAuth test content</p>"}
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": "<p>SSL test content</p>"}
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": "<p>Test</p>"}
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": "<p>Test</p>"}
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": "<p>Test</p>"}
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": "<h1>Hello OAuth!</h1>",
}
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

View File

@@ -2,19 +2,19 @@ from unittest.mock import MagicMock, patch
import pytest
from libs.smtp import SMTPClient
from libs.mail import SMTPClient
def _mail() -> dict:
return {"to": "user@example.com", "subject": "Hi", "html": "<b>Hi</b>"}
@patch("libs.smtp.smtplib.SMTP")
@patch("libs.mail.smtp_connection.smtplib.SMTP")
def test_smtp_plain_success(mock_smtp_cls: MagicMock):
mock_smtp = MagicMock()
mock_smtp_cls.return_value = mock_smtp
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", _from="noreply@example.com")
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", from_addr="noreply@example.com")
client.send(_mail())
mock_smtp_cls.assert_called_once_with("smtp.example.com", 25, timeout=10)
@@ -22,7 +22,7 @@ def test_smtp_plain_success(mock_smtp_cls: MagicMock):
mock_smtp.quit.assert_called_once()
@patch("libs.smtp.smtplib.SMTP")
@patch("libs.mail.smtp_connection.smtplib.SMTP")
def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock):
mock_smtp = MagicMock()
mock_smtp_cls.return_value = mock_smtp
@@ -32,7 +32,7 @@ def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock):
port=587,
username="user",
password="pass",
_from="noreply@example.com",
from_addr="noreply@example.com",
use_tls=True,
opportunistic_tls=True,
)
@@ -46,7 +46,7 @@ def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock):
mock_smtp.quit.assert_called_once()
@patch("libs.smtp.smtplib.SMTP_SSL")
@patch("libs.mail.smtp_connection.smtplib.SMTP_SSL")
def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock):
# Cover SMTP_SSL branch and TimeoutError handling
mock_smtp = MagicMock()
@@ -58,7 +58,7 @@ def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock):
port=465,
username="",
password="",
_from="noreply@example.com",
from_addr="noreply@example.com",
use_tls=True,
opportunistic_tls=False,
)
@@ -67,19 +67,19 @@ def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock):
mock_smtp.quit.assert_called_once()
@patch("libs.smtp.smtplib.SMTP")
@patch("libs.mail.smtp_connection.smtplib.SMTP")
def test_smtp_generic_exception_propagates(mock_smtp_cls: MagicMock):
mock_smtp = MagicMock()
mock_smtp.sendmail.side_effect = RuntimeError("oops")
mock_smtp_cls.return_value = mock_smtp
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", _from="noreply@example.com")
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", from_addr="noreply@example.com")
with pytest.raises(RuntimeError):
client.send(_mail())
mock_smtp.quit.assert_called_once()
@patch("libs.smtp.smtplib.SMTP")
@patch("libs.mail.smtp_connection.smtplib.SMTP")
def test_smtp_smtplib_exception_in_login(mock_smtp_cls: MagicMock):
# Ensure we hit the specific SMTPException except branch
import smtplib
@@ -93,7 +93,7 @@ def test_smtp_smtplib_exception_in_login(mock_smtp_cls: MagicMock):
port=25,
username="user", # non-empty to trigger login
password="pass",
_from="noreply@example.com",
from_addr="noreply@example.com",
)
with pytest.raises(smtplib.SMTPException):
client.send(_mail())

View File

@@ -836,6 +836,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=

View File

@@ -373,6 +373,11 @@ x-shared-env: &shared-api-worker-env
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false}
SMTP_AUTH_TYPE: ${SMTP_AUTH_TYPE:-basic}
MICROSOFT_OAUTH2_CLIENT_ID: ${MICROSOFT_OAUTH2_CLIENT_ID:-}
MICROSOFT_OAUTH2_CLIENT_SECRET: ${MICROSOFT_OAUTH2_CLIENT_SECRET:-}
MICROSOFT_OAUTH2_TENANT_ID: ${MICROSOFT_OAUTH2_TENANT_ID:-common}
MICROSOFT_OAUTH2_ACCESS_TOKEN: ${MICROSOFT_OAUTH2_ACCESS_TOKEN:-}
SENDGRID_API_KEY: ${SENDGRID_API_KEY:-}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}