feat(mail): Support oauth for stmp mail server.
Signed-off-by: -LAN- <laipz8200@outlook.com>pull/21218/head
parent
2020a31785
commit
573adb1deb
@ -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
|
||||||
Loading…
Reference in New Issue