Compare commits

..

6 Commits

Author SHA1 Message Date
-LAN-
40f2bee979 Tidy frontend typings after review 2025-10-06 02:25:35 +08:00
-LAN-
c14f4495c1 Send account deletion codes via POST 2025-10-06 02:25:35 +08:00
-LAN-
c9eabe1612 Use POST for suggested questions 2025-10-06 02:25:35 +08:00
-LAN-
e0fb754e80 Use POST for more-like-this flow 2025-10-06 02:25:35 +08:00
-LAN-
695d89ef2d Use POST for MCP server refresh flow 2025-10-06 02:25:35 +08:00
-LAN-
be13f79696 Fix logout flow to use POST 2025-10-06 02:25:35 +08:00
32 changed files with 702 additions and 1666 deletions

View File

@@ -379,19 +379,6 @@ SMTP_USERNAME=123
SMTP_PASSWORD=abc
SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=false
# SMTP authentication type: 'basic' for username/password, 'oauth2' for Microsoft OAuth 2.0
# Use 'oauth2' for Microsoft Exchange/Outlook due to Basic Auth retirement (September 2025)
SMTP_AUTH_TYPE=basic
# Microsoft OAuth 2.0 configuration for SMTP authentication
# Required when SMTP_AUTH_TYPE=oauth2 and using Microsoft Exchange/Outlook
# Setup: Create Azure AD app → Add Mail.Send + SMTP.Send permissions → Get Client ID/Secret
# For Exchange Online: SMTP_SERVER=smtp.office365.com, SMTP_PORT=587, SMTP_USE_TLS=true
MICROSOFT_OAUTH2_CLIENT_ID=
MICROSOFT_OAUTH2_CLIENT_SECRET=
MICROSOFT_OAUTH2_TENANT_ID=common
MICROSOFT_OAUTH2_ACCESS_TOKEN=
# Sendgid configuration
SENDGRID_API_KEY=
# Sentry configuration

View File

@@ -821,32 +821,6 @@ class MailConfig(BaseSettings):
default=False,
)
SMTP_AUTH_TYPE: str = Field(
description="SMTP authentication type ('basic' or 'oauth2')",
default="basic",
)
# Microsoft OAuth 2.0 configuration for SMTP
MICROSOFT_OAUTH2_CLIENT_ID: str | None = Field(
description="Microsoft OAuth 2.0 client ID for SMTP authentication",
default=None,
)
MICROSOFT_OAUTH2_CLIENT_SECRET: str | None = Field(
description="Microsoft OAuth 2.0 client secret for SMTP authentication",
default=None,
)
MICROSOFT_OAUTH2_TENANT_ID: str = Field(
description="Microsoft OAuth 2.0 tenant ID (use 'common' for multi-tenant)",
default="common",
)
MICROSOFT_OAUTH2_ACCESS_TOKEN: str | None = Field(
description="Microsoft OAuth 2.0 access token for SMTP authentication",
default=None,
)
EMAIL_SEND_IP_LIMIT_PER_MINUTE: PositiveInt = Field(
description="Maximum number of emails allowed to be sent from the same IP address in a minute",
default=50,

View File

@@ -142,7 +142,7 @@ class AppMCPServerRefreshController(Resource):
@login_required
@account_initialization_required
@marshal_with(app_server_fields)
def get(self, server_id):
def post(self, server_id):
if not current_user.is_editor:
raise NotFound()
server = (

View File

@@ -269,7 +269,7 @@ class MessageSuggestedQuestionApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def get(self, app_model, message_id):
def post(self, app_model, message_id):
message_id = str(message_id)
try:

View File

@@ -95,7 +95,7 @@ class LoginApi(Resource):
@console_ns.route("/logout")
class LogoutApi(Resource):
@setup_required
def get(self):
def post(self):
account = cast(Account, flask_login.current_user)
if isinstance(account, flask_login.AnonymousUserMixin):
return {"result": "success"}

View File

@@ -108,7 +108,7 @@ class MessageFeedbackApi(InstalledAppResource):
endpoint="installed_app_more_like_this",
)
class MessageMoreLikeThisApi(InstalledAppResource):
def get(self, installed_app, message_id):
def post(self, installed_app, message_id):
app_model = installed_app.app
if app_model.mode != "completion":
raise NotCompletionAppError()
@@ -117,7 +117,12 @@ class MessageMoreLikeThisApi(InstalledAppResource):
parser = reqparse.RequestParser()
parser.add_argument(
"response_mode", type=str, required=True, choices=["blocking", "streaming"], location="args"
"response_mode",
type=str,
required=False,
choices=["blocking", "streaming"],
default="blocking",
location="json",
)
args = parser.parse_args()
@@ -158,7 +163,7 @@ class MessageMoreLikeThisApi(InstalledAppResource):
endpoint="installed_app_suggested_question",
)
class MessageSuggestedQuestionApi(InstalledAppResource):
def get(self, installed_app, message_id):
def post(self, installed_app, message_id):
app_model = installed_app.app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:

View File

@@ -287,7 +287,7 @@ class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
def post(self):
if not isinstance(current_user, Account):
raise ValueError("Invalid user account")
account = current_user

View File

@@ -169,12 +169,6 @@ class MessageMoreLikeThisApi(WebApiResource):
@web_ns.doc(
params={
"message_id": {"description": "Message UUID", "type": "string", "required": True},
"response_mode": {
"description": "Response mode",
"type": "string",
"enum": ["blocking", "streaming"],
"required": True,
},
}
)
@web_ns.doc(
@@ -187,7 +181,7 @@ class MessageMoreLikeThisApi(WebApiResource):
500: "Internal Server Error",
}
)
def get(self, app_model, end_user, message_id):
def post(self, app_model, end_user, message_id):
if app_model.mode != "completion":
raise NotCompletionAppError()
@@ -195,7 +189,12 @@ class MessageMoreLikeThisApi(WebApiResource):
parser = reqparse.RequestParser()
parser.add_argument(
"response_mode", type=str, required=True, choices=["blocking", "streaming"], location="args"
"response_mode",
type=str,
required=False,
choices=["blocking", "streaming"],
default="blocking",
location="json",
)
args = parser.parse_args()
@@ -250,7 +249,7 @@ class MessageSuggestedQuestionApi(WebApiResource):
}
)
@marshal_with(suggested_questions_response_fields)
def get(self, app_model, end_user, message_id):
def post(self, app_model, end_user, message_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotCompletionAppError()

View File

@@ -16,7 +16,7 @@ class Mail:
def is_inited(self) -> bool:
return self._client is not None
def init_app(self, _: Flask):
def init_app(self, app: Flask):
mail_type = dify_config.MAIL_TYPE
if not mail_type:
logger.warning("MAIL_TYPE is not set")
@@ -40,36 +40,20 @@ class Mail:
resend.api_key = api_key
self._client = resend.Emails
case "smtp":
from libs.mail import SMTPClient
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")
# Validate OAuth 2.0 configuration if auth_type is oauth2
oauth_access_token = None
if dify_config.SMTP_AUTH_TYPE == "oauth2":
oauth_access_token = dify_config.MICROSOFT_OAUTH2_ACCESS_TOKEN
if not oauth_access_token:
# Try to get token using client credentials flow
if dify_config.MICROSOFT_OAUTH2_CLIENT_ID and dify_config.MICROSOFT_OAUTH2_CLIENT_SECRET:
oauth_access_token = self._get_oauth_token()
if not oauth_access_token:
raise ValueError("OAuth 2.0 access token is required for oauth2 auth_type")
self._client = SMTPClient(
server=dify_config.SMTP_SERVER,
port=dify_config.SMTP_PORT,
username=dify_config.SMTP_USERNAME or "",
password=dify_config.SMTP_PASSWORD or "",
from_addr=dify_config.MAIL_DEFAULT_SEND_FROM or "",
_from=dify_config.MAIL_DEFAULT_SEND_FROM or "",
use_tls=dify_config.SMTP_USE_TLS,
opportunistic_tls=dify_config.SMTP_OPPORTUNISTIC_TLS,
oauth_access_token=oauth_access_token,
auth_type=dify_config.SMTP_AUTH_TYPE,
)
case "sendgrid":
from libs.sendgrid import SendGridClient
@@ -83,33 +67,6 @@ class Mail:
case _:
raise ValueError(f"Unsupported mail type {mail_type}")
def _get_oauth_token(self) -> str | None:
"""Get OAuth access token using client credentials flow"""
try:
from libs.mail.oauth_email import MicrosoftEmailOAuth
client_id = dify_config.MICROSOFT_OAUTH2_CLIENT_ID
client_secret = dify_config.MICROSOFT_OAUTH2_CLIENT_SECRET
tenant_id = dify_config.MICROSOFT_OAUTH2_TENANT_ID or "common"
if not client_id or not client_secret:
return None
oauth_client = MicrosoftEmailOAuth(
client_id=client_id,
client_secret=client_secret,
redirect_uri="", # Not needed for client credentials flow
tenant_id=tenant_id,
)
token_response = oauth_client.get_access_token_client_credentials()
access_token = token_response.get("access_token")
return str(access_token) if access_token is not None else None
except Exception as e:
logging.warning("Failed to obtain OAuth 2.0 access token: %s", str(e))
return None
def send(self, to: str, subject: str, html: str, from_: str | None = None):
if not self._client:
raise ValueError("Mail client is not initialized")

View File

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

View File

@@ -1,26 +0,0 @@
"""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

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

View File

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

View File

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

View File

@@ -1,79 +0,0 @@
"""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)

59
api/libs/smtp.py Normal file
View File

@@ -0,0 +1,59 @@
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()

View File

@@ -0,0 +1,60 @@
import inspect
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from controllers.console.app import message as console_message_module
from controllers.console.app.message import MessageSuggestedQuestionApi
from core.app.entities.app_invoke_entities import InvokeFrom
from models.account import Account
@pytest.fixture
def flask_app():
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def account_user():
user = Account(name="Tester", email="tester@example.com")
user.id = "user-id"
return user
class TestConsoleAppMessageSuggestedQuestionApi:
def test_post_forwards_to_service(self, flask_app, account_user, monkeypatch):
app_model = SimpleNamespace(id="app-id", mode="chat")
questions = ["a", "b"]
service_mock = MagicMock(return_value=questions)
monkeypatch.setattr(console_message_module, "current_user", account_user, raising=False)
monkeypatch.setattr(
console_message_module.MessageService,
"get_suggested_questions_after_answer",
service_mock,
raising=False,
)
handler = inspect.unwrap(MessageSuggestedQuestionApi.post)
controller = MessageSuggestedQuestionApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/apps/{app_model.id}/chat-messages/{message_id}/suggested-questions",
method="POST",
json={},
):
result = handler(controller, app_model, message_id)
assert result == {"data": questions}
service_mock.assert_called_once_with(
app_model=app_model,
message_id=str(message_id),
user=account_user,
invoke_from=InvokeFrom.DEBUGGER,
)

View File

@@ -0,0 +1,92 @@
import inspect
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.exceptions import NotFound
from controllers.console.app.mcp_server import AppMCPServerRefreshController
from models.account import AccountStatus
from models.model import AppMCPServer
@pytest.fixture(autouse=True)
def configure_decorators(monkeypatch):
monkeypatch.setattr("libs.login.dify_config.LOGIN_DISABLED", True, raising=False)
monkeypatch.setattr("controllers.console.wraps.dify_config.EDITION", "CLOUD", raising=False)
@pytest.fixture
def mock_current_user(monkeypatch):
user = SimpleNamespace(
is_editor=True,
status=AccountStatus.ACTIVE,
current_tenant_id="tenant-id",
is_authenticated=True,
)
from controllers.console.app import mcp_server as mcp_module
monkeypatch.setattr(mcp_module, "current_user", user, raising=False)
monkeypatch.setattr("controllers.console.wraps.current_user", user, raising=False)
return user
@pytest.fixture
def mock_db_session(monkeypatch):
mock_session = MagicMock()
mock_db = SimpleNamespace(session=mock_session)
from controllers.console.app import mcp_server as mcp_module
monkeypatch.setattr(mcp_module, "db", mock_db, raising=False)
return mock_session
@pytest.fixture
def flask_app():
app = Flask(__name__)
app.config["TESTING"] = True
return app
class TestAppMCPServerRefreshController:
def test_refresh_regenerates_server_code(self, flask_app, mock_current_user, mock_db_session, monkeypatch):
server = MagicMock(spec=AppMCPServer)
server.server_code = "old"
server_query = MagicMock()
server_query.where.return_value = server_query
server_query.first.return_value = server
mock_db_session.query.return_value = server_query
mock_db_session.commit = MagicMock()
monkeypatch.setattr(
"models.model.AppMCPServer.generate_server_code", MagicMock(return_value="new"), raising=False
)
controller = AppMCPServerRefreshController()
refresh_handler = inspect.unwrap(AppMCPServerRefreshController.post)
with flask_app.test_request_context("/apps/{}/server/refresh".format("app"), method="POST"):
result = refresh_handler(controller, "server-id")
assert result is server
assert server.server_code == "new"
mock_db_session.commit.assert_called_once_with()
mock_db_session.query.assert_called_once()
def test_refresh_requires_editor(self, flask_app, mock_current_user, mock_db_session, monkeypatch):
mock_current_user.is_editor = False
mock_db_session.query.return_value = MagicMock()
mock_db_session.commit = MagicMock()
controller = AppMCPServerRefreshController()
refresh_handler = inspect.unwrap(AppMCPServerRefreshController.post)
with flask_app.test_request_context("/apps/{}/server/refresh".format("app"), method="POST"):
with pytest.raises(NotFound):
refresh_handler(controller, "server-id")
mock_db_session.commit.assert_not_called()

View File

@@ -0,0 +1,84 @@
import inspect
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from controllers.console.explore.error import NotChatAppError
from controllers.console.explore.message import MessageSuggestedQuestionApi
from core.app.entities.app_invoke_entities import InvokeFrom
from models.account import Account
from models.model import AppMode
@pytest.fixture
def flask_app():
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def account_user():
user = Account(name="Tester", email="tester@example.com")
user.id = "user-id"
return user
class TestConsoleExploreMessageSuggestedQuestionApi:
def test_post_returns_questions(self, flask_app, account_user, monkeypatch):
installed_app = SimpleNamespace(app=SimpleNamespace(mode=AppMode.CHAT.value))
questions = ["q1"]
service_mock = MagicMock(return_value=questions)
monkeypatch.setattr(
"controllers.console.explore.message.current_user",
account_user,
raising=False,
)
monkeypatch.setattr(
"controllers.console.explore.message.MessageService.get_suggested_questions_after_answer",
service_mock,
raising=False,
)
handler = inspect.unwrap(MessageSuggestedQuestionApi.post)
controller = MessageSuggestedQuestionApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/suggested-questions",
method="POST",
json={},
):
result = handler(controller, installed_app, message_id)
assert result == {"data": questions}
service_mock.assert_called_once_with(
app_model=installed_app.app,
user=account_user,
message_id=str(message_id),
invoke_from=InvokeFrom.EXPLORE,
)
def test_non_chat_app_raises(self, flask_app, account_user, monkeypatch):
installed_app = SimpleNamespace(app=SimpleNamespace(mode=AppMode.COMPLETION.value))
monkeypatch.setattr(
"controllers.console.explore.message.current_user",
account_user,
raising=False,
)
handler = inspect.unwrap(MessageSuggestedQuestionApi.post)
controller = MessageSuggestedQuestionApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/suggested-questions",
method="POST",
json={},
):
with pytest.raises(NotChatAppError):
handler(controller, installed_app, message_id)

View File

@@ -0,0 +1,124 @@
import inspect
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from controllers.console.explore.error import NotCompletionAppError
from controllers.console.explore.message import MessageMoreLikeThisApi
from core.app.entities.app_invoke_entities import InvokeFrom
from models.account import Account
@pytest.fixture
def flask_app():
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def account_user():
user = Account(name="Tester", email="tester@example.com")
user.id = "user-id"
return user
class TestConsoleExploreMessageMoreLikeThisApi:
def test_post_generates_with_blocking_default(self, flask_app, account_user, monkeypatch):
installed_app = SimpleNamespace(app=SimpleNamespace(mode="completion"))
response_payload = {"answer": "ok"}
generate_mock = MagicMock(return_value=object())
compact_mock = MagicMock(return_value=response_payload)
monkeypatch.setattr(
"controllers.console.explore.message.current_user",
account_user,
raising=False,
)
monkeypatch.setattr(
"controllers.console.explore.message.AppGenerateService.generate_more_like_this",
generate_mock,
raising=False,
)
monkeypatch.setattr(
"controllers.console.explore.message.helper.compact_generate_response",
compact_mock,
raising=False,
)
handler = inspect.unwrap(MessageMoreLikeThisApi.post)
controller = MessageMoreLikeThisApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/more-like-this",
method="POST",
json={},
):
result = handler(controller, installed_app, message_id)
assert result == response_payload
generate_mock.assert_called_once()
call_kwargs = generate_mock.call_args.kwargs
assert call_kwargs["streaming"] is False
assert call_kwargs["invoke_from"] == InvokeFrom.EXPLORE
assert call_kwargs["message_id"] == str(message_id)
compact_mock.assert_called_once_with(generate_mock.return_value)
def test_post_allows_streaming_mode(self, flask_app, account_user, monkeypatch):
installed_app = SimpleNamespace(app=SimpleNamespace(mode="completion"))
generate_mock = MagicMock(return_value=object())
monkeypatch.setattr(
"controllers.console.explore.message.current_user",
account_user,
raising=False,
)
monkeypatch.setattr(
"controllers.console.explore.message.AppGenerateService.generate_more_like_this",
generate_mock,
raising=False,
)
monkeypatch.setattr(
"controllers.console.explore.message.helper.compact_generate_response",
MagicMock(return_value={}),
raising=False,
)
handler = inspect.unwrap(MessageMoreLikeThisApi.post)
controller = MessageMoreLikeThisApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/more-like-this",
method="POST",
json={"response_mode": "streaming"},
):
handler(controller, installed_app, message_id)
generate_mock.assert_called_once()
assert generate_mock.call_args.kwargs["streaming"] is True
def test_non_completion_app_raises(self, flask_app, account_user, monkeypatch):
installed_app = SimpleNamespace(app=SimpleNamespace(mode="chat"))
monkeypatch.setattr(
"controllers.console.explore.message.current_user",
account_user,
raising=False,
)
handler = inspect.unwrap(MessageMoreLikeThisApi.post)
controller = MessageMoreLikeThisApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/more-like-this",
method="POST",
json={},
):
with pytest.raises(NotCompletionAppError):
handler(controller, installed_app, message_id)

View File

@@ -0,0 +1,63 @@
import inspect
from unittest.mock import MagicMock
import pytest
from flask import Flask
from controllers.console.workspace import account as account_module
from controllers.console.workspace.account import AccountDeleteVerifyApi
from models.account import Account
@pytest.fixture
def flask_app():
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def account_user():
user = Account(name="Tester", email="tester@example.com")
user.id = "user-id"
return user
class TestAccountDeleteVerifyApi:
def test_post_generates_token_and_sends_email(self, flask_app, account_user, monkeypatch):
generate_mock = MagicMock(return_value=("token", "code"))
send_mock = MagicMock()
monkeypatch.setattr(account_module, "current_user", account_user, raising=False)
monkeypatch.setattr(
account_module.AccountService,
"generate_account_deletion_verification_code",
generate_mock,
raising=False,
)
monkeypatch.setattr(
account_module.AccountService,
"send_account_deletion_verification_email",
send_mock,
raising=False,
)
controller = AccountDeleteVerifyApi()
handler = inspect.unwrap(AccountDeleteVerifyApi.post)
with flask_app.test_request_context("/account/delete/verify", method="POST", json={}):
response = handler(controller)
assert response == {"result": "success", "data": "token"}
generate_mock.assert_called_once_with(account_user)
send_mock.assert_called_once_with(account_user, "code")
def test_post_requires_account_user(self, flask_app, monkeypatch):
monkeypatch.setattr(account_module, "current_user", object(), raising=False)
controller = AccountDeleteVerifyApi()
handler = inspect.unwrap(AccountDeleteVerifyApi.post)
with flask_app.test_request_context("/account/delete/verify", method="POST", json={}):
with pytest.raises(ValueError):
handler(controller)

View File

@@ -0,0 +1,103 @@
import inspect
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from controllers.web.error import NotCompletionAppError
from controllers.web.message import MessageMoreLikeThisApi
from core.app.entities.app_invoke_entities import InvokeFrom
@pytest.fixture
def flask_app():
app = Flask(__name__)
app.config["TESTING"] = True
return app
class TestWebMessageMoreLikeThisApi:
def test_post_uses_blocking_by_default(self, flask_app, monkeypatch):
app_model = SimpleNamespace(mode="completion")
end_user = SimpleNamespace()
response_payload = {"answer": "ok"}
generate_mock = MagicMock(return_value=object())
compact_mock = MagicMock(return_value=response_payload)
monkeypatch.setattr(
"controllers.web.message.AppGenerateService.generate_more_like_this",
generate_mock,
raising=False,
)
monkeypatch.setattr(
"controllers.web.message.helper.compact_generate_response",
compact_mock,
raising=False,
)
handler = inspect.unwrap(MessageMoreLikeThisApi.post)
controller = MessageMoreLikeThisApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/more-like-this",
method="POST",
json={},
):
result = handler(controller, app_model, end_user, message_id)
assert result == response_payload
generate_mock.assert_called_once()
call_kwargs = generate_mock.call_args.kwargs
assert call_kwargs["streaming"] is False
assert call_kwargs["invoke_from"] == InvokeFrom.WEB_APP
assert call_kwargs["message_id"] == str(message_id)
compact_mock.assert_called_once_with(generate_mock.return_value)
def test_post_allows_streaming_mode(self, flask_app, monkeypatch):
app_model = SimpleNamespace(mode="completion")
end_user = SimpleNamespace()
generate_mock = MagicMock(return_value=object())
monkeypatch.setattr(
"controllers.web.message.AppGenerateService.generate_more_like_this",
generate_mock,
raising=False,
)
monkeypatch.setattr(
"controllers.web.message.helper.compact_generate_response",
MagicMock(return_value={}),
raising=False,
)
handler = inspect.unwrap(MessageMoreLikeThisApi.post)
controller = MessageMoreLikeThisApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/more-like-this",
method="POST",
json={"response_mode": "streaming"},
):
handler(controller, app_model, end_user, message_id)
generate_mock.assert_called_once()
assert generate_mock.call_args.kwargs["streaming"] is True
def test_non_completion_app_raises(self, flask_app):
app_model = SimpleNamespace(mode="chat")
end_user = SimpleNamespace()
handler = inspect.unwrap(MessageMoreLikeThisApi.post)
controller = MessageMoreLikeThisApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/more-like-this",
method="POST",
json={},
):
with pytest.raises(NotCompletionAppError):
handler(controller, app_model, end_user, message_id)

View File

@@ -0,0 +1,67 @@
import inspect
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from controllers.web.error import NotCompletionAppError
from controllers.web.message import MessageSuggestedQuestionApi
from core.app.entities.app_invoke_entities import InvokeFrom
from models.model import AppMode
@pytest.fixture
def flask_app():
app = Flask(__name__)
app.config["TESTING"] = True
return app
class TestWebMessageSuggestedQuestionApi:
def test_post_returns_questions(self, flask_app, monkeypatch):
app_model = SimpleNamespace(mode=AppMode.CHAT.value)
end_user = SimpleNamespace()
questions = ["Q1", "Q2"]
service_mock = MagicMock(return_value=questions)
monkeypatch.setattr(
"controllers.web.message.MessageService.get_suggested_questions_after_answer",
service_mock,
raising=False,
)
handler = inspect.unwrap(MessageSuggestedQuestionApi.post)
controller = MessageSuggestedQuestionApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/suggested-questions",
method="POST",
json={},
):
result = handler(controller, app_model, end_user, message_id)
assert result == {"data": questions}
service_mock.assert_called_once_with(
app_model=app_model,
user=end_user,
message_id=str(message_id),
invoke_from=InvokeFrom.WEB_APP,
)
def test_non_chat_app_raises(self, flask_app):
app_model = SimpleNamespace(mode=AppMode.COMPLETION.value)
end_user = SimpleNamespace()
handler = inspect.unwrap(MessageSuggestedQuestionApi.post)
controller = MessageSuggestedQuestionApi()
message_id = uuid.uuid4()
with flask_app.test_request_context(
f"/messages/{message_id}/suggested-questions",
method="POST",
json={},
):
with pytest.raises(NotCompletionAppError):
handler(controller, app_model, end_user, message_id)

View File

@@ -1,375 +0,0 @@
"""Comprehensive tests for email OAuth implementation"""
import base64
from typing import Union
import pytest
from libs.mail.oauth_email import MicrosoftEmailOAuth, OAuthUserInfo
from libs.mail.oauth_http_client import OAuthHTTPClientProtocol
class MockHTTPClient(OAuthHTTPClientProtocol):
"""Mock HTTP client for testing OAuth without real network calls"""
def __init__(self):
self.post_responses = []
self.get_responses = []
self.post_calls = []
self.get_calls = []
self.post_index = 0
self.get_index = 0
def add_post_response(self, status_code: int, json_data: dict[str, Union[str, int]]):
"""Add a mocked POST response"""
self.post_responses.append(
{
"status_code": status_code,
"json": json_data,
"text": str(json_data),
"headers": {"content-type": "application/json"},
}
)
def add_get_response(self, json_data: dict[str, Union[str, int, dict, list]]):
"""Add a mocked GET response"""
self.get_responses.append(json_data)
def post(
self, url: str, data: dict[str, Union[str, int]], headers: dict[str, str] | None = None
) -> dict[str, Union[str, int, dict, list]]:
"""Mock POST request"""
self.post_calls.append({"url": url, "data": data, "headers": headers})
if self.post_index < len(self.post_responses):
response = self.post_responses[self.post_index]
self.post_index += 1
return response
# Default error response
return {
"status_code": 500,
"json": {"error": "No mock response configured"},
"text": "No mock response configured",
"headers": {},
}
def get(self, url: str, headers: dict[str, str] | None = None) -> dict[str, Union[str, int, dict, list]]:
"""Mock GET request"""
self.get_calls.append({"url": url, "headers": headers})
if self.get_index < len(self.get_responses):
response = self.get_responses[self.get_index]
self.get_index += 1
return response
# Default error response
raise Exception("No mock response configured")
class TestMicrosoftEmailOAuth:
"""Test cases for MicrosoftEmailOAuth"""
@pytest.fixture
def mock_http_client(self):
"""Create a mock HTTP client"""
return MockHTTPClient()
@pytest.fixture
def oauth_client(self, mock_http_client):
"""Create OAuth client with mock HTTP client"""
return MicrosoftEmailOAuth(
client_id="test-client-id",
client_secret="test-client-secret",
redirect_uri="https://example.com/callback",
tenant_id="test-tenant",
http_client=mock_http_client,
)
def test_get_authorization_url(self, oauth_client):
"""Test authorization URL generation"""
url = oauth_client.get_authorization_url()
assert "login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize" in url
assert "client_id=test-client-id" in url
assert "response_type=code" in url
assert "redirect_uri=https%3A%2F%2Fexample.com%2Fcallback" in url
assert "scope=https%3A%2F%2Foutlook.office.com%2FSMTP.Send+offline_access" in url
assert "response_mode=query" in url
def test_get_authorization_url_with_state(self, oauth_client):
"""Test authorization URL with state parameter"""
url = oauth_client.get_authorization_url(invite_token="test-state-123")
assert "state=test-state-123" in url
def test_get_access_token_success(self, oauth_client, mock_http_client):
"""Test successful access token retrieval"""
# Setup mock response
mock_http_client.add_post_response(
200,
{
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "test-refresh-token",
},
)
result = oauth_client.get_access_token("test-auth-code")
# Verify result
assert result["access_token"] == "test-access-token"
assert result["token_type"] == "Bearer"
assert result["expires_in"] == 3600
assert result["refresh_token"] == "test-refresh-token"
# Verify HTTP call
assert len(mock_http_client.post_calls) == 1
call = mock_http_client.post_calls[0]
assert "login.microsoftonline.com/test-tenant/oauth2/v2.0/token" in call["url"]
assert call["data"]["grant_type"] == "authorization_code"
assert call["data"]["code"] == "test-auth-code"
assert call["data"]["client_id"] == "test-client-id"
assert call["data"]["client_secret"] == "test-client-secret"
def test_get_access_token_failure(self, oauth_client, mock_http_client):
"""Test access token retrieval failure"""
# Setup mock error response
mock_http_client.add_post_response(
400, {"error": "invalid_grant", "error_description": "The authorization code is invalid"}
)
with pytest.raises(ValueError, match="Error in Microsoft OAuth"):
oauth_client.get_access_token("bad-auth-code")
def test_get_access_token_client_credentials_success(self, oauth_client, mock_http_client):
"""Test successful client credentials flow"""
# Setup mock response
mock_http_client.add_post_response(
200, {"access_token": "service-access-token", "token_type": "Bearer", "expires_in": 3600}
)
result = oauth_client.get_access_token_client_credentials()
# Verify result
assert result["access_token"] == "service-access-token"
assert result["token_type"] == "Bearer"
# Verify HTTP call
call = mock_http_client.post_calls[0]
assert call["data"]["grant_type"] == "client_credentials"
assert call["data"]["scope"] == "https://outlook.office365.com/.default"
def test_get_access_token_client_credentials_custom_scope(self, oauth_client, mock_http_client):
"""Test client credentials with custom scope"""
mock_http_client.add_post_response(200, {"access_token": "custom-scope-token", "token_type": "Bearer"})
result = oauth_client.get_access_token_client_credentials(scope="https://graph.microsoft.com/.default")
assert result["access_token"] == "custom-scope-token"
# Verify custom scope was used
call = mock_http_client.post_calls[0]
assert call["data"]["scope"] == "https://graph.microsoft.com/.default"
def test_refresh_access_token_success(self, oauth_client, mock_http_client):
"""Test successful token refresh"""
# Setup mock response
mock_http_client.add_post_response(
200,
{
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
"token_type": "Bearer",
"expires_in": 3600,
},
)
result = oauth_client.refresh_access_token("old-refresh-token")
# Verify result
assert result["access_token"] == "new-access-token"
assert result["refresh_token"] == "new-refresh-token"
# Verify HTTP call
call = mock_http_client.post_calls[0]
assert call["data"]["grant_type"] == "refresh_token"
assert call["data"]["refresh_token"] == "old-refresh-token"
def test_refresh_access_token_failure(self, oauth_client, mock_http_client):
"""Test token refresh failure"""
# Setup mock error response
mock_http_client.add_post_response(
400, {"error": "invalid_grant", "error_description": "The refresh token has expired"}
)
with pytest.raises(ValueError, match="Error refreshing Microsoft OAuth token"):
oauth_client.refresh_access_token("expired-refresh-token")
def test_get_raw_user_info(self, oauth_client, mock_http_client):
"""Test getting user info from Microsoft Graph"""
# Setup mock response
mock_http_client.add_get_response(
{
"id": "12345",
"displayName": "Test User",
"mail": "test@contoso.com",
"userPrincipalName": "test@contoso.com",
}
)
result = oauth_client.get_raw_user_info("test-access-token")
# Verify result
assert result["id"] == "12345"
assert result["displayName"] == "Test User"
assert result["mail"] == "test@contoso.com"
# Verify HTTP call
call = mock_http_client.get_calls[0]
assert call["url"] == "https://graph.microsoft.com/v1.0/me"
assert call["headers"]["Authorization"] == "Bearer test-access-token"
def test_get_user_info_complete_flow(self, oauth_client, mock_http_client):
"""Test complete user info retrieval flow"""
# Setup mock response
mock_http_client.add_get_response(
{
"id": "67890",
"displayName": "John Doe",
"mail": "john.doe@contoso.com",
"userPrincipalName": "john.doe@contoso.com",
}
)
user_info = oauth_client.get_user_info("test-access-token")
# Verify transformed user info
assert isinstance(user_info, OAuthUserInfo)
assert user_info.id == "67890"
assert user_info.name == "John Doe"
assert user_info.email == "john.doe@contoso.com"
def test_transform_user_info_with_missing_mail(self, oauth_client):
"""Test user info transformation when mail field is missing"""
raw_info = {"id": "99999", "displayName": "No Mail User", "userPrincipalName": "nomail@contoso.com"}
user_info = oauth_client._transform_user_info(raw_info)
# Should fall back to userPrincipalName
assert user_info.email == "nomail@contoso.com"
def test_transform_user_info_with_no_display_name(self, oauth_client):
"""Test user info transformation when displayName is missing"""
raw_info = {"id": "11111", "mail": "anonymous@contoso.com", "userPrincipalName": "anonymous@contoso.com"}
user_info = oauth_client._transform_user_info(raw_info)
# Should have empty name
assert user_info.name == ""
assert user_info.email == "anonymous@contoso.com"
def test_create_sasl_xoauth2_string(self):
"""Test static SASL XOAUTH2 string creation"""
username = "test@contoso.com"
access_token = "test-token-456"
result = MicrosoftEmailOAuth.create_sasl_xoauth2_string(username, access_token)
# Decode and verify format
decoded = base64.b64decode(result).decode()
expected = f"user={username}\x01auth=Bearer {access_token}\x01\x01"
assert decoded == expected
def test_error_handling_with_non_json_response(self, oauth_client, mock_http_client):
"""Test handling of non-JSON error responses"""
# Setup mock HTML error response
mock_http_client.post_responses.append(
{
"status_code": 500,
"json": {},
"text": "<html>Internal Server Error</html>",
"headers": {"content-type": "text/html"},
}
)
with pytest.raises(ValueError, match="Error in Microsoft OAuth"):
oauth_client.get_access_token("test-code")
class TestOAuthIntegration:
"""Integration tests for OAuth with SMTP"""
def test_oauth_token_flow_for_smtp(self):
"""Test complete OAuth token flow for SMTP usage"""
# Create mock HTTP client
mock_http = MockHTTPClient()
# Setup mock responses for complete flow
mock_http.add_post_response(
200,
{
"access_token": "smtp-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "smtp-refresh-token",
"scope": "https://outlook.office.com/SMTP.Send offline_access",
},
)
# Create OAuth client
oauth_client = MicrosoftEmailOAuth(
client_id="smtp-client-id",
client_secret="smtp-client-secret",
redirect_uri="https://app.example.com/oauth/callback",
tenant_id="contoso.onmicrosoft.com",
http_client=mock_http,
)
# Get authorization URL
auth_url = oauth_client.get_authorization_url()
assert "scope=https%3A%2F%2Foutlook.office.com%2FSMTP.Send+offline_access" in auth_url
# Exchange code for token
token_response = oauth_client.get_access_token("auth-code-from-user")
assert token_response["access_token"] == "smtp-access-token"
# Create SASL string for SMTP
access_token = str(token_response["access_token"])
sasl_string = MicrosoftEmailOAuth.create_sasl_xoauth2_string("user@contoso.com", access_token)
# Verify SASL string is valid base64
try:
decoded = base64.b64decode(sasl_string)
assert b"user=user@contoso.com" in decoded
assert b"auth=Bearer smtp-access-token" in decoded
except Exception:
pytest.fail("SASL string is not valid base64")
def test_service_account_flow(self):
"""Test service account (client credentials) flow"""
mock_http = MockHTTPClient()
# Setup mock response for client credentials
mock_http.add_post_response(
200, {"access_token": "service-smtp-token", "token_type": "Bearer", "expires_in": 3600}
)
oauth_client = MicrosoftEmailOAuth(
client_id="service-client-id",
client_secret="service-client-secret",
redirect_uri="", # Not needed for service accounts
tenant_id="contoso.onmicrosoft.com",
http_client=mock_http,
)
# Get token using client credentials
token_response = oauth_client.get_access_token_client_credentials()
assert token_response["access_token"] == "service-smtp-token"
# Verify the request used correct grant type
call = mock_http.post_calls[0]
assert call["data"]["grant_type"] == "client_credentials"
assert "redirect_uri" not in call["data"] # Should not include redirect_uri

View File

@@ -1,368 +0,0 @@
"""Comprehensive tests for SMTP implementation with OAuth 2.0 support"""
import base64
import smtplib
from unittest.mock import MagicMock, Mock
import pytest
from libs.mail.smtp import SMTPAuthenticator, SMTPClient, SMTPMessageBuilder
from libs.mail.smtp_connection import SMTPConnectionFactory, SMTPConnectionProtocol
class MockSMTPConnection:
"""Mock SMTP connection for testing"""
def __init__(self):
self.ehlo_called = 0
self.starttls_called = False
self.login_called = False
self.docmd_called = False
self.sendmail_called = False
self.quit_called = False
self.last_docmd_args = None
self.last_login_args = None
self.last_sendmail_args = None
def ehlo(self, name: str = "") -> tuple:
self.ehlo_called += 1
return (250, b"OK")
def starttls(self) -> tuple:
self.starttls_called = True
return (220, b"TLS started")
def login(self, user: str, password: str) -> tuple:
self.login_called = True
self.last_login_args = (user, password)
return (235, b"Authentication successful")
def docmd(self, cmd: str, args: str = "") -> tuple:
self.docmd_called = True
self.last_docmd_args = (cmd, args)
return (235, b"Authentication successful")
def sendmail(self, from_addr: str, to_addrs: str, msg: str) -> dict:
self.sendmail_called = True
self.last_sendmail_args = (from_addr, to_addrs, msg)
return {}
def quit(self) -> tuple:
self.quit_called = True
return (221, b"Bye")
class MockSMTPConnectionFactory(SMTPConnectionFactory):
"""Mock factory for creating mock SMTP connections"""
def __init__(self, connection: MockSMTPConnection):
self.connection = connection
self.create_called = False
def create_connection(self, server: str, port: int, timeout: int = 10) -> SMTPConnectionProtocol:
self.create_called = True
self.last_create_args = (server, port, timeout)
return self.connection
class TestSMTPAuthenticator:
"""Test cases for SMTPAuthenticator"""
def test_create_sasl_xoauth2_string(self):
"""Test SASL XOAUTH2 string creation"""
authenticator = SMTPAuthenticator()
username = "test@example.com"
access_token = "test_token_123"
result = authenticator.create_sasl_xoauth2_string(username, access_token)
# Decode and verify
decoded = base64.b64decode(result).decode()
expected = f"user={username}\x01auth=Bearer {access_token}\x01\x01"
assert decoded == expected
def test_authenticate_basic_with_valid_credentials(self):
"""Test basic authentication with valid credentials"""
authenticator = SMTPAuthenticator()
connection = MockSMTPConnection()
authenticator.authenticate_basic(connection, "user@example.com", "password123")
assert connection.login_called
assert connection.last_login_args == ("user@example.com", "password123")
def test_authenticate_basic_with_empty_credentials(self):
"""Test basic authentication skips with empty credentials"""
authenticator = SMTPAuthenticator()
connection = MockSMTPConnection()
authenticator.authenticate_basic(connection, "", "")
assert not connection.login_called
def test_authenticate_oauth2_success(self):
"""Test successful OAuth2 authentication"""
authenticator = SMTPAuthenticator()
connection = MockSMTPConnection()
authenticator.authenticate_oauth2(connection, "user@example.com", "oauth_token_123")
assert connection.docmd_called
assert connection.last_docmd_args[0] == "AUTH"
assert connection.last_docmd_args[1].startswith("XOAUTH2 ")
# Verify the auth string
auth_string = connection.last_docmd_args[1].split(" ")[1]
decoded = base64.b64decode(auth_string).decode()
assert "user=user@example.com" in decoded
assert "auth=Bearer oauth_token_123" in decoded
def test_authenticate_oauth2_missing_credentials(self):
"""Test OAuth2 authentication fails with missing credentials"""
authenticator = SMTPAuthenticator()
connection = MockSMTPConnection()
with pytest.raises(ValueError, match="Username and OAuth access token are required"):
authenticator.authenticate_oauth2(connection, "", "token")
with pytest.raises(ValueError, match="Username and OAuth access token are required"):
authenticator.authenticate_oauth2(connection, "user", "")
def test_authenticate_oauth2_auth_failure(self):
"""Test OAuth2 authentication handles auth errors"""
authenticator = SMTPAuthenticator()
connection = Mock()
connection.docmd.side_effect = smtplib.SMTPAuthenticationError(535, b"Authentication failed")
with pytest.raises(ValueError, match="OAuth2 authentication failed"):
authenticator.authenticate_oauth2(connection, "user@example.com", "bad_token")
class TestSMTPMessageBuilder:
"""Test cases for SMTPMessageBuilder"""
def test_build_message(self):
"""Test message building"""
builder = SMTPMessageBuilder()
mail_data = {"to": "recipient@example.com", "subject": "Test Subject", "html": "<p>Test HTML content</p>"}
from_addr = "sender@example.com"
msg = builder.build_message(mail_data, from_addr)
assert msg["To"] == "recipient@example.com"
assert msg["From"] == "sender@example.com"
assert msg["Subject"] == "Test Subject"
assert "<p>Test HTML content</p>" in msg.as_string()
class TestSMTPClient:
"""Test cases for SMTPClient"""
@pytest.fixture
def mock_connection(self):
"""Create a mock SMTP connection"""
return MockSMTPConnection()
@pytest.fixture
def mock_factories(self, mock_connection):
"""Create mock connection factories"""
return {
"connection_factory": MockSMTPConnectionFactory(mock_connection),
"ssl_connection_factory": MockSMTPConnectionFactory(mock_connection),
}
def test_basic_auth_send_success(self, mock_connection, mock_factories):
"""Test successful email send with basic auth"""
client = SMTPClient(
server="smtp.example.com",
port=587,
username="user@example.com",
password="password123",
from_addr="sender@example.com",
use_tls=True,
opportunistic_tls=True,
auth_type="basic",
**mock_factories,
)
mail_data = {"to": "recipient@example.com", "subject": "Test Subject", "html": "<p>Test content</p>"}
client.send(mail_data)
# Verify connection sequence
assert mock_connection.ehlo_called == 2 # Before and after STARTTLS
assert mock_connection.starttls_called
assert mock_connection.login_called
assert mock_connection.last_login_args == ("user@example.com", "password123")
assert mock_connection.sendmail_called
assert mock_connection.quit_called
def test_oauth2_send_success(self, mock_connection, mock_factories):
"""Test successful email send with OAuth2"""
client = SMTPClient(
server="smtp.office365.com",
port=587,
username="user@contoso.com",
password="",
from_addr="sender@contoso.com",
use_tls=True,
opportunistic_tls=True,
oauth_access_token="oauth_token_123",
auth_type="oauth2",
**mock_factories,
)
mail_data = {"to": "recipient@example.com", "subject": "OAuth Test", "html": "<p>OAuth test content</p>"}
client.send(mail_data)
# Verify OAuth authentication was used
assert mock_connection.docmd_called
assert not mock_connection.login_called
assert mock_connection.sendmail_called
assert mock_connection.quit_called
def test_ssl_connection_used_when_configured(self, mock_connection):
"""Test SSL connection is used when configured"""
ssl_factory = MockSMTPConnectionFactory(mock_connection)
regular_factory = MockSMTPConnectionFactory(mock_connection)
client = SMTPClient(
server="smtp.example.com",
port=465,
username="user@example.com",
password="password123",
from_addr="sender@example.com",
use_tls=True,
opportunistic_tls=False, # Use SSL, not STARTTLS
connection_factory=regular_factory,
ssl_connection_factory=ssl_factory,
)
mail_data = {"to": "recipient@example.com", "subject": "SSL Test", "html": "<p>SSL test content</p>"}
client.send(mail_data)
# Verify SSL factory was used
assert ssl_factory.create_called
assert not regular_factory.create_called
# No STARTTLS with SSL connection
assert not mock_connection.starttls_called
def test_connection_cleanup_on_error(self, mock_connection, mock_factories):
"""Test connection is cleaned up even on error"""
# Make sendmail fail
mock_connection.sendmail = Mock(side_effect=smtplib.SMTPException("Send failed"))
client = SMTPClient(
server="smtp.example.com",
port=587,
username="user@example.com",
password="password123",
from_addr="sender@example.com",
**mock_factories,
)
mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "<p>Test</p>"}
with pytest.raises(smtplib.SMTPException):
client.send(mail_data)
# Verify quit was still called
assert mock_connection.quit_called
def test_custom_authenticator_injection(self, mock_connection, mock_factories):
"""Test custom authenticator can be injected"""
custom_authenticator = Mock(spec=SMTPAuthenticator)
client = SMTPClient(
server="smtp.example.com",
port=587,
username="user@example.com",
password="password123",
from_addr="sender@example.com",
authenticator=custom_authenticator,
**mock_factories,
)
mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "<p>Test</p>"}
client.send(mail_data)
# Verify custom authenticator was used
custom_authenticator.authenticate_basic.assert_called_once()
def test_custom_message_builder_injection(self, mock_connection, mock_factories):
"""Test custom message builder can be injected"""
custom_builder = Mock(spec=SMTPMessageBuilder)
custom_msg = MagicMock()
custom_msg.as_string.return_value = "custom message"
custom_builder.build_message.return_value = custom_msg
client = SMTPClient(
server="smtp.example.com",
port=587,
username="user@example.com",
password="password123",
from_addr="sender@example.com",
message_builder=custom_builder,
**mock_factories,
)
mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "<p>Test</p>"}
client.send(mail_data)
# Verify custom builder was used
custom_builder.build_message.assert_called_once_with(mail_data, "sender@example.com")
assert mock_connection.last_sendmail_args[2] == "custom message"
class TestIntegration:
"""Integration tests showing how components work together"""
def test_complete_oauth_flow_without_io(self):
"""Test complete OAuth flow without any real I/O"""
# Create all mocks
mock_connection = MockSMTPConnection()
connection_factory = MockSMTPConnectionFactory(mock_connection)
# Create client with OAuth
client = SMTPClient(
server="smtp.office365.com",
port=587,
username="test@contoso.com",
password="",
from_addr="test@contoso.com",
use_tls=True,
opportunistic_tls=True,
oauth_access_token="mock_oauth_token",
auth_type="oauth2",
connection_factory=connection_factory,
ssl_connection_factory=connection_factory,
)
# Send email
mail_data = {
"to": "recipient@example.com",
"subject": "OAuth Integration Test",
"html": "<h1>Hello OAuth!</h1>",
}
client.send(mail_data)
# Verify complete flow
assert connection_factory.create_called
assert mock_connection.ehlo_called == 2
assert mock_connection.starttls_called
assert mock_connection.docmd_called
assert "XOAUTH2" in mock_connection.last_docmd_args[1]
assert mock_connection.sendmail_called
assert mock_connection.quit_called
# Verify email data
from_addr, to_addr, msg_str = mock_connection.last_sendmail_args
assert from_addr == "test@contoso.com"
assert to_addr == "recipient@example.com"
assert "OAuth Integration Test" in msg_str
assert "Hello OAuth!" in msg_str

View File

@@ -2,19 +2,19 @@ from unittest.mock import MagicMock, patch
import pytest
from libs.mail import SMTPClient
from libs.smtp import SMTPClient
def _mail() -> dict:
return {"to": "user@example.com", "subject": "Hi", "html": "<b>Hi</b>"}
@patch("libs.mail.smtp_connection.smtplib.SMTP")
@patch("libs.smtp.smtplib.SMTP")
def test_smtp_plain_success(mock_smtp_cls: MagicMock):
mock_smtp = MagicMock()
mock_smtp_cls.return_value = mock_smtp
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", from_addr="noreply@example.com")
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", _from="noreply@example.com")
client.send(_mail())
mock_smtp_cls.assert_called_once_with("smtp.example.com", 25, timeout=10)
@@ -22,7 +22,7 @@ def test_smtp_plain_success(mock_smtp_cls: MagicMock):
mock_smtp.quit.assert_called_once()
@patch("libs.mail.smtp_connection.smtplib.SMTP")
@patch("libs.smtp.smtplib.SMTP")
def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock):
mock_smtp = MagicMock()
mock_smtp_cls.return_value = mock_smtp
@@ -32,7 +32,7 @@ def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock):
port=587,
username="user",
password="pass",
from_addr="noreply@example.com",
_from="noreply@example.com",
use_tls=True,
opportunistic_tls=True,
)
@@ -46,7 +46,7 @@ def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock):
mock_smtp.quit.assert_called_once()
@patch("libs.mail.smtp_connection.smtplib.SMTP_SSL")
@patch("libs.smtp.smtplib.SMTP_SSL")
def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock):
# Cover SMTP_SSL branch and TimeoutError handling
mock_smtp = MagicMock()
@@ -58,7 +58,7 @@ def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock):
port=465,
username="",
password="",
from_addr="noreply@example.com",
_from="noreply@example.com",
use_tls=True,
opportunistic_tls=False,
)
@@ -67,19 +67,19 @@ def test_smtp_tls_ssl_branch_and_timeout(mock_smtp_ssl_cls: MagicMock):
mock_smtp.quit.assert_called_once()
@patch("libs.mail.smtp_connection.smtplib.SMTP")
@patch("libs.smtp.smtplib.SMTP")
def test_smtp_generic_exception_propagates(mock_smtp_cls: MagicMock):
mock_smtp = MagicMock()
mock_smtp.sendmail.side_effect = RuntimeError("oops")
mock_smtp_cls.return_value = mock_smtp
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", from_addr="noreply@example.com")
client = SMTPClient(server="smtp.example.com", port=25, username="", password="", _from="noreply@example.com")
with pytest.raises(RuntimeError):
client.send(_mail())
mock_smtp.quit.assert_called_once()
@patch("libs.mail.smtp_connection.smtplib.SMTP")
@patch("libs.smtp.smtplib.SMTP")
def test_smtp_smtplib_exception_in_login(mock_smtp_cls: MagicMock):
# Ensure we hit the specific SMTPException except branch
import smtplib
@@ -93,7 +93,7 @@ def test_smtp_smtplib_exception_in_login(mock_smtp_cls: MagicMock):
port=25,
username="user", # non-empty to trigger login
password="pass",
from_addr="noreply@example.com",
_from="noreply@example.com",
)
with pytest.raises(smtplib.SMTPException):
client.send(_mail())

View File

@@ -836,33 +836,6 @@ SMTP_PASSWORD=
SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=false
# SMTP authentication type: 'basic' for username/password, 'oauth2' for Microsoft OAuth 2.0
# Use 'oauth2' for Microsoft Exchange/Outlook due to Basic Auth retirement (September 2025)
SMTP_AUTH_TYPE=basic
# Microsoft OAuth 2.0 configuration for SMTP authentication
# Required when SMTP_AUTH_TYPE=oauth2 and SMTP_SERVER uses Microsoft Exchange/Outlook
#
# Setup instructions:
# 1. Go to Azure Portal (https://portal.azure.com) → Azure Active Directory → App registrations
# 2. Create new application registration
# 3. Add API permissions: Mail.Send, SMTP.Send (Application permissions)
# 4. Grant admin consent for the permissions
# 5. Create client secret in "Certificates & secrets"
# 6. Use the application's Client ID, Client Secret, and your Tenant ID below
#
# For Microsoft Exchange Online, use:
# SMTP_SERVER=smtp.office365.com
# SMTP_PORT=587
# SMTP_USE_TLS=true
# SMTP_OPPORTUNISTIC_TLS=true
MICROSOFT_OAUTH2_CLIENT_ID=
MICROSOFT_OAUTH2_CLIENT_SECRET=
# Tenant ID from Azure AD (use 'common' for multi-tenant applications)
MICROSOFT_OAUTH2_TENANT_ID=common
# Optional: Pre-acquired access token (leave empty to auto-acquire using client credentials)
MICROSOFT_OAUTH2_ACCESS_TOKEN=
# Sendgid configuration
SENDGRID_API_KEY=

View File

@@ -373,11 +373,6 @@ x-shared-env: &shared-api-worker-env
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false}
SMTP_AUTH_TYPE: ${SMTP_AUTH_TYPE:-basic}
MICROSOFT_OAUTH2_CLIENT_ID: ${MICROSOFT_OAUTH2_CLIENT_ID:-}
MICROSOFT_OAUTH2_CLIENT_SECRET: ${MICROSOFT_OAUTH2_CLIENT_SECRET:-}
MICROSOFT_OAUTH2_TENANT_ID: ${MICROSOFT_OAUTH2_TENANT_ID:-common}
MICROSOFT_OAUTH2_ACCESS_TOKEN: ${MICROSOFT_OAUTH2_ACCESS_TOKEN:-}
SENDGRID_API_KEY: ${SENDGRID_API_KEY:-}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}

View File

@@ -84,8 +84,8 @@ export const updateUserProfile: Fetcher<CommonResponse, { url: string; body: Rec
return post<CommonResponse>(url, { body })
}
export const logout: Fetcher<CommonResponse, { url: string; params: Record<string, any> }> = ({ url, params }) => {
return get<CommonResponse>(url, params)
export const logout: Fetcher<CommonResponse, { url: string; body?: Record<string, any> }> = ({ url, body }) => {
return post<CommonResponse>(url, { body: body ?? {} })
}
export const fetchLangGeniusVersion: Fetcher<LangGeniusVersionResponse, { url: string; params: Record<string, any> }> = ({ url, params }) => {
@@ -375,7 +375,7 @@ export const verifyWebAppResetPasswordCode = (body: { email: string; code: strin
post<CommonResponse & { is_valid: boolean; token: string }>('/forgot-password/validity', { body }, { isPublicAPI: true })
export const sendDeleteAccountCode = () =>
get<CommonResponse & { data: string }>('/account/delete/verify')
post<CommonResponse & { data: string }>('/account/delete/verify', { body: {} })
export const verifyDeleteAccountCode = (body: { code: string; token: string }) =>
post<CommonResponse & { is_valid: boolean }>('/account/delete', { body })

View File

@@ -61,9 +61,11 @@ export const sendCompletionMessage = async (appId: string, body: Record<string,
}
export const fetchSuggestedQuestions = (appId: string, messageId: string, getAbortController?: any) => {
return get(
return post(
`apps/${appId}/chat-messages/${messageId}/suggested-questions`,
{},
{
body: {},
},
{
getAbortController,
},

View File

@@ -251,8 +251,8 @@ export const updateFeedback = async ({ url, body }: { url: string; body: Feedbac
}
export const fetchMoreLikeThis = async (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
return (getAction('get', isInstalledApp))(getUrl(`/messages/${messageId}/more-like-this`, isInstalledApp, installedAppId), {
params: {
return (getAction('post', isInstalledApp))(getUrl(`/messages/${messageId}/more-like-this`, isInstalledApp, installedAppId), {
body: {
response_mode: 'blocking',
},
})
@@ -271,7 +271,9 @@ export const removeMessage = (messageId: string, isInstalledApp: boolean, instal
}
export const fetchSuggestedQuestions = (messageId: string, isInstalledApp: boolean, installedAppId = '') => {
return (getAction('get', isInstalledApp))(getUrl(`/messages/${messageId}/suggested-questions`, isInstalledApp, installedAppId))
return (getAction('post', isInstalledApp))(getUrl(`/messages/${messageId}/suggested-questions`, isInstalledApp, installedAppId), {
body: {},
})
}
export const audioToText = (url: string, isPublicAPI: boolean, body: FormData) => {

View File

@@ -249,8 +249,10 @@ export const useUpdateMCPServer = () => {
export const useRefreshMCPServerCode = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'refresh-mcp-server-code'],
mutationFn: (appID: string) => {
return get<MCPServerDetail>(`apps/${appID}/server/refresh`)
mutationFn: (serverID: string) => {
return post<MCPServerDetail>(`apps/${serverID}/server/refresh`, {
body: {},
})
},
})
}