diff --git a/api/.env.example b/api/.env.example index baa9c382c8..0229db4212 100644 --- a/api/.env.example +++ b/api/.env.example @@ -353,6 +353,20 @@ SMTP_USERNAME=123 SMTP_PASSWORD=abc SMTP_USE_TLS=true 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 SENDGRID_API_KEY= # Sentry configuration diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index df15b92c35..f1f6fceb4c 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -658,6 +658,31 @@ class MailConfig(BaseSettings): 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( description="Maximum number of emails allowed to be sent from the same IP address in a minute", default=50, diff --git a/api/extensions/ext_mail.py b/api/extensions/ext_mail.py index df5d8a9c11..56f9064eb6 100644 --- a/api/extensions/ext_mail.py +++ b/api/extensions/ext_mail.py @@ -5,94 +5,54 @@ from flask import Flask from configs import dify_config from dify_app import DifyApp +from libs.mail import MailConfigError, MailMessage, MailSender, MailSenderFactory class Mail: def __init__(self): - self._client = None - self._default_send_from = None + self._sender: Optional[MailSender] = None def is_inited(self) -> bool: - return self._client is not None - - def init_app(self, app: Flask): - mail_type = dify_config.MAIL_TYPE - if not mail_type: - logging.warning("MAIL_TYPE is not set") - return - - if dify_config.MAIL_DEFAULT_SEND_FROM: - self._default_send_from = dify_config.MAIL_DEFAULT_SEND_FROM - - match mail_type: - case "resend": - import resend - - api_key = dify_config.RESEND_API_KEY - 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)) + return self._sender is not None + + def init_app(self, app: Flask) -> None: + """Initialize mail sender using the new factory pattern.""" + try: + self._sender = MailSenderFactory.create_from_dify_config(dify_config) + if self._sender: + logging.info("Mail sender initialized successfully") + else: + logging.warning("MAIL_TYPE is not set, mail functionality disabled") + except MailConfigError as e: + logging.exception("Failed to initialize mail sender") + raise ValueError(f"Mail configuration error: {e}") + except Exception as e: + logging.exception("Unexpected error initializing mail sender") + raise ValueError(f"Failed to initialize mail sender: {e}") def send(self, to: str, subject: str, html: str, from_: Optional[str] = None): - if not self._client: - raise ValueError("Mail client is not initialized") - - if not from_ and self._default_send_from: - from_ = self._default_send_from - - if not from_: - raise ValueError("mail from is not set") - - if not to: - raise ValueError("mail to is not set") - - if not subject: - raise ValueError("mail subject is not set") - - if not html: - raise ValueError("mail html is not set") - - self._client.send( - { - "from": from_, - "to": to, - "subject": subject, - "html": html, - } - ) + """ + Send an email using the configured mail sender. + + Args: + to: Recipient email address + subject: Email subject + html: Email HTML content + from_: Sender email address (optional, uses default if not provided) + """ + if not self._sender: + raise ValueError("Mail sender is not initialized") + + try: + # Create mail message + message = MailMessage(to=to, subject=subject, html=html, from_=from_) + + # Send the message + self._sender.send(message) + + except Exception as e: + logging.exception(f"Failed to send email to {to}") + raise def is_enabled() -> bool: diff --git a/api/libs/mail/README.md b/api/libs/mail/README.md new file mode 100644 index 0000000000..6e911df9cd --- /dev/null +++ b/api/libs/mail/README.md @@ -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="
This email was sent using SMTP Basic Auth.
", + 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="This email was sent using Microsoft OAuth2.
", + 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""" +``` diff --git a/api/libs/mail/__init__.py b/api/libs/mail/__init__.py new file mode 100644 index 0000000000..3e86d9f82b --- /dev/null +++ b/api/libs/mail/__init__.py @@ -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", +] diff --git a/api/libs/mail/exceptions.py b/api/libs/mail/exceptions.py new file mode 100644 index 0000000000..a2ea0b8533 --- /dev/null +++ b/api/libs/mail/exceptions.py @@ -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 diff --git a/api/libs/mail/factory.py b/api/libs/mail/factory.py new file mode 100644 index 0000000000..ceba94222f --- /dev/null +++ b/api/libs/mail/factory.py @@ -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() diff --git a/api/libs/mail/oauth2_handler.py b/api/libs/mail/oauth2_handler.py new file mode 100644 index 0000000000..dc34c0eed5 --- /dev/null +++ b/api/libs/mail/oauth2_handler.py @@ -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.") diff --git a/api/libs/mail/protocol.py b/api/libs/mail/protocol.py new file mode 100644 index 0000000000..12ac321d12 --- /dev/null +++ b/api/libs/mail/protocol.py @@ -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 diff --git a/api/libs/mail/resend_sender.py b/api/libs/mail/resend_sender.py new file mode 100644 index 0000000000..47b68e409f --- /dev/null +++ b/api/libs/mail/resend_sender.py @@ -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 diff --git a/api/libs/mail/sendgrid_sender.py b/api/libs/mail/sendgrid_sender.py new file mode 100644 index 0000000000..fbddd8cfeb --- /dev/null +++ b/api/libs/mail/sendgrid_sender.py @@ -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 diff --git a/api/libs/mail/smtp_basic.py b/api/libs/mail/smtp_basic.py new file mode 100644 index 0000000000..c86862891d --- /dev/null +++ b/api/libs/mail/smtp_basic.py @@ -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 diff --git a/api/libs/mail/smtp_oauth2.py b/api/libs/mail/smtp_oauth2.py new file mode 100644 index 0000000000..9d77bf6c86 --- /dev/null +++ b/api/libs/mail/smtp_oauth2.py @@ -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 diff --git a/api/libs/mail/smtp_sender.py b/api/libs/mail/smtp_sender.py new file mode 100644 index 0000000000..88150f3a88 --- /dev/null +++ b/api/libs/mail/smtp_sender.py @@ -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})" diff --git a/api/tests/integration_tests/test_mail_integration.py b/api/tests/integration_tests/test_mail_integration.py new file mode 100644 index 0000000000..44103635ef --- /dev/null +++ b/api/tests/integration_tests/test_mail_integration.py @@ -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="Test content
", 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 == "Test content
" + 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="Test content
") + + +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="Test content
", 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="Test OAuth2 content
", + 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) diff --git a/api/tests/unit_tests/libs/test_mail.py b/api/tests/unit_tests/libs/test_mail.py new file mode 100644 index 0000000000..29257df440 --- /dev/null +++ b/api/tests/unit_tests/libs/test_mail.py @@ -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="Test content
", from_="sender@example.com" + ) + + assert message.to == "test@example.com" + assert message.subject == "Test Subject" + assert message.html == "Test content
" + 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="Test
") + + # Missing subject + with pytest.raises(ValueError, match="Email subject is required"): + MailMessage(to="test@example.com", subject="", html="Test
") + + # 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="Test content
", 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 diff --git a/docker/.env.example b/docker/.env.example index 5a2a426338..4bc76e6d90 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -745,6 +745,20 @@ SMTP_USERNAME= SMTP_PASSWORD= SMTP_USE_TLS=true 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 SENDGRID_API_KEY= diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 5f13060658..c11264c32f 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -327,6 +327,11 @@ x-shared-env: &shared-api-worker-env SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_USE_TLS: ${SMTP_USE_TLS:-true} SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false} + SMTP_AUTH_TYPE: ${SMTP_AUTH_TYPE:-basic} + 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:-} INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}