feat: Add SMTP OAuth 2.0 support for Microsoft Exchange

Add comprehensive OAuth 2.0 authentication support for SMTP to address
Microsoft's Basic Authentication retirement in September 2025.

Key features:
- OAuth 2.0 SASL XOAUTH2 authentication mechanism
- Microsoft Azure AD integration with client credentials flow
- Backward compatible with existing basic authentication
- Comprehensive configuration options in .env.example files
- Enhanced SMTP client with dependency injection for better testability
- Complete test coverage with proper mocking

Configuration:
- SMTP_AUTH_TYPE: Choose between 'basic' and 'oauth2' authentication
- Microsoft OAuth 2.0 settings for Azure AD integration
- Automatic token acquisition using client credentials flow

Files changed:
- Enhanced SMTP client with OAuth 2.0 support
- New mail module structure under libs/mail/
- Updated configuration system with OAuth settings
- Comprehensive documentation and setup instructions
- Complete test suite for OAuth functionality

This change ensures compatibility with Microsoft Exchange Online
after Basic Authentication retirement.
This commit is contained in:
-LAN-
2025-07-22 03:00:16 +08:00
parent f4522fd695
commit 69f712b713
13 changed files with 1593 additions and 62 deletions

276
api/libs/mail/README.md Normal file
View File

@ -0,0 +1,276 @@
# Email Module
This module provides email functionality for Dify, including SMTP with OAuth 2.0 support for Microsoft Exchange/Outlook.
## Features
- Basic SMTP authentication
- OAuth 2.0 authentication for Microsoft Exchange/Outlook
- Multiple email providers: SMTP, SendGrid, Resend
- TLS/SSL support
- Microsoft Exchange compliance (Basic Auth retirement September 2025)
## Configuration
### Basic SMTP Configuration
```env
MAIL_TYPE=smtp
MAIL_DEFAULT_SEND_FROM=your-email@company.com
SMTP_SERVER=smtp.company.com
SMTP_PORT=587
SMTP_USERNAME=your-email@company.com
SMTP_PASSWORD=your-password
SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=true
SMTP_AUTH_TYPE=basic
```
### Microsoft Exchange OAuth 2.0 Configuration
For Microsoft Exchange/Outlook compatibility:
```env
MAIL_TYPE=smtp
MAIL_DEFAULT_SEND_FROM=your-email@company.com
SMTP_SERVER=smtp.office365.com
SMTP_PORT=587
SMTP_USERNAME=your-email@company.com
SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=true
SMTP_AUTH_TYPE=oauth2
# Microsoft OAuth 2.0 Settings
MICROSOFT_OAUTH2_CLIENT_ID=your-azure-app-client-id
MICROSOFT_OAUTH2_CLIENT_SECRET=your-azure-app-client-secret
MICROSOFT_OAUTH2_TENANT_ID=your-tenant-id
```
## Microsoft Azure AD App Setup
### 1. Create Azure AD Application
1. Go to [Azure Portal](https://portal.azure.com) → Azure Active Directory → App registrations
2. Click "New registration"
3. Enter application name (e.g., "Dify Email Service")
4. Select "Accounts in this organizational directory only"
5. Click "Register"
### 2. Configure API Permissions
1. Go to "API permissions"
2. Click "Add a permission" → Microsoft Graph
3. Select "Application permissions"
4. Add these permissions:
- `Mail.Send` - Send mail as any user
- `SMTP.Send` - Send email via SMTP AUTH
5. Click "Grant admin consent"
### 3. Create Client Secret
1. Go to "Certificates & secrets"
2. Click "New client secret"
3. Enter description and expiration
4. Copy the secret value (you won't see it again)
### 4. Get Configuration Values
- **Client ID**: Application (client) ID from Overview page
- **Client Secret**: The secret value you just created
- **Tenant ID**: Directory (tenant) ID from Overview page
## Usage Examples
### Basic Usage
The email service is automatically configured based on environment variables. Simply use the mail extension:
```python
from extensions.ext_mail import mail
# Send email
mail_data = {
"to": "recipient@example.com",
"subject": "Test Email",
"html": "<h1>Hello World</h1>"
}
try:
mail._client.send(mail_data)
print("Email sent successfully")
except Exception as e:
print(f"Failed to send email: {e}")
```
### OAuth Token Management
For service accounts using client credentials flow:
```python
from libs.mail.oauth_email import MicrosoftEmailOAuth
# Initialize OAuth client
oauth_client = MicrosoftEmailOAuth(
client_id="your-client-id",
client_secret="your-client-secret",
redirect_uri="", # Not needed for client credentials
tenant_id="your-tenant-id"
)
# Get access token
try:
token_response = oauth_client.get_access_token_client_credentials()
access_token = token_response["access_token"]
print(f"Access token obtained: {access_token[:10]}...")
except Exception as e:
print(f"Failed to get OAuth token: {e}")
```
### Custom SMTP Client
For direct SMTP usage with OAuth:
```python
from libs.mail import SMTPClient
# Create SMTP client with OAuth
client = SMTPClient(
server="smtp.office365.com",
port=587,
username="your-email@company.com",
password="", # Not used with OAuth
from_addr="your-email@company.com",
use_tls=True,
opportunistic_tls=True,
oauth_access_token="your-access-token",
auth_type="oauth2"
)
# Send email
mail_data = {
"to": "recipient@example.com",
"subject": "OAuth Test",
"html": "<p>Sent via OAuth 2.0</p>"
}
client.send(mail_data)
```
## Migration from Basic Auth
### Microsoft Exchange Migration
Microsoft is retiring Basic Authentication for Exchange Online in September 2025. Follow these steps to migrate:
1. **Set up Azure AD Application** (see setup instructions above)
2. **Update configuration** to use OAuth 2.0:
```env
SMTP_AUTH_TYPE=oauth2
MICROSOFT_OAUTH2_CLIENT_ID=your-client-id
MICROSOFT_OAUTH2_CLIENT_SECRET=your-client-secret
MICROSOFT_OAUTH2_TENANT_ID=your-tenant-id
```
3. **Test the configuration** before the migration deadline
4. **Remove old password-based settings** once OAuth is working
### Backward Compatibility
The system maintains backward compatibility:
- Existing Basic Auth configurations continue to work
- OAuth settings are optional and only used when `SMTP_AUTH_TYPE=oauth2`
- Gradual migration is supported
## Troubleshooting
### Common OAuth Issues
1. **Token acquisition fails**:
- Verify Client ID and Secret are correct
- Check that admin consent was granted for API permissions
- Ensure Tenant ID is correct
2. **SMTP authentication fails**:
- Verify the access token is valid and not expired
- Check that SMTP.Send permission is granted
- Ensure the user has Send As permissions
3. **Configuration issues**:
- Verify all required environment variables are set
- Check SMTP server and port settings
- Ensure TLS settings match your server requirements
### Testing Token Acquisition
```python
from libs.mail.oauth_email import MicrosoftEmailOAuth
def test_oauth_token():
oauth_client = MicrosoftEmailOAuth(
client_id="your-client-id",
client_secret="your-client-secret",
redirect_uri="",
tenant_id="your-tenant-id"
)
try:
response = oauth_client.get_access_token_client_credentials()
print("✓ OAuth token acquired successfully")
print(f"Token type: {response.get('token_type')}")
print(f"Expires in: {response.get('expires_in')} seconds")
return True
except Exception as e:
print(f"✗ OAuth token acquisition failed: {e}")
return False
if __name__ == "__main__":
test_oauth_token()
```
## Security Considerations
### Token Management
- Access tokens are automatically obtained when needed
- Tokens are not stored permanently
- Client credentials flow is used for service accounts
- Secrets should be stored securely in environment variables
### Network Security
- Always use TLS for SMTP connections (`SMTP_USE_TLS=true`)
- Use opportunistic TLS when supported (`SMTP_OPPORTUNISTIC_TLS=true`)
- Verify SMTP server certificates in production
### Access Control
- Grant minimum required permissions in Azure AD
- Use dedicated service accounts for email sending
- Regularly rotate client secrets
- Monitor access logs for suspicious activity
## Dependencies
The email module uses these internal components:
- `libs.mail.smtp`: Core SMTP client with OAuth support
- `libs.mail.oauth_email`: Microsoft OAuth 2.0 implementation
- `libs.mail.oauth_http_client`: HTTP client abstraction
- `libs.mail.smtp_connection`: SMTP connection management
- `extensions.ext_mail`: Flask extension for email integration
## Testing
The module includes comprehensive tests with proper mocking:
- `tests/unit_tests/libs/mail/test_oauth_email.py`: OAuth functionality tests
- `tests/unit_tests/libs/mail/test_smtp_enhanced.py`: SMTP client tests
Run tests with:
```bash
uv run pytest tests/unit_tests/libs/mail/test_oauth_email.py -v
uv run pytest tests/unit_tests/libs/mail/test_smtp_enhanced.py -v
```

26
api/libs/mail/__init__.py Normal file
View File

@ -0,0 +1,26 @@
"""Mail module for email functionality
This module provides comprehensive email support including:
- SMTP clients with OAuth 2.0 support
- Microsoft Exchange/Outlook integration
- Email authentication and connection management
- Support for TLS/SSL encryption
"""
from .oauth_email import EmailOAuth, MicrosoftEmailOAuth, OAuthUserInfo
from .oauth_http_client import OAuthHTTPClient, OAuthHTTPClientProtocol
from .smtp import SMTPAuthenticator, SMTPClient, SMTPMessageBuilder
from .smtp_connection import SMTPConnectionFactory, SMTPConnectionProtocol
__all__ = [
"EmailOAuth",
"MicrosoftEmailOAuth",
"OAuthHTTPClient",
"OAuthHTTPClientProtocol",
"OAuthUserInfo",
"SMTPAuthenticator",
"SMTPClient",
"SMTPConnectionFactory",
"SMTPConnectionProtocol",
"SMTPMessageBuilder",
]

View File

@ -0,0 +1,175 @@
"""Email OAuth implementation with dependency injection for better testability"""
import base64
import urllib.parse
from dataclasses import dataclass
from typing import Optional, Union
from .oauth_http_client import OAuthHTTPClient, OAuthHTTPClientProtocol
@dataclass
class OAuthUserInfo:
id: str
name: str
email: str
class EmailOAuth:
"""Base OAuth class with dependency injection"""
def __init__(
self,
client_id: str,
client_secret: str,
redirect_uri: str,
http_client: Optional[OAuthHTTPClientProtocol] = None,
):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.http_client = http_client or OAuthHTTPClient()
def get_authorization_url(self):
raise NotImplementedError()
def get_access_token(self, code: str):
raise NotImplementedError()
def get_raw_user_info(self, token: str):
raise NotImplementedError()
def get_user_info(self, token: str) -> OAuthUserInfo:
raw_info = self.get_raw_user_info(token)
return self._transform_user_info(raw_info)
def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo:
raise NotImplementedError()
class MicrosoftEmailOAuth(EmailOAuth):
"""Microsoft OAuth 2.0 implementation with dependency injection
References:
- Microsoft identity platform OAuth 2.0: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
- Microsoft Graph API permissions: https://learn.microsoft.com/en-us/graph/permissions-reference
- OAuth 2.0 client credentials flow: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow
- SMTP OAuth 2.0 authentication: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
"""
_AUTH_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize"
_TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
_USER_INFO_URL = "https://graph.microsoft.com/v1.0/me"
def __init__(
self,
client_id: str,
client_secret: str,
redirect_uri: str,
tenant_id: str = "common",
http_client: Optional[OAuthHTTPClientProtocol] = None,
):
super().__init__(client_id, client_secret, redirect_uri, http_client)
self.tenant_id = tenant_id
def get_authorization_url(self, invite_token: Optional[str] = None) -> str:
"""Generate OAuth authorization URL"""
params = {
"client_id": self.client_id,
"response_type": "code",
"redirect_uri": self.redirect_uri,
"scope": "https://outlook.office.com/SMTP.Send offline_access",
"response_mode": "query",
}
if invite_token:
params["state"] = invite_token
auth_url = self._AUTH_URL.format(tenant=self.tenant_id)
return f"{auth_url}?{urllib.parse.urlencode(params)}"
def get_access_token(self, code: str) -> dict[str, Union[str, int]]:
"""Get access token using authorization code flow"""
data: dict[str, Union[str, int]] = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": self.redirect_uri,
"scope": "https://outlook.office.com/SMTP.Send offline_access",
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
token_url = self._TOKEN_URL.format(tenant=self.tenant_id)
response = self.http_client.post(token_url, data=data, headers=headers)
if response["status_code"] != 200:
raise ValueError(f"Error in Microsoft OAuth: {response['json']}")
json_response = response["json"]
if isinstance(json_response, dict):
return json_response
raise ValueError("Unexpected response format")
def get_access_token_client_credentials(
self, scope: str = "https://outlook.office365.com/.default"
) -> dict[str, Union[str, int]]:
"""Get access token using client credentials flow (for service accounts)"""
data: dict[str, Union[str, int]] = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials",
"scope": scope,
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
token_url = self._TOKEN_URL.format(tenant=self.tenant_id)
response = self.http_client.post(token_url, data=data, headers=headers)
if response["status_code"] != 200:
raise ValueError(f"Error in Microsoft OAuth Client Credentials: {response['json']}")
json_response = response["json"]
if isinstance(json_response, dict):
return json_response
raise ValueError("Unexpected response format")
def refresh_access_token(self, refresh_token: str) -> dict[str, Union[str, int]]:
"""Refresh access token using refresh token"""
data: dict[str, Union[str, int]] = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
"scope": "https://outlook.office.com/SMTP.Send offline_access",
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
token_url = self._TOKEN_URL.format(tenant=self.tenant_id)
response = self.http_client.post(token_url, data=data, headers=headers)
if response["status_code"] != 200:
raise ValueError(f"Error refreshing Microsoft OAuth token: {response['json']}")
json_response = response["json"]
if isinstance(json_response, dict):
return json_response
raise ValueError("Unexpected response format")
def get_raw_user_info(self, token: str) -> dict[str, Union[str, int, dict, list]]:
"""Get user info from Microsoft Graph API"""
headers = {"Authorization": f"Bearer {token}"}
return self.http_client.get(self._USER_INFO_URL, headers=headers)
def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo:
"""Transform raw user info to OAuthUserInfo"""
return OAuthUserInfo(
id=str(raw_info["id"]),
name=raw_info.get("displayName", ""),
email=raw_info.get("mail", raw_info.get("userPrincipalName", "")),
)
@staticmethod
def create_sasl_xoauth2_string(username: str, access_token: str) -> str:
"""Create SASL XOAUTH2 authentication string for SMTP"""
auth_string = f"user={username}\x01auth=Bearer {access_token}\x01\x01"
return base64.b64encode(auth_string.encode()).decode()

View File

@ -0,0 +1,45 @@
"""HTTP client abstraction for OAuth requests"""
from abc import ABC, abstractmethod
from typing import Optional, Union
import requests
class OAuthHTTPClientProtocol(ABC):
"""Abstract interface for OAuth HTTP operations"""
@abstractmethod
def post(
self, url: str, data: dict[str, Union[str, int]], headers: Optional[dict[str, str]] = None
) -> dict[str, Union[str, int, dict, list]]:
"""Make a POST request"""
pass
@abstractmethod
def get(self, url: str, headers: Optional[dict[str, str]] = None) -> dict[str, Union[str, int, dict, list]]:
"""Make a GET request"""
pass
class OAuthHTTPClient(OAuthHTTPClientProtocol):
"""Default implementation using requests library"""
def post(
self, url: str, data: dict[str, Union[str, int]], headers: Optional[dict[str, str]] = None
) -> dict[str, Union[str, int, dict, list]]:
"""Make a POST request"""
response = requests.post(url, data=data, headers=headers or {})
return {
"status_code": response.status_code,
"json": response.json() if response.headers.get("content-type", "").startswith("application/json") else {},
"text": response.text,
"headers": dict(response.headers),
}
def get(self, url: str, headers: Optional[dict[str, str]] = None) -> dict[str, Union[str, int, dict, list]]:
"""Make a GET request"""
response = requests.get(url, headers=headers or {})
response.raise_for_status()
json_data = response.json()
return dict(json_data)

164
api/libs/mail/smtp.py Normal file
View File

@ -0,0 +1,164 @@
"""Enhanced SMTP client with dependency injection for better testability"""
import base64
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
from .smtp_connection import (
SMTPConnectionFactory,
SMTPConnectionProtocol,
SSLSMTPConnectionFactory,
StandardSMTPConnectionFactory,
)
class SMTPAuthenticator:
"""Handles SMTP authentication logic"""
@staticmethod
def create_sasl_xoauth2_string(username: str, access_token: str) -> str:
"""Create SASL XOAUTH2 authentication string for SMTP OAuth2
References:
- SASL XOAUTH2 Mechanism: https://developers.google.com/gmail/imap/xoauth2-protocol
- Microsoft XOAUTH2 Format: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#sasl-xoauth2
"""
auth_string = f"user={username}\x01auth=Bearer {access_token}\x01\x01"
return base64.b64encode(auth_string.encode()).decode()
def authenticate_basic(self, connection: SMTPConnectionProtocol, username: str, password: str) -> None:
"""Perform basic authentication"""
if username and password and username.strip() and password.strip():
connection.login(username, password)
def authenticate_oauth2(self, connection: SMTPConnectionProtocol, username: str, access_token: str) -> None:
"""Perform OAuth 2.0 authentication using SASL XOAUTH2 mechanism
References:
- Microsoft OAuth 2.0 and SMTP: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
- SASL XOAUTH2 Mechanism: https://developers.google.com/gmail/imap/xoauth2-protocol
- RFC 4954 - SMTP AUTH: https://tools.ietf.org/html/rfc4954
"""
if not username or not access_token:
raise ValueError("Username and OAuth access token are required for OAuth2 authentication")
auth_string = self.create_sasl_xoauth2_string(username, access_token)
try:
connection.docmd("AUTH", f"XOAUTH2 {auth_string}")
except smtplib.SMTPAuthenticationError as e:
logging.exception(f"OAuth2 authentication failed for user {username}")
raise ValueError(f"OAuth2 authentication failed: {str(e)}")
except Exception:
logging.exception(f"Unexpected error during OAuth2 authentication for user {username}")
raise
class SMTPMessageBuilder:
"""Builds SMTP messages"""
@staticmethod
def build_message(mail_data: dict[str, str], from_addr: str) -> MIMEMultipart:
"""Build a MIME message from mail data"""
msg = MIMEMultipart()
msg["Subject"] = mail_data["subject"]
msg["From"] = from_addr
msg["To"] = mail_data["to"]
msg.attach(MIMEText(mail_data["html"], "html"))
return msg
class SMTPClient:
"""SMTP client with OAuth 2.0 support and dependency injection for better testability"""
def __init__(
self,
server: str,
port: int,
username: str,
password: str,
from_addr: str,
use_tls: bool = False,
opportunistic_tls: bool = False,
oauth_access_token: Optional[str] = None,
auth_type: str = "basic",
connection_factory: Optional[SMTPConnectionFactory] = None,
ssl_connection_factory: Optional[SMTPConnectionFactory] = None,
authenticator: Optional[SMTPAuthenticator] = None,
message_builder: Optional[SMTPMessageBuilder] = None,
):
self.server = server
self.port = port
self.from_addr = from_addr
self.username = username
self.password = password
self.use_tls = use_tls
self.opportunistic_tls = opportunistic_tls
self.oauth_access_token = oauth_access_token
self.auth_type = auth_type
# Use injected dependencies or create defaults
self.connection_factory = connection_factory or StandardSMTPConnectionFactory()
self.ssl_connection_factory = ssl_connection_factory or SSLSMTPConnectionFactory()
self.authenticator = authenticator or SMTPAuthenticator()
self.message_builder = message_builder or SMTPMessageBuilder()
def _create_connection(self) -> SMTPConnectionProtocol:
"""Create appropriate SMTP connection based on TLS settings"""
if self.use_tls and not self.opportunistic_tls:
return self.ssl_connection_factory.create_connection(self.server, self.port)
else:
return self.connection_factory.create_connection(self.server, self.port)
def _setup_tls_if_needed(self, connection: SMTPConnectionProtocol) -> None:
"""Setup TLS if opportunistic TLS is enabled"""
if self.use_tls and self.opportunistic_tls:
connection.ehlo(self.server)
connection.starttls()
connection.ehlo(self.server)
def _authenticate(self, connection: SMTPConnectionProtocol) -> None:
"""Authenticate with the SMTP server"""
if self.auth_type == "oauth2":
if not self.oauth_access_token:
raise ValueError("OAuth access token is required for oauth2 auth_type")
self.authenticator.authenticate_oauth2(connection, self.username, self.oauth_access_token)
else:
self.authenticator.authenticate_basic(connection, self.username, self.password)
def send(self, mail: dict[str, str]) -> None:
"""Send email using SMTP"""
connection = None
try:
# Create connection
connection = self._create_connection()
# Setup TLS if needed
self._setup_tls_if_needed(connection)
# Authenticate
self._authenticate(connection)
# Build and send message
msg = self.message_builder.build_message(mail, self.from_addr)
connection.sendmail(self.from_addr, mail["to"], msg.as_string())
except smtplib.SMTPException:
logging.exception("SMTP error occurred")
raise
except TimeoutError:
logging.exception("Timeout occurred while sending email")
raise
except Exception:
logging.exception(f"Unexpected error occurred while sending email to {mail['to']}")
raise
finally:
if connection:
try:
connection.quit()
except Exception:
# Ignore errors during cleanup
pass

View File

@ -0,0 +1,79 @@
"""SMTP connection abstraction for better testability"""
import smtplib
from abc import ABC, abstractmethod
from typing import Protocol, Union
class SMTPConnectionProtocol(Protocol):
"""Protocol defining SMTP connection interface"""
def ehlo(self, name: str = "") -> tuple[int, bytes]: ...
def starttls(self) -> tuple[int, bytes]: ...
def login(self, user: str, password: str) -> tuple[int, bytes]: ...
def docmd(self, cmd: str, args: str = "") -> tuple[int, bytes]: ...
def sendmail(self, from_addr: str, to_addrs: str, msg: str) -> dict: ...
def quit(self) -> tuple[int, bytes]: ...
class SMTPConnectionFactory(ABC):
"""Abstract factory for creating SMTP connections"""
@abstractmethod
def create_connection(self, server: str, port: int, timeout: int = 10) -> SMTPConnectionProtocol:
"""Create an SMTP connection"""
pass
class SMTPConnectionWrapper:
"""Wrapper to adapt smtplib.SMTP to our protocol"""
def __init__(self, smtp_obj: Union[smtplib.SMTP, smtplib.SMTP_SSL]):
self._smtp = smtp_obj
def ehlo(self, name: str = "") -> tuple[int, bytes]:
result = self._smtp.ehlo(name)
return (result[0], result[1])
def starttls(self) -> tuple[int, bytes]:
result = self._smtp.starttls()
return (result[0], result[1])
def login(self, user: str, password: str) -> tuple[int, bytes]:
result = self._smtp.login(user, password)
return (result[0], result[1])
def docmd(self, cmd: str, args: str = "") -> tuple[int, bytes]:
result = self._smtp.docmd(cmd, args)
return (result[0], result[1])
def sendmail(self, from_addr: str, to_addrs: str, msg: str) -> dict:
result = self._smtp.sendmail(from_addr, to_addrs, msg)
return dict(result)
def quit(self) -> tuple[int, bytes]:
result = self._smtp.quit()
return (result[0], result[1])
class StandardSMTPConnectionFactory(SMTPConnectionFactory):
"""Factory for creating standard SMTP connections"""
def create_connection(self, server: str, port: int, timeout: int = 10) -> SMTPConnectionProtocol:
"""Create a standard SMTP connection"""
smtp_obj = smtplib.SMTP(server, port, timeout=timeout)
return SMTPConnectionWrapper(smtp_obj)
class SSLSMTPConnectionFactory(SMTPConnectionFactory):
"""Factory for creating SSL SMTP connections"""
def create_connection(self, server: str, port: int, timeout: int = 10) -> SMTPConnectionProtocol:
"""Create an SSL SMTP connection"""
smtp_obj = smtplib.SMTP_SSL(server, port, timeout=timeout)
return SMTPConnectionWrapper(smtp_obj)

View File

@ -1,59 +0,0 @@
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
logger = logging.getLogger(__name__)
class SMTPClient:
def __init__(
self, server: str, port: int, username: str, password: str, _from: str, use_tls=False, opportunistic_tls=False
):
self.server = server
self.port = port
self._from = _from
self.username = username
self.password = password
self.use_tls = use_tls
self.opportunistic_tls = opportunistic_tls
def send(self, mail: dict):
smtp = None
try:
if self.use_tls:
if self.opportunistic_tls:
smtp = smtplib.SMTP(self.server, self.port, timeout=10)
# Send EHLO command with the HELO domain name as the server address
smtp.ehlo(self.server)
smtp.starttls()
# Resend EHLO command to identify the TLS session
smtp.ehlo(self.server)
else:
smtp = smtplib.SMTP_SSL(self.server, self.port, timeout=10)
else:
smtp = smtplib.SMTP(self.server, self.port, timeout=10)
# Only authenticate if both username and password are non-empty
if self.username and self.password and self.username.strip() and self.password.strip():
smtp.login(self.username, self.password)
msg = MIMEMultipart()
msg["Subject"] = mail["subject"]
msg["From"] = self._from
msg["To"] = mail["to"]
msg.attach(MIMEText(mail["html"], "html"))
smtp.sendmail(self._from, mail["to"], msg.as_string())
except smtplib.SMTPException:
logger.exception("SMTP error occurred")
raise
except TimeoutError:
logger.exception("Timeout occurred while sending email")
raise
except Exception:
logger.exception("Unexpected error occurred while sending email to %s", mail["to"])
raise
finally:
if smtp:
smtp.quit()