mirror of
https://github.com/langgenius/dify.git
synced 2025-12-20 14:42:37 +00:00
Compare commits
14 Commits
refactor/u
...
feature/sm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dadecdd42 | ||
|
|
ee04f0d250 | ||
|
|
16e9ea44a9 | ||
|
|
4a8ac18879 | ||
|
|
1fc4844beb | ||
|
|
bccd18b838 | ||
|
|
f486d1bcee | ||
|
|
2c2069f77c | ||
|
|
cf222ecfed | ||
|
|
2c343e98cc | ||
|
|
2d4d4b6b8a | ||
|
|
25ae492247 | ||
|
|
16d30fbd60 | ||
|
|
69f712b713 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
281
api/libs/mail/README.md
Normal 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
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 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()
|
||||
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 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
163
api/libs/mail/smtp.py
Normal 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
|
||||
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()
|
||||
375
api/tests/unit_tests/libs/mail/test_oauth_email.py
Normal file
375
api/tests/unit_tests/libs/mail/test_oauth_email.py
Normal 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
|
||||
368
api/tests/unit_tests/libs/mail/test_smtp.py
Normal file
368
api/tests/unit_tests/libs/mail/test_smtp.py
Normal 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
|
||||
@@ -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())
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user