mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 16:38:04 +08:00
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.
This commit is contained in:
276
api/libs/mail/README.md
Normal file
276
api/libs/mail/README.md
Normal file
@ -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": "<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)
|
||||
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
|
||||
```
|
||||
26
api/libs/mail/__init__.py
Normal file
26
api/libs/mail/__init__.py
Normal 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",
|
||||
]
|
||||
175
api/libs/mail/oauth_email.py
Normal file
175
api/libs/mail/oauth_email.py
Normal 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 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()
|
||||
45
api/libs/mail/oauth_http_client.py
Normal file
45
api/libs/mail/oauth_http_client.py
Normal file
@ -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)
|
||||
164
api/libs/mail/smtp.py
Normal file
164
api/libs/mail/smtp.py
Normal file
@ -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
|
||||
79
api/libs/mail/smtp_connection.py
Normal file
79
api/libs/mail/smtp_connection.py
Normal 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)
|
||||
@ -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()
|
||||
Reference in New Issue
Block a user