feat(mail): Support oauth for stmp mail server.

Signed-off-by: -LAN- <laipz8200@outlook.com>
pull/21218/head
-LAN- 11 months ago
parent 2020a31785
commit 573adb1deb
No known key found for this signature in database
GPG Key ID: 6BA0D108DED011FF

@ -353,6 +353,20 @@ SMTP_USERNAME=123
SMTP_PASSWORD=abc SMTP_PASSWORD=abc
SMTP_USE_TLS=true SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=false SMTP_OPPORTUNISTIC_TLS=false
# SMTP authentication type: 'basic' for traditional username/password authentication,
# 'oauth2' for modern OAuth2 authentication with services like Microsoft 365/Outlook
SMTP_AUTH_TYPE=basic
# OAuth2 configuration for SMTP (required when SMTP_AUTH_TYPE=oauth2)
# Client ID from your registered OAuth2 application in the provider's developer portal
SMTP_CLIENT_ID=
# Client secret from your registered OAuth2 application in the provider's developer portal
SMTP_CLIENT_SECRET=
# For Microsoft OAuth2 (Office 365/Outlook)
# Tenant ID (Directory ID) from your Azure AD/Microsoft 365 account
SMTP_TENANT_ID=
# OAuth2 provider name - currently only 'microsoft' is supported
# This identifies which OAuth2 implementation to use for authentication
SMTP_OAUTH2_PROVIDER=microsoft
# Sendgid configuration # Sendgid configuration
SENDGRID_API_KEY= SENDGRID_API_KEY=
# Sentry configuration # Sentry configuration

@ -658,6 +658,31 @@ class MailConfig(BaseSettings):
default=False, default=False,
) )
SMTP_AUTH_TYPE: str = Field(
description="SMTP authentication type ('basic' or 'oauth2')",
default="basic",
)
SMTP_CLIENT_ID: Optional[str] = Field(
description="OAuth2 client ID for SMTP authentication",
default=None,
)
SMTP_CLIENT_SECRET: Optional[str] = Field(
description="OAuth2 client secret for SMTP authentication",
default=None,
)
SMTP_TENANT_ID: Optional[str] = Field(
description="OAuth2 tenant ID for Microsoft SMTP authentication",
default=None,
)
SMTP_OAUTH2_PROVIDER: str = Field(
description="OAuth2 provider for SMTP authentication (currently only 'microsoft' is supported)",
default="microsoft",
)
EMAIL_SEND_IP_LIMIT_PER_MINUTE: PositiveInt = Field( 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", description="Maximum number of emails allowed to be sent from the same IP address in a minute",
default=50, default=50,

@ -5,94 +5,54 @@ from flask import Flask
from configs import dify_config from configs import dify_config
from dify_app import DifyApp from dify_app import DifyApp
from libs.mail import MailConfigError, MailMessage, MailSender, MailSenderFactory
class Mail: class Mail:
def __init__(self): def __init__(self):
self._client = None self._sender: Optional[MailSender] = None
self._default_send_from = None
def is_inited(self) -> bool: def is_inited(self) -> bool:
return self._client is not None return self._sender is not None
def init_app(self, app: Flask): def init_app(self, app: Flask) -> None:
mail_type = dify_config.MAIL_TYPE """Initialize mail sender using the new factory pattern."""
if not mail_type: try:
logging.warning("MAIL_TYPE is not set") self._sender = MailSenderFactory.create_from_dify_config(dify_config)
return if self._sender:
logging.info("Mail sender initialized successfully")
if dify_config.MAIL_DEFAULT_SEND_FROM: else:
self._default_send_from = dify_config.MAIL_DEFAULT_SEND_FROM logging.warning("MAIL_TYPE is not set, mail functionality disabled")
except MailConfigError as e:
match mail_type: logging.exception("Failed to initialize mail sender")
case "resend": raise ValueError(f"Mail configuration error: {e}")
import resend except Exception as e:
logging.exception("Unexpected error initializing mail sender")
api_key = dify_config.RESEND_API_KEY raise ValueError(f"Failed to initialize mail sender: {e}")
if not api_key:
raise ValueError("RESEND_API_KEY is not set")
api_url = dify_config.RESEND_API_URL
if api_url:
resend.api_url = api_url
resend.api_key = api_key
self._client = resend.Emails
case "smtp":
from libs.smtp 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")
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 "",
use_tls=dify_config.SMTP_USE_TLS,
opportunistic_tls=dify_config.SMTP_OPPORTUNISTIC_TLS,
)
case "sendgrid":
from libs.sendgrid import SendGridClient
if not dify_config.SENDGRID_API_KEY:
raise ValueError("SENDGRID_API_KEY is required for SendGrid mail type")
self._client = SendGridClient(
sendgrid_api_key=dify_config.SENDGRID_API_KEY, _from=dify_config.MAIL_DEFAULT_SEND_FROM or ""
)
case _:
raise ValueError("Unsupported mail type {}".format(mail_type))
def send(self, to: str, subject: str, html: str, from_: Optional[str] = None): def send(self, to: str, subject: str, html: str, from_: Optional[str] = None):
if not self._client: """
raise ValueError("Mail client is not initialized") Send an email using the configured mail sender.
if not from_ and self._default_send_from: Args:
from_ = self._default_send_from to: Recipient email address
subject: Email subject
if not from_: html: Email HTML content
raise ValueError("mail from is not set") from_: Sender email address (optional, uses default if not provided)
"""
if not to: if not self._sender:
raise ValueError("mail to is not set") raise ValueError("Mail sender is not initialized")
if not subject: try:
raise ValueError("mail subject is not set") # Create mail message
message = MailMessage(to=to, subject=subject, html=html, from_=from_)
if not html:
raise ValueError("mail html is not set") # Send the message
self._sender.send(message)
self._client.send(
{ except Exception as e:
"from": from_, logging.exception(f"Failed to send email to {to}")
"to": to, raise
"subject": subject,
"html": html,
}
)
def is_enabled() -> bool: def is_enabled() -> bool:

@ -0,0 +1,324 @@
# Dify Mail Sending Architecture
## Overview
To address Microsoft's upcoming discontinuation of Basic Auth support, we have redesigned Dify's mail sending system using a Protocol-based architecture that supports multiple authentication methods and email service providers.
## Design Principles
1. **Protocol-Driven**: Uses Python Protocol to define unified mail sending interfaces
2. **Authentication Abstraction**: Hides Basic Auth and OAuth2 authentication details in implementation layers
3. **Extensibility**: Easy to add new email service providers and authentication methods
4. **Backward Compatibility**: Maintains compatibility with existing configuration methods
5. **User Experience**: Transparent to users with minimal configuration changes
## Architecture Components
### 1. Core Protocol (`protocol.py`)
```python
@runtime_checkable
class MailSender(Protocol):
def send(self, message: MailMessage) -> None: ...
def is_configured(self) -> bool: ...
def test_connection(self) -> bool: ...
@dataclass
class MailMessage:
to: str
subject: str
html: str
from_: Optional[str] = None
# ... other fields
```
### 2. SMTP Implementations
#### Basic Auth (`smtp_basic.py`)
- Traditional username/password authentication
- Supports TLS and STARTTLS
- Backward compatible with existing configurations
#### OAuth2 (`smtp_oauth2.py`)
- Supports Microsoft OAuth2 (Client Credentials Flow)
- Automatic token management and refresh
- Enterprise-ready security
#### Unified Interface (`smtp_sender.py`)
- Automatically selects authentication method based on configuration
- Provides unified SMTP sending interface
### 3. Third-Party Service Adapters
- **Resend** (`resend_sender.py`)
- **SendGrid** (`sendgrid_sender.py`)
### 4. Factory Pattern (`factory.py`)
```python
# Automatically create mail sender from Dify configuration
sender = MailSenderFactory.create_from_dify_config(dify_config)
# Manually create specific type of sender
sender = MailSenderFactory.create_sender("smtp", config)
```
## Configuration
### SMTP Basic Auth (Existing Method)
```bash
MAIL_TYPE=smtp
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=user@gmail.com
SMTP_PASSWORD=app-password
SMTP_USE_TLS=true
SMTP_AUTH_TYPE=basic
```
### SMTP OAuth2 (New)
#### Microsoft OAuth2
```bash
MAIL_TYPE=smtp
SMTP_SERVER=smtp.office365.com
SMTP_PORT=587
SMTP_USERNAME=user@company.com
SMTP_USE_TLS=true
SMTP_AUTH_TYPE=oauth2
SMTP_OAUTH2_PROVIDER=microsoft
SMTP_CLIENT_ID=your-azure-app-client-id
SMTP_CLIENT_SECRET=your-azure-app-client-secret
SMTP_TENANT_ID=your-azure-tenant-id
```
## Microsoft OAuth2 Setup Guide
### 1. Azure AD App Registration
1. Sign in to [Azure Portal](https://portal.azure.com)
2. Navigate to "Azure Active Directory" > "App registrations"
3. Click "New registration"
4. Fill in application information:
- Name: "Dify Mail Service"
- Supported account types: "Accounts in this organizational directory only"
- Redirect URI: Not needed (server application)
### 2. Configure API Permissions
1. In the app page, click "API permissions"
2. Click "Add a permission"
3. Select "Microsoft Graph"
4. Select "Application permissions"
5. Add the following permissions:
- `Mail.Send` - Send mail
- `Mail.ReadWrite` - Read and write mail (if needed)
### 3. Create Client Secret
1. Click "Certificates & secrets"
2. Click "New client secret"
3. Set description and expiration time
4. Copy the generated secret value (shown only once)
### 4. Get Tenant ID
1. Find "Tenant ID" in the Azure AD overview page
2. Copy this ID for configuration
## Usage Examples
### Basic Usage
```python
from libs.mail import MailMessage, MailSenderFactory
# Create mail message
message = MailMessage(
to="recipient@example.com",
subject="Test Email",
html="<h1>Hello from Dify!</h1>",
from_="sender@company.com"
)
# Create sender from configuration
sender = MailSenderFactory.create_from_dify_config(dify_config)
# Send email
if sender and sender.is_configured():
sender.send(message)
```
### SMTP Basic Auth Example
```python
# Configuration for SMTP Basic Auth
config = {
"server": "smtp.gmail.com",
"port": 587,
"username": "your-email@gmail.com",
"password": "your-app-password",
"use_tls": True,
"auth_type": "basic",
"default_from": "your-email@gmail.com"
}
# Create sender
sender = MailSenderFactory.create_sender("smtp", config)
# Create and send message
message = MailMessage(
to="recipient@example.com",
subject="Test Email - Basic Auth",
html="<h1>Hello from Dify!</h1><p>This email was sent using SMTP Basic Auth.</p>",
from_="your-email@gmail.com"
)
sender.send(message)
```
### Microsoft OAuth2 Example
```python
# Configuration for Microsoft OAuth2
config = {
"server": "smtp.office365.com",
"port": 587,
"username": "your-email@company.com",
"use_tls": True,
"auth_type": "oauth2",
"oauth2_provider": "microsoft",
"client_id": "your-azure-app-client-id",
"client_secret": "your-azure-app-client-secret",
"tenant_id": "your-azure-tenant-id",
"default_from": "your-email@company.com"
}
# Create sender
sender = MailSenderFactory.create_sender("smtp", config)
# Create and send message
message = MailMessage(
to="recipient@company.com",
subject="Test Email - OAuth2",
html="<h1>Hello from Dify!</h1><p>This email was sent using Microsoft OAuth2.</p>",
from_="sender@company.com"
)
sender.send(message)
```
### Third-Party Services
#### Resend Service
```python
config = {
"api_key": "your-resend-api-key",
"default_from": "noreply@yourdomain.com"
}
sender = MailSenderFactory.create_sender("resend", config)
```
#### SendGrid Service
```python
config = {
"api_key": "your-sendgrid-api-key",
"default_from": "noreply@yourdomain.com"
}
sender = MailSenderFactory.create_sender("sendgrid", config)
```
## Testing
### Unit Tests
```bash
python -m pytest tests/unit_tests/libs/test_mail.py -v
```
### Integration Tests
```bash
python -m pytest tests/integration_tests/test_mail_integration.py -v
```
## Troubleshooting
### Common Issues
1. **OAuth2 Authentication Failure**
- Check if Client ID, Client Secret, and Tenant ID are correct
- Ensure Azure AD app permissions are properly configured and admin consent is granted
2. **SMTP Connection Failure**
- Check server address and port
- Ensure TLS settings are correct
3. **Email Send Failure**
- Check if sender email address is valid
- Ensure recipient email address format is correct
### Debug Logging
Enable detailed logging:
```python
import logging
logging.getLogger('libs.mail').setLevel(logging.DEBUG)
```
## API Reference
### MailMessage
```python
@dataclass
class MailMessage:
to: str # Recipient email address
subject: str # Email subject
html: str # Email HTML content
from_: Optional[str] = None # Sender email address
cc: Optional[List[str]] = None # CC recipients
bcc: Optional[List[str]] = None # BCC recipients
reply_to: Optional[str] = None # Reply-to address
```
### MailSender Protocol
```python
class MailSender(Protocol):
def send(self, message: MailMessage) -> None:
"""Send an email message"""
def is_configured(self) -> bool:
"""Check if sender is properly configured"""
def test_connection(self) -> bool:
"""Test connection to mail service"""
```
### MailSenderFactory
```python
class MailSenderFactory:
@classmethod
def create_sender(cls, mail_type: str, config: dict) -> MailSender:
"""Create a mail sender instance"""
@classmethod
def create_from_dify_config(cls, dify_config) -> Optional[MailSender]:
"""Create sender from Dify configuration"""
@classmethod
def get_supported_types(cls) -> list[str]:
"""Get list of supported mail types"""
```

@ -0,0 +1,25 @@
"""
Mail sending library with pluggable authentication and transport protocols.
This module provides a clean abstraction for sending emails with support for
multiple authentication methods (Basic Auth, OAuth2) and transport protocols.
"""
# Import all sender implementations to ensure they are registered
from . import resend_sender, sendgrid_sender, smtp_sender
from .exceptions import MailAuthError, MailConfigError, MailConnectionError, MailError, MailSendError, MailTimeoutError
from .factory import MailSenderFactory
from .protocol import MailMessage, MailSender, MailSenderBase
__all__ = [
"MailAuthError",
"MailConfigError",
"MailConnectionError",
"MailError",
"MailMessage",
"MailSendError",
"MailSender",
"MailSenderBase",
"MailSenderFactory",
"MailTimeoutError",
]

@ -0,0 +1,51 @@
"""
Exception classes for mail sending operations.
This module defines specific exceptions that can be raised during mail
sending operations, providing clear error handling and debugging information.
"""
class MailError(Exception):
"""Base exception for all mail-related errors."""
pass
class MailConfigError(MailError):
"""Raised when mail sender configuration is invalid or incomplete."""
pass
class MailSendError(MailError):
"""Raised when an email could not be sent."""
def __init__(self, message: str, original_error: Exception | None = None):
"""
Initialize the mail send error.
Args:
message: Error description
original_error: The original exception that caused this error
"""
super().__init__(message)
self.original_error = original_error
class MailAuthError(MailError):
"""Raised when authentication with the mail service fails."""
pass
class MailConnectionError(MailError):
"""Raised when connection to the mail service fails."""
pass
class MailTimeoutError(MailError):
"""Raised when mail operation times out."""
pass

@ -0,0 +1,144 @@
"""
Factory for creating mail sender instances based on configuration.
This module provides a factory that automatically selects and configures
the appropriate mail sender implementation based on the provided configuration.
"""
from collections.abc import Callable
from typing import Any, Optional, Union
from configs.app_config import DifyConfig
from .exceptions import MailConfigError
from .protocol import MailSender
class MailSenderFactory:
"""
Factory for creating mail sender instances.
This factory automatically selects the appropriate mail sender implementation
based on the mail type and authentication method specified in the configuration.
"""
_senders: dict[str, Union[type[MailSender], Callable[..., MailSender]]] = {}
@classmethod
def register_sender(cls, mail_type: str, sender_class: Union[type[MailSender], Callable[..., MailSender]]) -> None:
"""
Register a mail sender implementation.
Args:
mail_type: The mail type identifier (e.g., 'smtp', 'sendgrid')
sender_class: The mail sender class or factory function to register
"""
cls._senders[mail_type] = sender_class
@classmethod
def create_sender(cls, mail_type: str, config: dict) -> MailSender:
"""
Create a mail sender instance based on configuration.
Args:
mail_type: The type of mail sender to create
config: Configuration dictionary for the mail sender
Returns:
Configured mail sender instance
Raises:
MailConfigError: If the mail type is not supported or configuration is invalid
"""
if mail_type not in cls._senders:
raise MailConfigError(f"Unsupported mail type: {mail_type}")
sender_factory = cls._senders[mail_type]
try:
# Handle both class constructors and factory functions
if callable(sender_factory):
return sender_factory(**config)
else:
return sender_factory(**config)
except TypeError as e:
raise MailConfigError(f"Invalid configuration for {mail_type}: {e}")
@classmethod
def get_supported_types(cls) -> list[str]:
"""
Get list of supported mail types.
Returns:
List of supported mail type identifiers
"""
return list(cls._senders.keys())
@classmethod
def create_from_dify_config(cls, dify_config: DifyConfig) -> Optional[MailSender]:
"""
Create a mail sender from Dify configuration object.
Args:
dify_config: Dify configuration object
Returns:
Configured mail sender instance or None if mail is not configured
Raises:
MailConfigError: If configuration is invalid
"""
mail_type = dify_config.MAIL_TYPE
if not mail_type:
return None
# Build configuration based on mail type
config: dict[str, Any] = {"default_from": dify_config.MAIL_DEFAULT_SEND_FROM}
if mail_type == "smtp":
config.update(
{
"server": dify_config.SMTP_SERVER,
"port": dify_config.SMTP_PORT,
"username": dify_config.SMTP_USERNAME,
"password": dify_config.SMTP_PASSWORD,
"use_tls": dify_config.SMTP_USE_TLS,
"opportunistic_tls": dify_config.SMTP_OPPORTUNISTIC_TLS,
"auth_type": dify_config.SMTP_AUTH_TYPE,
"client_id": dify_config.SMTP_CLIENT_ID,
"client_secret": dify_config.SMTP_CLIENT_SECRET,
"tenant_id": dify_config.SMTP_TENANT_ID,
"oauth2_provider": dify_config.SMTP_OAUTH2_PROVIDER,
}
)
elif mail_type == "resend":
config.update(
{
"api_key": dify_config.RESEND_API_KEY,
"api_url": dify_config.RESEND_API_URL,
}
)
elif mail_type == "sendgrid":
config.update(
{
"api_key": dify_config.SENDGRID_API_KEY,
}
)
return cls.create_sender(mail_type, config)
# Register all available mail senders
def _register_default_senders():
"""Register all default mail sender implementations."""
from .resend_sender import ResendSender
from .sendgrid_sender import SendGridSender
from .smtp_sender import SMTPSender
MailSenderFactory.register_sender("smtp", SMTPSender)
MailSenderFactory.register_sender("resend", ResendSender)
MailSenderFactory.register_sender("sendgrid", SendGridSender)
# Auto-register senders when module is imported
_register_default_senders()

@ -0,0 +1,233 @@
"""
OAuth2 authentication handlers for mail services.
This module provides OAuth2 authentication support for various mail providers,
with automatic token management and refresh capabilities.
"""
import logging
import time
from abc import ABC, abstractmethod
from typing import Optional
import requests
from .exceptions import MailAuthError, MailConfigError
logger = logging.getLogger(__name__)
class OAuth2Handler(ABC):
"""
Abstract base class for OAuth2 authentication handlers.
This class defines the interface for OAuth2 authentication with
automatic token management and refresh.
"""
def __init__(self, client_id: str, client_secret: str):
"""
Initialize OAuth2 handler.
Args:
client_id: OAuth2 client ID
client_secret: OAuth2 client secret
"""
self.client_id = client_id
self.client_secret = client_secret
self._access_token: Optional[str] = None
self._refresh_token: Optional[str] = None
self._token_expires_at: Optional[float] = None
def get_access_token(self) -> str:
"""
Get a valid access token, refreshing if necessary.
Returns:
Valid access token
Raises:
MailAuthError: If token cannot be obtained
"""
if self._is_token_valid():
assert self._access_token is not None
return self._access_token
if self._refresh_token:
try:
self._refresh_access_token()
assert self._access_token is not None
return self._access_token
except Exception as e:
logger.warning(f"Token refresh failed, attempting new token: {e}")
# Get new token using client credentials
self._get_new_token()
assert self._access_token is not None
return self._access_token
def _is_token_valid(self) -> bool:
"""
Check if current access token is valid.
Returns:
True if token is valid and not expired
"""
if not self._access_token:
return False
if not self._token_expires_at:
return True # No expiration info, assume valid
# Add 60 second buffer before expiration
return time.time() < (self._token_expires_at - 60)
@abstractmethod
def _get_new_token(self) -> None:
"""
Obtain a new access token using client credentials.
This method should update _access_token, _refresh_token, and _token_expires_at.
"""
pass
@abstractmethod
def _refresh_access_token(self) -> None:
"""
Refresh the access token using the refresh token.
This method should update _access_token and _token_expires_at.
"""
pass
class MicrosoftOAuth2Handler(OAuth2Handler):
"""
OAuth2 handler for Microsoft/Office 365 services.
This handler supports the Client Credentials flow for server-to-server
authentication with Microsoft Graph API for sending emails.
"""
def __init__(self, client_id: str, client_secret: str, tenant_id: str):
"""
Initialize Microsoft OAuth2 handler.
Args:
client_id: Azure AD application client ID
client_secret: Azure AD application client secret
tenant_id: Azure AD tenant ID
"""
super().__init__(client_id, client_secret)
self.tenant_id = tenant_id
if not tenant_id:
raise MailConfigError("Tenant ID is required for Microsoft OAuth2")
self.token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
def _get_new_token(self) -> None:
"""
Get new access token using Client Credentials flow.
This uses the Client Credentials flow which is suitable for
server-to-server authentication without user interaction.
"""
data = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "https://graph.microsoft.com/.default",
}
try:
response = requests.post(self.token_url, data=data, timeout=30)
response.raise_for_status()
token_data = response.json()
self._access_token = token_data["access_token"]
self._refresh_token = token_data.get("refresh_token") # May not be present in client credentials flow
# Calculate expiration time
expires_in = token_data.get("expires_in", 3600)
self._token_expires_at = time.time() + expires_in
logger.info("Successfully obtained Microsoft OAuth2 access token")
except requests.RequestException as e:
logger.exception("Failed to obtain Microsoft OAuth2 token")
raise MailAuthError(f"Failed to obtain access token: {e}")
except KeyError as e:
logger.exception("Invalid token response from Microsoft")
raise MailAuthError(f"Invalid token response: {e}")
def _refresh_access_token(self) -> None:
"""
Refresh access token using refresh token.
Note: Client Credentials flow typically doesn't provide refresh tokens,
so this will usually fall back to getting a new token.
"""
if not self._refresh_token:
# Client credentials flow doesn't provide refresh tokens
# Fall back to getting a new token
self._get_new_token()
return
data = {
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self._refresh_token,
}
try:
response = requests.post(self.token_url, data=data, timeout=30)
response.raise_for_status()
token_data = response.json()
self._access_token = token_data["access_token"]
if "refresh_token" in token_data:
self._refresh_token = token_data["refresh_token"]
expires_in = token_data.get("expires_in", 3600)
self._token_expires_at = time.time() + expires_in
logger.info("Successfully refreshed Microsoft OAuth2 access token")
except requests.RequestException as e:
logger.exception("Failed to refresh Microsoft OAuth2 token")
raise MailAuthError(f"Failed to refresh access token: {e}")
def create_oauth2_handler(provider: str, **config) -> OAuth2Handler:
"""
Factory function to create OAuth2 handlers.
Args:
provider: OAuth2 provider name (currently only 'microsoft' is supported)
**config: Provider-specific configuration
Returns:
Configured OAuth2 handler
Raises:
MailConfigError: If provider is not supported or configuration is invalid
"""
if provider.lower() == "microsoft":
# Validate required configuration parameters
required_params = ["client_id", "client_secret", "tenant_id"]
for param in required_params:
if param not in config:
raise MailConfigError(f"Missing required parameter '{param}' for Microsoft OAuth2 configuration")
if not config[param] or not config[param].strip():
raise MailConfigError(f"Parameter '{param}' cannot be empty for Microsoft OAuth2 configuration")
# Create handler with validated parameters
return MicrosoftOAuth2Handler(
client_id=config["client_id"], client_secret=config["client_secret"], tenant_id=config["tenant_id"]
)
else:
raise MailConfigError(f"Unsupported OAuth2 provider: {provider}. Only 'microsoft' is supported.")

@ -0,0 +1,151 @@
"""
Core protocols and data structures for mail sending.
This module defines the abstract interfaces that all mail sending implementations
must follow, ensuring consistency and interchangeability.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, Protocol, runtime_checkable
@dataclass
class MailMessage:
"""
Represents an email message with all necessary information.
This is the standard data structure used across all mail sending implementations.
"""
to: str
subject: str
html: str
from_: Optional[str] = None
cc: Optional[list[str]] = None
bcc: Optional[list[str]] = None
reply_to: Optional[str] = None
attachments: Optional[list[dict]] = None # Future extension for attachments
def __post_init__(self):
"""Validate required fields."""
if not self.to:
raise ValueError("Recipient email address is required")
if not self.subject:
raise ValueError("Email subject is required")
if not self.html:
raise ValueError("Email content is required")
@runtime_checkable
class MailSender(Protocol):
"""
Protocol defining the interface for all mail sending implementations.
This protocol ensures that all mail senders (SMTP Basic Auth, SMTP OAuth2,
third-party services like SendGrid, etc.) provide a consistent interface.
"""
@abstractmethod
def send(self, message: MailMessage) -> None:
"""
Send an email message.
Args:
message: The email message to send
Raises:
MailSendError: If the email could not be sent
MailConfigError: If the mail sender is not properly configured
"""
pass
@abstractmethod
def is_configured(self) -> bool:
"""
Check if the mail sender is properly configured and ready to send emails.
Returns:
True if the sender is configured and ready, False otherwise
"""
pass
@abstractmethod
def test_connection(self) -> bool:
"""
Test the connection to the mail service.
Returns:
True if connection is successful, False otherwise
"""
pass
class MailSenderBase(ABC):
"""
Abstract base class providing common functionality for mail senders.
This class implements the MailSender protocol and provides common
functionality that can be shared across different implementations.
"""
def __init__(self, default_from: Optional[str] = None):
"""
Initialize the mail sender.
Args:
default_from: Default sender email address
"""
self.default_from = default_from
def send(self, message: MailMessage) -> None:
"""
Send an email message with validation and preprocessing.
Args:
message: The email message to send
"""
# Set default from address if not specified
if not message.from_ and self.default_from:
message.from_ = self.default_from
# Validate the message
self._validate_message(message)
# Send the message using the specific implementation
self._send_message(message)
def _validate_message(self, message: MailMessage) -> None:
"""
Validate the email message before sending.
Args:
message: The message to validate
Raises:
ValueError: If the message is invalid
"""
if not message.from_:
raise ValueError("Sender email address is required")
@abstractmethod
def _send_message(self, message: MailMessage) -> None:
"""
Send the email message using the specific implementation.
This method must be implemented by concrete classes.
Args:
message: The validated message to send
"""
pass
@abstractmethod
def is_configured(self) -> bool:
"""Check if the mail sender is properly configured."""
pass
@abstractmethod
def test_connection(self) -> bool:
"""Test the connection to the mail service."""
pass

@ -0,0 +1,124 @@
"""
Resend mail sender implementation.
This module provides email sending functionality using the Resend service.
"""
import logging
from typing import Optional
from .exceptions import MailConfigError, MailSendError
from .protocol import MailMessage, MailSenderBase
logger = logging.getLogger(__name__)
class ResendSender(MailSenderBase):
"""
Mail sender using Resend service.
This implementation provides email sending through the Resend API.
"""
def __init__(
self,
api_key: str,
api_url: Optional[str] = None,
default_from: Optional[str] = None,
):
"""
Initialize Resend sender.
Args:
api_key: Resend API key
api_url: Custom Resend API URL (optional)
default_from: Default sender email address
"""
super().__init__(default_from)
if not api_key:
raise MailConfigError("Resend API key is required")
self.api_key = api_key
self.api_url = api_url
# Initialize Resend client
self._init_resend_client()
def _init_resend_client(self):
"""Initialize the Resend client."""
try:
import resend
if self.api_url:
resend.api_url = self.api_url
resend.api_key = self.api_key
self._client = resend.Emails
except ImportError:
raise MailConfigError("Resend package is not installed. Install with: pip install resend")
except Exception as e:
raise MailConfigError(f"Failed to initialize Resend client: {e}")
def _send_message(self, message: MailMessage) -> None:
"""
Send email message using Resend.
Args:
message: The email message to send
Raises:
MailSendError: If the email could not be sent
"""
try:
# Prepare email data for Resend
email_data = {
"from": message.from_,
"to": message.to,
"subject": message.subject,
"html": message.html,
}
# Add optional fields
if message.cc:
email_data["cc"] = message.cc # type: ignore
if message.bcc:
email_data["bcc"] = message.bcc # type: ignore
if message.reply_to:
email_data["reply_to"] = message.reply_to
# Send the email
response = self._client.send(email_data) # type: ignore
logger.info(f"Email sent successfully to {message.to} via Resend (ID: {response.get('id', 'unknown')})")
except Exception as e:
logger.exception("Failed to send email via Resend")
raise MailSendError(f"Resend error: {e}", e)
def is_configured(self) -> bool:
"""
Check if the Resend sender is properly configured.
Returns:
True if properly configured
"""
return bool(self.api_key and self._client)
def test_connection(self) -> bool:
"""
Test connection to Resend service.
Returns:
True if connection is successful
"""
try:
# Resend doesn't have a specific health check endpoint
# We'll just verify that the client is initialized
return self.is_configured()
except Exception as e:
logger.warning(f"Resend connection test failed: {e}")
return False

@ -0,0 +1,139 @@
"""
SendGrid mail sender implementation.
This module provides email sending functionality using the SendGrid service.
"""
import logging
from typing import Optional
from .exceptions import MailConfigError, MailSendError
from .protocol import MailMessage, MailSenderBase
logger = logging.getLogger(__name__)
class SendGridSender(MailSenderBase):
"""
Mail sender using SendGrid service.
This implementation provides email sending through the SendGrid API.
"""
# Constants for SendGrid mail classes
_MAIL_CLASS = "MAIL_CLASS"
_EMAIL_CLASS = "EMAIL_CLASS"
_TO_CLASS = "TO_CLASS"
_CONTENT_CLASS = "CONTENT_CLASS"
def __init__(
self,
api_key: str,
default_from: Optional[str] = None,
):
"""
Initialize SendGrid sender.
Args:
api_key: SendGrid API key
default_from: Default sender email address
"""
super().__init__(default_from)
if not api_key:
raise MailConfigError("SendGrid API key is required")
self.api_key = api_key
# Initialize SendGrid client
self._init_sendgrid_client()
def _init_sendgrid_client(self):
"""Initialize the SendGrid client."""
try:
import sendgrid # type: ignore
from sendgrid.helpers.mail import Content, Email, Mail, To # type: ignore
self._client = sendgrid.SendGridAPIClient(api_key=self.api_key)
self._mail_classes = {
self._MAIL_CLASS: Mail,
self._EMAIL_CLASS: Email,
self._TO_CLASS: To,
self._CONTENT_CLASS: Content,
}
except ImportError:
raise MailConfigError("SendGrid package is not installed. Install with: pip install sendgrid")
except Exception as e:
raise MailConfigError(f"Failed to initialize SendGrid client: {e}")
def _send_message(self, message: MailMessage) -> None:
"""
Send email message using SendGrid.
Args:
message: The email message to send
Raises:
MailSendError: If the email could not be sent
"""
try:
# Create SendGrid mail object
from_email = self._mail_classes[self._EMAIL_CLASS](message.from_)
to_email = self._mail_classes[self._TO_CLASS](message.to)
subject = message.subject
content = self._mail_classes[self._CONTENT_CLASS]("text/html", message.html)
mail = self._mail_classes[self._MAIL_CLASS](from_email, to_email, subject, content)
# Add optional fields
if message.cc:
for cc_email in message.cc:
mail.add_cc(self._mail_classes[self._EMAIL_CLASS](cc_email))
if message.bcc:
for bcc_email in message.bcc:
mail.add_bcc(self._mail_classes[self._EMAIL_CLASS](bcc_email))
if message.reply_to:
mail.reply_to = self._mail_classes[self._EMAIL_CLASS](message.reply_to)
# Send the email
mail_json = mail.get()
response = self._client.client.mail.send.post(request_body=mail_json) # type: ignore
logger.info(f"Email sent successfully to {message.to} via SendGrid (Status: {response.status_code})")
# Check for errors
if response.status_code >= 400:
raise MailSendError(f"SendGrid returned status {response.status_code}: {response.body}")
except Exception as e:
logger.exception("Failed to send email via SendGrid")
if isinstance(e, MailSendError):
raise
raise MailSendError(f"SendGrid error: {e}", e)
def is_configured(self) -> bool:
"""
Check if the SendGrid sender is properly configured.
Returns:
True if properly configured
"""
return bool(self.api_key and self._client)
def test_connection(self) -> bool:
"""
Test connection to SendGrid service.
Returns:
True if connection is successful
"""
try:
# Test API key by making a simple API call
response = self._client.client.api_keys.get() # type: ignore
return bool(response.status_code == 200)
except Exception as e:
logger.warning(f"SendGrid connection test failed: {e}")
return False

@ -0,0 +1,204 @@
"""
SMTP mail sender with Basic Authentication support.
This module provides SMTP email sending functionality using traditional
username/password authentication.
"""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
from .exceptions import MailConfigError, MailConnectionError, MailSendError, MailTimeoutError
from .protocol import MailMessage, MailSenderBase
logger = logging.getLogger(__name__)
class SMTPBasicAuthSender(MailSenderBase):
"""
SMTP mail sender using Basic Authentication (username/password).
This implementation provides traditional SMTP email sending with
username and password authentication.
"""
def __init__(
self,
server: str,
port: int,
username: Optional[str] = None,
password: Optional[str] = None,
use_tls: bool = False,
opportunistic_tls: bool = False,
default_from: Optional[str] = None,
):
"""
Initialize SMTP Basic Auth sender.
Args:
server: SMTP server hostname
port: SMTP server port
username: Username for authentication (optional)
password: Password for authentication (optional)
use_tls: Whether to use TLS encryption
opportunistic_tls: Whether to use opportunistic TLS
default_from: Default sender email address
"""
super().__init__(default_from)
self.server = server
self.port = port
self.username = username
self.password = password
self.use_tls = use_tls
self.opportunistic_tls = opportunistic_tls
# Validate configuration
if not server or not port:
raise MailConfigError("SMTP server and port are required")
if opportunistic_tls and not use_tls:
raise MailConfigError("Opportunistic TLS requires TLS to be enabled")
def _send_message(self, message: MailMessage) -> None:
"""
Send email message using SMTP with Basic Auth.
Args:
message: The email message to send
Raises:
MailSendError: If the email could not be sent
"""
smtp = None
try:
# Establish SMTP connection
smtp = self._create_smtp_connection()
# Authenticate if credentials are provided
if self._should_authenticate():
assert self.username is not None
assert self.password is not None
smtp.login(self.username, self.password)
# Create email message
msg = self._create_mime_message(message)
# Send the email
assert message.from_ is not None
smtp.sendmail(message.from_, message.to, msg.as_string())
logger.info(f"Email sent successfully to {message.to}")
except smtplib.SMTPAuthenticationError as e:
logger.exception("SMTP authentication failed")
raise MailSendError(f"Authentication failed: {e}", e)
except smtplib.SMTPRecipientsRefused as e:
logger.exception("Recipients refused")
raise MailSendError(f"Recipients refused: {e}", e)
except smtplib.SMTPException as e:
logger.exception("SMTP error occurred")
raise MailSendError(f"SMTP error: {e}", e)
except TimeoutError as e:
logger.exception("Timeout occurred while sending email")
raise MailTimeoutError(f"Email sending timed out: {e}")
except Exception as e:
logger.exception(f"Unexpected error while sending email to {message.to}")
raise MailSendError(f"Unexpected error: {e}", e)
finally:
if smtp:
try:
smtp.quit()
except Exception:
pass # Ignore errors when closing connection
def _create_smtp_connection(self) -> smtplib.SMTP:
"""
Create and configure SMTP connection.
Returns:
Configured SMTP connection
Raises:
MailConnectionError: If connection cannot be established
"""
try:
if self.use_tls:
if self.opportunistic_tls:
smtp = smtplib.SMTP(self.server, self.port, timeout=10)
smtp.starttls()
else:
smtp = smtplib.SMTP_SSL(self.server, self.port, timeout=10)
else:
smtp = smtplib.SMTP(self.server, self.port, timeout=10)
return smtp
except Exception as e:
raise MailConnectionError(f"Failed to connect to SMTP server: {e}")
def _should_authenticate(self) -> bool:
"""
Check if authentication should be performed.
Returns:
True if authentication credentials are available and valid
"""
return bool(self.username and self.password and self.username.strip() and self.password.strip())
def _create_mime_message(self, message: MailMessage) -> MIMEMultipart:
"""
Create MIME message from MailMessage.
Args:
message: The mail message to convert
Returns:
MIME multipart message
"""
msg = MIMEMultipart()
msg["Subject"] = message.subject
assert message.from_ is not None
msg["From"] = message.from_
msg["To"] = message.to
if message.cc:
msg["Cc"] = ", ".join(message.cc)
if message.reply_to:
msg["Reply-To"] = message.reply_to
# Attach HTML content
msg.attach(MIMEText(message.html, "html"))
return msg
def is_configured(self) -> bool:
"""
Check if the SMTP sender is properly configured.
Returns:
True if properly configured
"""
return bool(self.server and self.port)
def test_connection(self) -> bool:
"""
Test connection to SMTP server.
Returns:
True if connection is successful
"""
try:
smtp = self._create_smtp_connection()
if self._should_authenticate():
assert self.username is not None
assert self.password is not None
smtp.login(self.username, self.password)
smtp.quit()
return True
except Exception as e:
logger.warning(f"SMTP connection test failed: {e}")
return False

@ -0,0 +1,257 @@
"""
SMTP mail sender with OAuth2 authentication support.
This module provides SMTP email sending functionality using OAuth2
authentication for modern email providers like Microsoft and Google.
"""
import base64
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
from .exceptions import MailAuthError, MailConfigError, MailConnectionError, MailSendError
from .oauth2_handler import create_oauth2_handler
from .protocol import MailMessage, MailSenderBase
logger = logging.getLogger(__name__)
class SMTPOAuth2Sender(MailSenderBase):
"""
SMTP mail sender using OAuth2 authentication.
This implementation provides SMTP email sending with OAuth2 authentication
for providers like Microsoft Office 365 and Google Gmail.
"""
def __init__(
self,
server: str,
port: int,
username: str,
client_id: str,
client_secret: str,
tenant_id: str,
oauth2_provider: str = "microsoft",
use_tls: bool = True,
opportunistic_tls: bool = False,
default_from: Optional[str] = None,
):
"""
Initialize SMTP OAuth2 sender.
Args:
server: SMTP server hostname
port: SMTP server port
username: Email address for authentication
client_id: OAuth2 client ID
client_secret: OAuth2 client secret
tenant_id: Azure AD tenant ID (required for Microsoft OAuth2)
oauth2_provider: OAuth2 provider (currently only 'microsoft' is supported)
use_tls: Whether to use TLS encryption
opportunistic_tls: Whether to use opportunistic TLS
default_from: Default sender email address
"""
super().__init__(default_from)
self.server = server
self.port = port
self.username = username
self.use_tls = use_tls
self.opportunistic_tls = opportunistic_tls
# Validate configuration
if not all([server, port, username, client_id, client_secret, tenant_id]):
raise MailConfigError("Server, port, username, client ID, client secret, and tenant ID are required")
if opportunistic_tls and not use_tls:
raise MailConfigError("Opportunistic TLS requires TLS to be enabled")
# Validate OAuth2 provider
if oauth2_provider.lower() != "microsoft":
raise MailConfigError("Only Microsoft OAuth2 is supported")
# Create OAuth2 handler
oauth_config = {
"client_id": client_id,
"client_secret": client_secret,
"tenant_id": tenant_id,
}
try:
self.oauth2_handler = create_oauth2_handler(oauth2_provider, **oauth_config)
except Exception as e:
raise MailConfigError(f"Failed to create OAuth2 handler: {e}")
def _send_message(self, message: MailMessage) -> None:
"""
Send email message using SMTP with OAuth2 authentication.
Args:
message: The email message to send
Raises:
MailSendError: If the email could not be sent
"""
smtp = None
try:
# Get access token
access_token = self.oauth2_handler.get_access_token()
# Establish SMTP connection
smtp = self._create_smtp_connection()
# Authenticate using OAuth2
self._oauth2_authenticate(smtp, access_token)
# Create email message
msg = self._create_mime_message(message)
# Send the email
assert message.from_ is not None
smtp.sendmail(message.from_, message.to, msg.as_string())
logger.info(f"Email sent successfully to {message.to} using OAuth2")
except MailAuthError:
raise # Re-raise auth errors as-is
except smtplib.SMTPAuthenticationError as e:
logger.exception("SMTP OAuth2 authentication failed")
raise MailAuthError(f"OAuth2 authentication failed: {e}")
except smtplib.SMTPRecipientsRefused as e:
logger.exception("Recipients refused")
raise MailSendError(f"Recipients refused: {e}", e)
except smtplib.SMTPException as e:
logger.exception("SMTP error occurred")
raise MailSendError(f"SMTP error: {e}", e)
except Exception as e:
logger.exception(f"Unexpected error while sending email to {message.to}")
raise MailSendError(f"Unexpected error: {e}", e)
finally:
if smtp:
try:
smtp.quit()
except Exception:
pass # Ignore errors when closing connection
def _create_smtp_connection(self) -> smtplib.SMTP:
"""
Create and configure SMTP connection.
Returns:
Configured SMTP connection
Raises:
MailConnectionError: If connection cannot be established
"""
try:
if self.use_tls:
if self.opportunistic_tls:
smtp = smtplib.SMTP(self.server, self.port, timeout=30)
smtp.starttls()
else:
smtp = smtplib.SMTP_SSL(self.server, self.port, timeout=30)
else:
smtp = smtplib.SMTP(self.server, self.port, timeout=30)
return smtp
except Exception as e:
raise MailConnectionError(f"Failed to connect to SMTP server: {e}")
def _oauth2_authenticate(self, smtp: smtplib.SMTP, access_token: str) -> None:
"""
Authenticate with SMTP server using OAuth2.
Args:
smtp: SMTP connection
access_token: OAuth2 access token
Raises:
MailAuthError: If authentication fails
"""
try:
# Create OAuth2 authentication string
auth_string = self._create_oauth2_auth_string(access_token)
# Authenticate using XOAUTH2
smtp.docmd("AUTH", "XOAUTH2 " + auth_string)
except smtplib.SMTPAuthenticationError as e:
raise MailAuthError(f"OAuth2 authentication failed: {e}")
except Exception as e:
raise MailAuthError(f"OAuth2 authentication error: {e}")
def _create_oauth2_auth_string(self, access_token: str) -> str:
"""
Create OAuth2 authentication string for SMTP.
Args:
access_token: OAuth2 access token
Returns:
Base64-encoded authentication string
"""
# Create the authentication string in the format:
# user=username\x01auth=Bearer access_token\x01\x01
auth_bytes = f"user={self.username}\x01auth=Bearer {access_token}\x01\x01".encode("ascii")
return base64.b64encode(auth_bytes).decode("ascii")
def _create_mime_message(self, message: MailMessage) -> MIMEMultipart:
"""
Create MIME message from MailMessage.
Args:
message: The mail message to convert
Returns:
MIME multipart message
"""
msg = MIMEMultipart()
msg["Subject"] = message.subject
assert message.from_ is not None
msg["From"] = message.from_
msg["To"] = message.to
if message.cc:
msg["Cc"] = ", ".join(message.cc)
if message.reply_to:
msg["Reply-To"] = message.reply_to
# Attach HTML content
msg.attach(MIMEText(message.html, "html"))
return msg
def is_configured(self) -> bool:
"""
Check if the SMTP OAuth2 sender is properly configured.
Returns:
True if properly configured
"""
return bool(self.server and self.port and self.username and self.oauth2_handler)
def test_connection(self) -> bool:
"""
Test connection and authentication with SMTP server.
Returns:
True if connection and authentication are successful
"""
try:
# Test OAuth2 token acquisition
access_token = self.oauth2_handler.get_access_token()
# Test SMTP connection and authentication
smtp = self._create_smtp_connection()
self._oauth2_authenticate(smtp, access_token)
smtp.quit()
return True
except Exception as e:
logger.warning(f"SMTP OAuth2 connection test failed: {e}")
return False

@ -0,0 +1,162 @@
"""
Unified SMTP mail sender that automatically selects authentication method.
This module provides a single SMTP sender that can automatically choose
between Basic Auth and OAuth2 based on configuration.
"""
import logging
from typing import Optional, Union
from .exceptions import MailConfigError
from .protocol import MailMessage
from .smtp_basic import SMTPBasicAuthSender
from .smtp_oauth2 import SMTPOAuth2Sender
logger = logging.getLogger(__name__)
class SMTPSender:
"""
Unified SMTP sender that automatically selects authentication method.
This class acts as a facade that automatically chooses between Basic Auth
and OAuth2 authentication based on the provided configuration.
"""
def __init__(
self,
server: str,
port: int,
username: Optional[str] = None,
password: Optional[str] = None,
auth_type: str = "basic",
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
tenant_id: Optional[str] = None,
oauth2_provider: str = "microsoft",
use_tls: bool = False,
opportunistic_tls: bool = False,
default_from: Optional[str] = None,
**kwargs,
):
"""
Initialize SMTP sender with automatic authentication method selection.
Args:
server: SMTP server hostname
port: SMTP server port
username: Username/email for authentication
password: Password for Basic Auth
auth_type: Authentication type ('basic' or 'oauth2')
client_id: OAuth2 client ID
client_secret: OAuth2 client secret
tenant_id: OAuth2 tenant ID (required for Microsoft OAuth2)
oauth2_provider: OAuth2 provider (currently only 'microsoft' is supported)
use_tls: Whether to use TLS encryption
opportunistic_tls: Whether to use opportunistic TLS
default_from: Default sender email address
"""
# Auto-detect authentication type if not explicitly specified
auth_type = auth_type.lower()
if auth_type == "basic":
# Validate Basic Auth configuration
if not username or not password:
logger.warning("Username or password missing for Basic Auth, checking for OAuth2 config")
# Check if OAuth2 config is available
if client_id and client_secret:
auth_type = "oauth2"
logger.info("Auto-switching to OAuth2 authentication")
else:
raise MailConfigError("Username and password are required for Basic Auth")
elif auth_type == "oauth2":
# Validate OAuth2 configuration
if not client_id or not client_secret:
raise MailConfigError("Client ID and client secret are required for OAuth2")
provider = oauth2_provider.lower()
if provider != "microsoft":
raise MailConfigError("Only Microsoft OAuth2 is supported")
if not tenant_id:
raise MailConfigError("Tenant ID is required for Microsoft OAuth2")
if not username:
raise MailConfigError("Username is required for OAuth2")
else:
raise MailConfigError(f"Unsupported authentication type: {auth_type}")
self.auth_type = auth_type
# Create the appropriate sender based on auth type
self._sender: Union[SMTPBasicAuthSender, SMTPOAuth2Sender]
if self.auth_type == "basic":
self._sender = SMTPBasicAuthSender(
server=server,
port=port,
username=username,
password=password,
use_tls=use_tls,
opportunistic_tls=opportunistic_tls,
default_from=default_from,
)
elif self.auth_type == "oauth2":
# Type assertions after validation
assert username is not None
assert client_id is not None
assert client_secret is not None
assert tenant_id is not None
self._sender = SMTPOAuth2Sender(
server=server,
port=port,
username=username,
oauth2_provider=oauth2_provider,
client_id=client_id,
client_secret=client_secret,
tenant_id=tenant_id,
use_tls=use_tls,
opportunistic_tls=opportunistic_tls,
default_from=default_from,
)
logger.info(f"Initialized SMTP sender with {self.auth_type} authentication")
def send(self, message: MailMessage) -> None:
"""
Send an email message.
Args:
message: The email message to send
"""
self._sender.send(message)
def is_configured(self) -> bool:
"""
Check if the SMTP sender is properly configured.
Returns:
True if properly configured
"""
return self._sender.is_configured()
def test_connection(self) -> bool:
"""
Test connection to the SMTP server.
Returns:
True if connection is successful
"""
return self._sender.test_connection()
@property
def sender_type(self) -> str:
"""
Get the type of the underlying sender.
Returns:
Sender type description
"""
return f"SMTP ({self.auth_type})"

@ -0,0 +1,206 @@
"""
Integration tests for the mail sending system.
These tests verify that the mail system works end-to-end with real configuration.
"""
from unittest.mock import Mock, patch
import pytest
from extensions.ext_mail import Mail
from libs.mail import MailConfigError, MailMessage, MailSenderFactory
class TestMailExtensionIntegration:
"""Test the Mail extension integration."""
@patch("extensions.ext_mail.MailSenderFactory.create_from_dify_config")
def test_mail_extension_initialization(self, mock_create_sender):
"""Test Mail extension initialization with new architecture."""
# Setup mock
mock_sender = Mock()
mock_create_sender.return_value = mock_sender
# Initialize Mail extension
mail = Mail()
assert not mail.is_inited()
# Initialize with app
mock_app = Mock()
mail.init_app(mock_app)
# Verify initialization
assert mail.is_inited()
mock_create_sender.assert_called_once()
@patch("extensions.ext_mail.MailSenderFactory.create_from_dify_config")
def test_mail_extension_send(self, mock_create_sender):
"""Test sending email through Mail extension."""
# Setup mock sender
mock_sender = Mock()
mock_create_sender.return_value = mock_sender
# Initialize Mail extension
mail = Mail()
mock_app = Mock()
mail.init_app(mock_app)
# Send email
mail.send(
to="recipient@example.com", subject="Test Subject", html="<p>Test content</p>", from_="sender@example.com"
)
# Verify sender was called with correct message
mock_sender.send.assert_called_once()
call_args = mock_sender.send.call_args[0][0] # Get the MailMessage argument
assert isinstance(call_args, MailMessage)
assert call_args.to == "recipient@example.com"
assert call_args.subject == "Test Subject"
assert call_args.html == "<p>Test content</p>"
assert call_args.from_ == "sender@example.com"
@patch("extensions.ext_mail.MailSenderFactory.create_from_dify_config")
def test_mail_extension_not_initialized(self, mock_create_sender):
"""Test error when trying to send without initialization."""
mock_create_sender.return_value = None # No mail configured
mail = Mail()
mock_app = Mock()
mail.init_app(mock_app)
# Should raise error when trying to send
with pytest.raises(ValueError, match="Mail sender is not initialized"):
mail.send(to="recipient@example.com", subject="Test Subject", html="<p>Test content</p>")
class TestEndToEndMailFlow:
"""Test end-to-end mail sending flow."""
@patch("libs.mail.smtp_basic.smtplib.SMTP")
def test_smtp_basic_auth_flow(self, mock_smtp_class):
"""Test complete SMTP Basic Auth flow."""
# Setup mock SMTP
mock_smtp = Mock()
mock_smtp_class.return_value = mock_smtp
# Create mock config
mock_config = Mock()
mock_config.MAIL_TYPE = "smtp"
mock_config.MAIL_DEFAULT_SEND_FROM = "noreply@example.com"
mock_config.SMTP_SERVER = "smtp.example.com"
mock_config.SMTP_PORT = 587
mock_config.SMTP_USERNAME = "user@example.com"
mock_config.SMTP_PASSWORD = "password"
mock_config.SMTP_USE_TLS = False
mock_config.SMTP_OPPORTUNISTIC_TLS = False
mock_config.SMTP_AUTH_TYPE = "basic"
mock_config.SMTP_CLIENT_ID = None
mock_config.SMTP_CLIENT_SECRET = None
mock_config.SMTP_TENANT_ID = None
mock_config.SMTP_REFRESH_TOKEN = None
mock_config.SMTP_OAUTH2_PROVIDER = "microsoft"
# Create sender from config
sender = MailSenderFactory.create_from_dify_config(mock_config)
# Create and send message
message = MailMessage(
to="recipient@example.com", subject="Test Subject", html="<p>Test content</p>", from_="sender@example.com"
)
sender.send(message)
# Verify SMTP interaction
mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=10)
mock_smtp.login.assert_called_once_with("user@example.com", "password")
mock_smtp.sendmail.assert_called_once()
mock_smtp.quit.assert_called_once()
@patch("libs.mail.oauth2_handler.requests.post")
@patch("libs.mail.smtp_oauth2.smtplib.SMTP_SSL")
def test_smtp_oauth2_flow(self, mock_smtp_class, mock_requests_post):
"""Test complete SMTP OAuth2 flow."""
# Setup mock OAuth2 token response
mock_response = Mock()
mock_response.json.return_value = {"access_token": "test-access-token", "expires_in": 3600}
mock_response.raise_for_status.return_value = None
mock_requests_post.return_value = mock_response
# Setup mock SMTP
mock_smtp = Mock()
mock_smtp_class.return_value = mock_smtp
# Create mock config for OAuth2
mock_config = Mock()
mock_config.MAIL_TYPE = "smtp"
mock_config.MAIL_DEFAULT_SEND_FROM = "noreply@company.com"
mock_config.SMTP_SERVER = "smtp.office365.com"
mock_config.SMTP_PORT = 587
mock_config.SMTP_USERNAME = "user@company.com"
mock_config.SMTP_PASSWORD = None
mock_config.SMTP_USE_TLS = True
mock_config.SMTP_OPPORTUNISTIC_TLS = False
mock_config.SMTP_AUTH_TYPE = "oauth2"
mock_config.SMTP_CLIENT_ID = "test-client-id"
mock_config.SMTP_CLIENT_SECRET = "test-client-secret"
mock_config.SMTP_TENANT_ID = "test-tenant-id"
mock_config.SMTP_OAUTH2_PROVIDER = "microsoft"
# Create sender from config
sender = MailSenderFactory.create_from_dify_config(mock_config)
# Create and send message
message = MailMessage(
to="recipient@company.com",
subject="Test OAuth2 Subject",
html="<p>Test OAuth2 content</p>",
from_="sender@company.com",
)
sender.send(message)
# Verify OAuth2 token request
mock_requests_post.assert_called_once()
call_args = mock_requests_post.call_args
assert "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token" in call_args[0]
# Verify SMTP interaction
mock_smtp_class.assert_called_once_with("smtp.office365.com", 587, timeout=30)
mock_smtp.docmd.assert_called_once() # OAuth2 authentication
mock_smtp.sendmail.assert_called_once()
mock_smtp.quit.assert_called_once()
class TestConfigurationValidation:
"""Test configuration validation and error handling."""
def test_invalid_smtp_config(self):
"""Test error handling for invalid SMTP configuration."""
mock_config = Mock()
mock_config.MAIL_TYPE = "smtp"
mock_config.SMTP_SERVER = None # Invalid: missing server
mock_config.SMTP_PORT = 587
with pytest.raises((MailConfigError, ValueError)): # Should raise configuration error
MailSenderFactory.create_from_dify_config(mock_config)
def test_missing_oauth2_credentials(self):
"""Test error handling for missing OAuth2 credentials."""
mock_config = Mock()
mock_config.MAIL_TYPE = "smtp"
mock_config.MAIL_DEFAULT_SEND_FROM = "noreply@company.com"
mock_config.SMTP_SERVER = "smtp.office365.com"
mock_config.SMTP_PORT = 587
mock_config.SMTP_USERNAME = "user@company.com"
mock_config.SMTP_USE_TLS = True
mock_config.SMTP_OPPORTUNISTIC_TLS = False
mock_config.SMTP_AUTH_TYPE = "oauth2"
mock_config.SMTP_CLIENT_ID = None # Missing
mock_config.SMTP_CLIENT_SECRET = None # Missing
mock_config.SMTP_TENANT_ID = "test-tenant-id"
mock_config.SMTP_OAUTH2_PROVIDER = "microsoft"
with pytest.raises((MailConfigError, ValueError)): # Should raise configuration error
MailSenderFactory.create_from_dify_config(mock_config)

@ -0,0 +1,260 @@
"""
Unit tests for the mail sending library.
This module tests the new mail sending architecture with Protocol-based design.
"""
from unittest.mock import Mock, patch
import pytest
from libs.mail import MailConfigError, MailMessage, MailSenderFactory
from libs.mail.smtp_basic import SMTPBasicAuthSender
from libs.mail.smtp_oauth2 import SMTPOAuth2Sender
from libs.mail.smtp_sender import SMTPSender
class TestMailMessage:
"""Test the MailMessage data structure."""
def test_valid_message_creation(self):
"""Test creating a valid mail message."""
message = MailMessage(
to="test@example.com", subject="Test Subject", html="<p>Test content</p>", from_="sender@example.com"
)
assert message.to == "test@example.com"
assert message.subject == "Test Subject"
assert message.html == "<p>Test content</p>"
assert message.from_ == "sender@example.com"
def test_message_validation(self):
"""Test message validation for required fields."""
# Missing recipient
with pytest.raises(ValueError, match="Recipient email address is required"):
MailMessage(to="", subject="Test", html="<p>Test</p>")
# Missing subject
with pytest.raises(ValueError, match="Email subject is required"):
MailMessage(to="test@example.com", subject="", html="<p>Test</p>")
# Missing content
with pytest.raises(ValueError, match="Email content is required"):
MailMessage(to="test@example.com", subject="Test", html="")
class TestMailSenderFactory:
"""Test the mail sender factory."""
def test_register_and_create_sender(self):
"""Test registering and creating a mail sender."""
# Create a mock sender class
mock_sender_class = Mock()
mock_sender_instance = Mock()
mock_sender_class.return_value = mock_sender_instance
# Register the mock sender
MailSenderFactory.register_sender("test", mock_sender_class)
# Create sender instance
config = {"test_param": "test_value"}
sender = MailSenderFactory.create_sender("test", config)
# Verify
mock_sender_class.assert_called_once_with(**config)
assert sender == mock_sender_instance
def test_unsupported_mail_type(self):
"""Test error handling for unsupported mail types."""
with pytest.raises(MailConfigError, match="Unsupported mail type: nonexistent"):
MailSenderFactory.create_sender("nonexistent", {})
def test_get_supported_types(self):
"""Test getting list of supported mail types."""
# Register a test sender
MailSenderFactory.register_sender("test_type", Mock)
supported_types = MailSenderFactory.get_supported_types()
assert "test_type" in supported_types
class TestSMTPBasicAuthSender:
"""Test SMTP Basic Auth sender."""
def test_initialization(self):
"""Test SMTP Basic Auth sender initialization."""
sender = SMTPBasicAuthSender(
server="smtp.example.com", port=587, username="user@example.com", password="password", use_tls=True
)
assert sender.server == "smtp.example.com"
assert sender.port == 587
assert sender.username == "user@example.com"
assert sender.password == "password"
assert sender.use_tls is True
def test_invalid_configuration(self):
"""Test error handling for invalid configuration."""
# Missing server
with pytest.raises(MailConfigError, match="SMTP server and port are required"):
SMTPBasicAuthSender(server="", port=587)
# Invalid TLS configuration
with pytest.raises(MailConfigError, match="Opportunistic TLS requires TLS to be enabled"):
SMTPBasicAuthSender(server="smtp.example.com", port=587, use_tls=False, opportunistic_tls=True)
def test_is_configured(self):
"""Test configuration check."""
sender = SMTPBasicAuthSender(server="smtp.example.com", port=587)
assert sender.is_configured() is True
# Test with missing server
sender.server = ""
assert sender.is_configured() is False
@patch("libs.mail.smtp_basic.smtplib.SMTP")
def test_send_message_success(self, mock_smtp_class):
"""Test successful email sending."""
# Setup mock
mock_smtp = Mock()
mock_smtp_class.return_value = mock_smtp
sender = SMTPBasicAuthSender(
server="smtp.example.com", port=587, username="user@example.com", password="password"
)
message = MailMessage(
to="recipient@example.com", subject="Test Subject", html="<p>Test content</p>", from_="sender@example.com"
)
# Send message
sender.send(message)
# Verify SMTP calls
mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=10)
mock_smtp.login.assert_called_once_with("user@example.com", "password")
mock_smtp.sendmail.assert_called_once()
mock_smtp.quit.assert_called_once()
class TestSMTPOAuth2Sender:
"""Test SMTP OAuth2 sender."""
@patch("libs.mail.smtp_oauth2.create_oauth2_handler")
def test_initialization_microsoft(self, mock_create_handler):
"""Test SMTP OAuth2 sender initialization for Microsoft."""
mock_handler = Mock()
mock_create_handler.return_value = mock_handler
sender = SMTPOAuth2Sender(
server="smtp.office365.com",
port=587,
username="user@company.com",
oauth2_provider="microsoft",
client_id="client-id",
client_secret="client-secret",
tenant_id="tenant-id",
)
assert sender.server == "smtp.office365.com"
assert sender.port == 587
assert sender.username == "user@company.com"
assert sender.oauth2_handler == mock_handler
mock_create_handler.assert_called_once_with(
"microsoft", client_id="client-id", client_secret="client-secret", tenant_id="tenant-id"
)
def test_missing_tenant_id_for_microsoft(self):
"""Test error when tenant ID is missing for Microsoft OAuth2."""
with pytest.raises(TypeError, match="missing 1 required positional argument: 'tenant_id'"):
SMTPOAuth2Sender(
server="smtp.office365.com",
port=587,
username="user@company.com",
client_id="client-id",
client_secret="client-secret",
# tenant_id is missing
)
class TestSMTPSender:
"""Test unified SMTP sender."""
@patch("libs.mail.smtp_sender.SMTPBasicAuthSender")
def test_basic_auth_selection(self, mock_basic_sender):
"""Test automatic selection of Basic Auth."""
mock_instance = Mock()
mock_basic_sender.return_value = mock_instance
sender = SMTPSender(
server="smtp.example.com", port=587, username="user@example.com", password="password", auth_type="basic"
)
assert sender.auth_type == "basic"
mock_basic_sender.assert_called_once()
@patch("libs.mail.smtp_sender.SMTPOAuth2Sender")
def test_oauth2_selection(self, mock_oauth2_sender):
"""Test automatic selection of OAuth2."""
mock_instance = Mock()
mock_oauth2_sender.return_value = mock_instance
sender = SMTPSender(
server="smtp.office365.com",
port=587,
username="user@company.com",
auth_type="oauth2",
oauth2_provider="microsoft",
client_id="client-id",
client_secret="client-secret",
tenant_id="tenant-id",
)
assert sender.auth_type == "oauth2"
mock_oauth2_sender.assert_called_once()
def test_unsupported_auth_type(self):
"""Test error for unsupported authentication type."""
with pytest.raises(MailConfigError, match="Unsupported authentication type: invalid"):
SMTPSender(server="smtp.example.com", port=587, auth_type="invalid")
class TestFactoryIntegration:
"""Test factory integration with Dify config."""
def test_create_from_dify_config_smtp_basic(self):
"""Test creating SMTP Basic Auth sender from Dify config."""
mock_config = Mock()
mock_config.MAIL_TYPE = "smtp"
mock_config.MAIL_DEFAULT_SEND_FROM = "noreply@example.com"
mock_config.SMTP_SERVER = "smtp.example.com"
mock_config.SMTP_PORT = 587
mock_config.SMTP_USERNAME = "user@example.com"
mock_config.SMTP_PASSWORD = "password"
mock_config.SMTP_USE_TLS = True
mock_config.SMTP_OPPORTUNISTIC_TLS = False
# Add attributes that will be accessed by getattr
mock_config.SMTP_AUTH_TYPE = "basic"
mock_config.SMTP_CLIENT_ID = None
mock_config.SMTP_CLIENT_SECRET = None
mock_config.SMTP_TENANT_ID = None
mock_config.SMTP_REFRESH_TOKEN = None
mock_config.SMTP_OAUTH2_PROVIDER = "microsoft"
result = MailSenderFactory.create_from_dify_config(mock_config)
# Verify that a sender was created and it's the right type
assert result is not None
assert hasattr(result, "send")
assert hasattr(result, "is_configured")
assert result.is_configured()
def test_create_from_dify_config_no_mail_type(self):
"""Test handling when MAIL_TYPE is not set."""
mock_config = Mock()
mock_config.MAIL_TYPE = None
result = MailSenderFactory.create_from_dify_config(mock_config)
assert result is None

@ -745,6 +745,20 @@ SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_USE_TLS=true SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=false SMTP_OPPORTUNISTIC_TLS=false
# SMTP authentication type: 'basic' for traditional username/password authentication,
# 'oauth2' for modern OAuth2 authentication with services like Microsoft 365/Outlook
SMTP_AUTH_TYPE=basic
# OAuth2 configuration for SMTP (required when SMTP_AUTH_TYPE=oauth2)
# Client ID from your registered OAuth2 application in the provider's developer portal
SMTP_CLIENT_ID=
# Client secret from your registered OAuth2 application in the provider's developer portal
SMTP_CLIENT_SECRET=
# For Microsoft OAuth2 (Office 365/Outlook)
# Tenant ID (Directory ID) from your Azure AD/Microsoft 365 account
SMTP_TENANT_ID=
# OAuth2 provider name - currently only 'microsoft' is supported
# This identifies which OAuth2 implementation to use for authentication
SMTP_OAUTH2_PROVIDER=microsoft
# Sendgid configuration # Sendgid configuration
SENDGRID_API_KEY= SENDGRID_API_KEY=

@ -327,6 +327,11 @@ x-shared-env: &shared-api-worker-env
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_USE_TLS: ${SMTP_USE_TLS:-true} SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false} SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false}
SMTP_AUTH_TYPE: ${SMTP_AUTH_TYPE:-basic}
SMTP_CLIENT_ID: ${SMTP_CLIENT_ID:-}
SMTP_CLIENT_SECRET: ${SMTP_CLIENT_SECRET:-}
SMTP_TENANT_ID: ${SMTP_TENANT_ID:-}
SMTP_OAUTH2_PROVIDER: ${SMTP_OAUTH2_PROVIDER:-microsoft}
SENDGRID_API_KEY: ${SENDGRID_API_KEY:-} SENDGRID_API_KEY: ${SENDGRID_API_KEY:-}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72} INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}

Loading…
Cancel
Save