diff --git a/api/tests/unit_tests/tasks/test_mail_send_task.py b/api/tests/unit_tests/tasks/test_mail_send_task.py new file mode 100644 index 0000000000..736871d784 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_mail_send_task.py @@ -0,0 +1,1504 @@ +""" +Unit tests for mail send tasks. + +This module tests the mail sending functionality including: +- Email template rendering with internationalization +- SMTP integration with various configurations +- Retry logic for failed email sends +- Error handling and logging +""" + +import smtplib +from unittest.mock import MagicMock, patch + +import pytest + +from configs import dify_config +from configs.feature import TemplateMode +from libs.email_i18n import EmailType +from tasks.mail_inner_task import _render_template_with_strategy, send_inner_email_task +from tasks.mail_register_task import ( + send_email_register_mail_task, + send_email_register_mail_task_when_account_exist, +) +from tasks.mail_reset_password_task import ( + send_reset_password_mail_task, + send_reset_password_mail_task_when_account_not_exist, +) + + +class TestEmailTemplateRendering: + """Test email template rendering with various scenarios.""" + + def test_render_template_unsafe_mode(self): + """Test template rendering in unsafe mode with Jinja2 syntax.""" + # Arrange + body = "Hello {{ name }}, your code is {{ code }}" + substitutions = {"name": "John", "code": "123456"} + + # Act + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", TemplateMode.UNSAFE): + result = _render_template_with_strategy(body, substitutions) + + # Assert + assert result == "Hello John, your code is 123456" + + def test_render_template_sandbox_mode(self): + """Test template rendering in sandbox mode for security.""" + # Arrange + body = "Hello {{ name }}, your code is {{ code }}" + substitutions = {"name": "Alice", "code": "654321"} + + # Act + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", TemplateMode.SANDBOX): + with patch.object(dify_config, "MAIL_TEMPLATING_TIMEOUT", 3): + result = _render_template_with_strategy(body, substitutions) + + # Assert + assert result == "Hello Alice, your code is 654321" + + def test_render_template_disabled_mode(self): + """Test template rendering when templating is disabled.""" + # Arrange + body = "Hello {{ name }}, your code is {{ code }}" + substitutions = {"name": "Bob", "code": "999999"} + + # Act + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", TemplateMode.DISABLED): + result = _render_template_with_strategy(body, substitutions) + + # Assert - should return body unchanged + assert result == "Hello {{ name }}, your code is {{ code }}" + + def test_render_template_sandbox_timeout(self): + """Test that sandbox mode respects timeout settings and range limits.""" + # Arrange - template with very large range (exceeds sandbox MAX_RANGE) + body = "{% for i in range(1000000) %}{{ i }}{% endfor %}" + substitutions: dict[str, str] = {} + + # Act & Assert - sandbox blocks ranges larger than MAX_RANGE (100000) + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", TemplateMode.SANDBOX): + with patch.object(dify_config, "MAIL_TEMPLATING_TIMEOUT", 1): + # Should raise OverflowError for range too big + with pytest.raises((TimeoutError, RuntimeError, OverflowError)): + _render_template_with_strategy(body, substitutions) + + def test_render_template_invalid_mode(self): + """Test that invalid template mode raises ValueError.""" + # Arrange + body = "Test" + substitutions: dict[str, str] = {} + + # Act & Assert + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", "invalid_mode"): + with pytest.raises(ValueError, match="Unsupported mail templating mode"): + _render_template_with_strategy(body, substitutions) + + def test_render_template_with_special_characters(self): + """Test template rendering with special characters and HTML.""" + # Arrange + body = "

Hello {{ name }}

Code: {{ code }}

" + substitutions = {"name": "Test", "code": "ABC&123"} + + # Act + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", TemplateMode.SANDBOX): + result = _render_template_with_strategy(body, substitutions) + + # Assert + assert "Test" in result + assert "ABC&123" in result + + def test_render_template_missing_variable_sandbox(self): + """Test sandbox mode handles missing variables gracefully.""" + # Arrange + body = "Hello {{ name }}, your code is {{ missing_var }}" + substitutions = {"name": "John"} + + # Act - sandbox mode renders undefined variables as empty strings by default + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", TemplateMode.SANDBOX): + result = _render_template_with_strategy(body, substitutions) + + # Assert - undefined variable is rendered as empty string + assert "Hello John" in result + assert "missing_var" not in result # Variable name should not appear in output + + +class TestSMTPIntegration: + """Test SMTP client integration with various configurations.""" + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_with_tls_ssl(self, mock_smtp_ssl): + """Test SMTP send with TLS using SMTP_SSL.""" + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp_ssl.return_value = mock_server + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test Subject", "html": "

Test Content

"} + + # Act + client.send(mail_data) + + # Assert + mock_smtp_ssl.assert_called_once_with("smtp.example.com", 465, timeout=10) + mock_server.login.assert_called_once_with("user@example.com", "password123") + mock_server.sendmail.assert_called_once() + mock_server.quit.assert_called_once() + + @patch("libs.smtp.smtplib.SMTP") + def test_smtp_send_with_opportunistic_tls(self, mock_smtp): + """Test SMTP send with opportunistic TLS (STARTTLS).""" + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + client = SMTPClient( + server="smtp.example.com", + port=587, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=True, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act + client.send(mail_data) + + # Assert + mock_smtp.assert_called_once_with("smtp.example.com", 587, timeout=10) + mock_server.ehlo.assert_called() + mock_server.starttls.assert_called_once() + assert mock_server.ehlo.call_count == 2 # Before and after STARTTLS + mock_server.sendmail.assert_called_once() + mock_server.quit.assert_called_once() + + @patch("libs.smtp.smtplib.SMTP") + def test_smtp_send_without_tls(self, mock_smtp): + """Test SMTP send without TLS encryption.""" + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + client = SMTPClient( + server="smtp.example.com", + port=25, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=False, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act + client.send(mail_data) + + # Assert + mock_smtp.assert_called_once_with("smtp.example.com", 25, timeout=10) + mock_server.login.assert_called_once() + mock_server.sendmail.assert_called_once() + mock_server.quit.assert_called_once() + + @patch("libs.smtp.smtplib.SMTP") + def test_smtp_send_without_authentication(self, mock_smtp): + """Test SMTP send without authentication (empty credentials).""" + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + client = SMTPClient( + server="smtp.example.com", + port=25, + username="", + password="", + _from="noreply@example.com", + use_tls=False, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act + client.send(mail_data) + + # Assert + mock_server.login.assert_not_called() # Should skip login with empty credentials + mock_server.sendmail.assert_called_once() + mock_server.quit.assert_called_once() + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_authentication_failure(self, mock_smtp_ssl): + """Test SMTP send handles authentication failure.""" + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp_ssl.return_value = mock_server + mock_server.login.side_effect = smtplib.SMTPAuthenticationError(535, b"Authentication failed") + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="wrong_password", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act & Assert + with pytest.raises(smtplib.SMTPAuthenticationError): + client.send(mail_data) + + mock_server.quit.assert_called_once() # Should still cleanup + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_timeout_error(self, mock_smtp_ssl): + """Test SMTP send handles timeout errors.""" + # Arrange + from libs.smtp import SMTPClient + + mock_smtp_ssl.side_effect = TimeoutError("Connection timeout") + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act & Assert + with pytest.raises(TimeoutError): + client.send(mail_data) + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_connection_refused(self, mock_smtp_ssl): + """Test SMTP send handles connection refused errors.""" + # Arrange + from libs.smtp import SMTPClient + + mock_smtp_ssl.side_effect = ConnectionRefusedError("Connection refused") + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act & Assert + with pytest.raises((ConnectionRefusedError, OSError)): + client.send(mail_data) + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_ensures_cleanup_on_error(self, mock_smtp_ssl): + """Test SMTP send ensures cleanup even when errors occur.""" + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp_ssl.return_value = mock_server + mock_server.sendmail.side_effect = smtplib.SMTPException("Send failed") + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act & Assert + with pytest.raises(smtplib.SMTPException): + client.send(mail_data) + + # Verify cleanup was called + mock_server.quit.assert_called_once() + + +class TestMailTaskRetryLogic: + """Test retry logic for mail sending tasks.""" + + @patch("tasks.mail_register_task.mail") + def test_mail_task_skips_when_not_initialized(self, mock_mail): + """Test that mail tasks skip execution when mail is not initialized.""" + # Arrange + mock_mail.is_inited.return_value = False + + # Act + result = send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + + # Assert + assert result is None + mock_mail.is_inited.assert_called_once() + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + @patch("tasks.mail_register_task.logger") + def test_mail_task_logs_success(self, mock_logger, mock_mail, mock_email_service): + """Test that successful mail sends are logged properly.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + + # Assert + mock_service.send_email.assert_called_once_with( + email_type=EmailType.EMAIL_REGISTER, + language_code="en-US", + to="test@example.com", + template_context={"to": "test@example.com", "code": "123456"}, + ) + # Verify logging calls + assert mock_logger.info.call_count == 2 # Start and success logs + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + @patch("tasks.mail_register_task.logger") + def test_mail_task_logs_failure(self, mock_logger, mock_mail, mock_email_service): + """Test that failed mail sends are logged with exception details.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_service.send_email.side_effect = Exception("SMTP connection failed") + mock_email_service.return_value = mock_service + + # Act + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + + # Assert + mock_logger.exception.assert_called_once_with("Send email register mail to %s failed", "test@example.com") + + @patch("tasks.mail_reset_password_task.get_email_i18n_service") + @patch("tasks.mail_reset_password_task.mail") + def test_reset_password_task_success(self, mock_mail, mock_email_service): + """Test reset password task sends email successfully.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_reset_password_mail_task(language="zh-Hans", to="user@example.com", code="RESET123") + + # Assert + mock_service.send_email.assert_called_once_with( + email_type=EmailType.RESET_PASSWORD, + language_code="zh-Hans", + to="user@example.com", + template_context={"to": "user@example.com", "code": "RESET123"}, + ) + + @patch("tasks.mail_reset_password_task.get_email_i18n_service") + @patch("tasks.mail_reset_password_task.mail") + @patch("tasks.mail_reset_password_task.dify_config") + def test_reset_password_when_account_not_exist_with_register(self, mock_config, mock_mail, mock_email_service): + """Test reset password task when account doesn't exist and registration is allowed.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_config.CONSOLE_WEB_URL = "https://console.example.com" + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_reset_password_mail_task_when_account_not_exist( + language="en-US", to="newuser@example.com", is_allow_register=True + ) + + # Assert + mock_service.send_email.assert_called_once() + call_args = mock_service.send_email.call_args + assert call_args[1]["email_type"] == EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST + assert call_args[1]["to"] == "newuser@example.com" + assert "sign_up_url" in call_args[1]["template_context"] + + @patch("tasks.mail_reset_password_task.get_email_i18n_service") + @patch("tasks.mail_reset_password_task.mail") + def test_reset_password_when_account_not_exist_without_register(self, mock_mail, mock_email_service): + """Test reset password task when account doesn't exist and registration is not allowed.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_reset_password_mail_task_when_account_not_exist( + language="en-US", to="newuser@example.com", is_allow_register=False + ) + + # Assert + mock_service.send_email.assert_called_once() + call_args = mock_service.send_email.call_args + assert call_args[1]["email_type"] == EmailType.RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER + + +class TestMailTaskInternationalization: + """Test internationalization support in mail tasks.""" + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + def test_mail_task_with_english_language(self, mock_mail, mock_email_service): + """Test mail task with English language code.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + + # Assert + call_args = mock_service.send_email.call_args + assert call_args[1]["language_code"] == "en-US" + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + def test_mail_task_with_chinese_language(self, mock_mail, mock_email_service): + """Test mail task with Chinese language code.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_email_register_mail_task(language="zh-Hans", to="test@example.com", code="123456") + + # Assert + call_args = mock_service.send_email.call_args + assert call_args[1]["language_code"] == "zh-Hans" + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + @patch("tasks.mail_register_task.dify_config") + def test_account_exist_task_includes_urls(self, mock_config, mock_mail, mock_email_service): + """Test account exist task includes proper URLs in template context.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_config.CONSOLE_WEB_URL = "https://console.example.com" + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_email_register_mail_task_when_account_exist( + language="en-US", to="existing@example.com", account_name="John Doe" + ) + + # Assert + call_args = mock_service.send_email.call_args + context = call_args[1]["template_context"] + assert context["login_url"] == "https://console.example.com/signin" + assert context["reset_password_url"] == "https://console.example.com/reset-password" + assert context["account_name"] == "John Doe" + + +class TestInnerEmailTask: + """Test inner email task with template rendering.""" + + @patch("tasks.mail_inner_task.get_email_i18n_service") + @patch("tasks.mail_inner_task.mail") + @patch("tasks.mail_inner_task._render_template_with_strategy") + def test_inner_email_task_renders_and_sends(self, mock_render, mock_mail, mock_email_service): + """Test inner email task renders template and sends email.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_render.return_value = "

Hello John, your code is 123456

" + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + to_list = ["user1@example.com", "user2@example.com"] + subject = "Test Subject" + body = "

Hello {{ name }}, your code is {{ code }}

" + substitutions = {"name": "John", "code": "123456"} + + # Act + send_inner_email_task(to=to_list, subject=subject, body=body, substitutions=substitutions) + + # Assert + mock_render.assert_called_once_with(body, substitutions) + mock_service.send_raw_email.assert_called_once_with( + to=to_list, subject=subject, html_content="

Hello John, your code is 123456

" + ) + + @patch("tasks.mail_inner_task.mail") + def test_inner_email_task_skips_when_not_initialized(self, mock_mail): + """Test inner email task skips when mail is not initialized.""" + # Arrange + mock_mail.is_inited.return_value = False + + # Act + result = send_inner_email_task(to=["test@example.com"], subject="Test", body="Body", substitutions={}) + + # Assert + assert result is None + + @patch("tasks.mail_inner_task.get_email_i18n_service") + @patch("tasks.mail_inner_task.mail") + @patch("tasks.mail_inner_task._render_template_with_strategy") + @patch("tasks.mail_inner_task.logger") + def test_inner_email_task_logs_failure(self, mock_logger, mock_render, mock_mail, mock_email_service): + """Test inner email task logs failures properly.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_render.return_value = "

Content

" + mock_service = MagicMock() + mock_service.send_raw_email.side_effect = Exception("Send failed") + mock_email_service.return_value = mock_service + + to_list = ["user@example.com"] + + # Act + send_inner_email_task(to=to_list, subject="Test", body="Body", substitutions={}) + + # Assert + mock_logger.exception.assert_called_once() + + +class TestSendGridIntegration: + """Test SendGrid client integration.""" + + @patch("libs.sendgrid.sendgrid.SendGridAPIClient") + def test_sendgrid_send_success(self, mock_sg_client): + """Test SendGrid client sends email successfully.""" + # Arrange + from libs.sendgrid import SendGridClient + + mock_client_instance = MagicMock() + mock_sg_client.return_value = mock_client_instance + mock_response = MagicMock() + mock_response.status_code = 202 + mock_client_instance.client.mail.send.post.return_value = mock_response + + client = SendGridClient(sendgrid_api_key="test_api_key", _from="noreply@example.com") + + mail_data = {"to": "recipient@example.com", "subject": "Test Subject", "html": "

Test Content

"} + + # Act + client.send(mail_data) + + # Assert + mock_sg_client.assert_called_once_with(api_key="test_api_key") + mock_client_instance.client.mail.send.post.assert_called_once() + + @patch("libs.sendgrid.sendgrid.SendGridAPIClient") + def test_sendgrid_send_missing_recipient(self, mock_sg_client): + """Test SendGrid client raises error when recipient is missing.""" + # Arrange + from libs.sendgrid import SendGridClient + + client = SendGridClient(sendgrid_api_key="test_api_key", _from="noreply@example.com") + + mail_data = {"to": "", "subject": "Test Subject", "html": "

Test Content

"} + + # Act & Assert + with pytest.raises(ValueError, match="recipient address is missing"): + client.send(mail_data) + + @patch("libs.sendgrid.sendgrid.SendGridAPIClient") + def test_sendgrid_send_unauthorized_error(self, mock_sg_client): + """Test SendGrid client handles unauthorized errors.""" + # Arrange + from python_http_client.exceptions import UnauthorizedError + + from libs.sendgrid import SendGridClient + + mock_client_instance = MagicMock() + mock_sg_client.return_value = mock_client_instance + mock_client_instance.client.mail.send.post.side_effect = UnauthorizedError( + MagicMock(status_code=401), "Unauthorized" + ) + + client = SendGridClient(sendgrid_api_key="invalid_key", _from="noreply@example.com") + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act & Assert + with pytest.raises(UnauthorizedError): + client.send(mail_data) + + @patch("libs.sendgrid.sendgrid.SendGridAPIClient") + def test_sendgrid_send_forbidden_error(self, mock_sg_client): + """Test SendGrid client handles forbidden errors.""" + # Arrange + from python_http_client.exceptions import ForbiddenError + + from libs.sendgrid import SendGridClient + + mock_client_instance = MagicMock() + mock_sg_client.return_value = mock_client_instance + mock_client_instance.client.mail.send.post.side_effect = ForbiddenError(MagicMock(status_code=403), "Forbidden") + + client = SendGridClient(sendgrid_api_key="test_api_key", _from="invalid@example.com") + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act & Assert + with pytest.raises(ForbiddenError): + client.send(mail_data) + + @patch("libs.sendgrid.sendgrid.SendGridAPIClient") + def test_sendgrid_send_timeout_error(self, mock_sg_client): + """Test SendGrid client handles timeout errors.""" + # Arrange + from libs.sendgrid import SendGridClient + + mock_client_instance = MagicMock() + mock_sg_client.return_value = mock_client_instance + mock_client_instance.client.mail.send.post.side_effect = TimeoutError("Request timeout") + + client = SendGridClient(sendgrid_api_key="test_api_key", _from="noreply@example.com") + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act & Assert + with pytest.raises(TimeoutError): + client.send(mail_data) + + +class TestMailExtension: + """Test mail extension initialization and configuration.""" + + @patch("extensions.ext_mail.dify_config") + def test_mail_init_smtp_configuration(self, mock_config): + """Test mail extension initializes SMTP client correctly.""" + # Arrange + from extensions.ext_mail import Mail + + mock_config.MAIL_TYPE = "smtp" + mock_config.SMTP_SERVER = "smtp.example.com" + mock_config.SMTP_PORT = 465 + mock_config.SMTP_USERNAME = "user@example.com" + mock_config.SMTP_PASSWORD = "password123" + mock_config.SMTP_USE_TLS = True + mock_config.SMTP_OPPORTUNISTIC_TLS = False + mock_config.MAIL_DEFAULT_SEND_FROM = "noreply@example.com" + + mail = Mail() + mock_app = MagicMock() + + # Act + mail.init_app(mock_app) + + # Assert + assert mail.is_inited() is True + assert mail._client is not None + + @patch("extensions.ext_mail.dify_config") + def test_mail_init_without_mail_type(self, mock_config): + """Test mail extension skips initialization when MAIL_TYPE is not set.""" + # Arrange + from extensions.ext_mail import Mail + + mock_config.MAIL_TYPE = None + + mail = Mail() + mock_app = MagicMock() + + # Act + mail.init_app(mock_app) + + # Assert + assert mail.is_inited() is False + + @patch("extensions.ext_mail.dify_config") + def test_mail_send_validates_parameters(self, mock_config): + """Test mail send validates required parameters.""" + # Arrange + from extensions.ext_mail import Mail + + mail = Mail() + mail._client = MagicMock() + mail._default_send_from = "noreply@example.com" + + # Act & Assert - missing to + with pytest.raises(ValueError, match="mail to is not set"): + mail.send(to="", subject="Test", html="

Content

") + + # Act & Assert - missing subject + with pytest.raises(ValueError, match="mail subject is not set"): + mail.send(to="test@example.com", subject="", html="

Content

") + + # Act & Assert - missing html + with pytest.raises(ValueError, match="mail html is not set"): + mail.send(to="test@example.com", subject="Test", html="") + + @patch("extensions.ext_mail.dify_config") + def test_mail_send_uses_default_from(self, mock_config): + """Test mail send uses default from address when not provided.""" + # Arrange + from extensions.ext_mail import Mail + + mail = Mail() + mock_client = MagicMock() + mail._client = mock_client + mail._default_send_from = "default@example.com" + + # Act + mail.send(to="test@example.com", subject="Test", html="

Content

") + + # Assert + mock_client.send.assert_called_once() + call_args = mock_client.send.call_args[0][0] + assert call_args["from"] == "default@example.com" + + +class TestEmailI18nService: + """Test email internationalization service.""" + + @patch("libs.email_i18n.FlaskMailSender") + @patch("libs.email_i18n.FeatureBrandingService") + @patch("libs.email_i18n.FlaskEmailRenderer") + def test_email_service_sends_with_branding(self, mock_renderer_class, mock_branding_class, mock_sender_class): + """Test email service sends email with branding support.""" + # Arrange + from libs.email_i18n import EmailI18nConfig, EmailI18nService, EmailLanguage, EmailTemplate, EmailType + from services.feature_service import BrandingModel + + mock_renderer = MagicMock() + mock_renderer.render_template.return_value = "Rendered content" + mock_renderer_class.return_value = mock_renderer + + mock_branding = MagicMock() + mock_branding.get_branding_config.return_value = BrandingModel( + enabled=True, application_title="Custom App", logo="logo.png" + ) + mock_branding_class.return_value = mock_branding + + mock_sender = MagicMock() + mock_sender_class.return_value = mock_sender + + template = EmailTemplate( + subject="Test {application_title}", + template_path="templates/test.html", + branded_template_path="templates/branded/test.html", + ) + + config = EmailI18nConfig(templates={EmailType.EMAIL_REGISTER: {EmailLanguage.EN_US: template}}) + + service = EmailI18nService( + config=config, renderer=mock_renderer, branding_service=mock_branding, sender=mock_sender + ) + + # Act + service.send_email( + email_type=EmailType.EMAIL_REGISTER, + language_code="en-US", + to="test@example.com", + template_context={"code": "123456"}, + ) + + # Assert + mock_renderer.render_template.assert_called_once() + # Should use branded template + assert mock_renderer.render_template.call_args[0][0] == "templates/branded/test.html" + mock_sender.send_email.assert_called_once_with( + to="test@example.com", subject="Test Custom App", html_content="Rendered content" + ) + + @patch("libs.email_i18n.FlaskMailSender") + def test_email_service_send_raw_email_single_recipient(self, mock_sender_class): + """Test email service sends raw email to single recipient.""" + # Arrange + from libs.email_i18n import EmailI18nConfig, EmailI18nService + + mock_sender = MagicMock() + mock_sender_class.return_value = mock_sender + + service = EmailI18nService( + config=EmailI18nConfig(), + renderer=MagicMock(), + branding_service=MagicMock(), + sender=mock_sender, + ) + + # Act + service.send_raw_email(to="test@example.com", subject="Test", html_content="

Content

") + + # Assert + mock_sender.send_email.assert_called_once_with( + to="test@example.com", subject="Test", html_content="

Content

" + ) + + @patch("libs.email_i18n.FlaskMailSender") + def test_email_service_send_raw_email_multiple_recipients(self, mock_sender_class): + """Test email service sends raw email to multiple recipients.""" + # Arrange + from libs.email_i18n import EmailI18nConfig, EmailI18nService + + mock_sender = MagicMock() + mock_sender_class.return_value = mock_sender + + service = EmailI18nService( + config=EmailI18nConfig(), + renderer=MagicMock(), + branding_service=MagicMock(), + sender=mock_sender, + ) + + # Act + service.send_raw_email( + to=["user1@example.com", "user2@example.com"], subject="Test", html_content="

Content

" + ) + + # Assert + assert mock_sender.send_email.call_count == 2 + mock_sender.send_email.assert_any_call(to="user1@example.com", subject="Test", html_content="

Content

") + mock_sender.send_email.assert_any_call(to="user2@example.com", subject="Test", html_content="

Content

") + + +class TestPerformanceAndTiming: + """Test performance tracking and timing in mail tasks.""" + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + @patch("tasks.mail_register_task.logger") + @patch("tasks.mail_register_task.time") + def test_mail_task_tracks_execution_time(self, mock_time, mock_logger, mock_mail, mock_email_service): + """Test that mail tasks track and log execution time.""" + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Simulate time progression + mock_time.perf_counter.side_effect = [100.0, 100.5] # 0.5 second execution + + # Act + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + + # Assert + assert mock_time.perf_counter.call_count == 2 + # Verify latency is logged + success_log_call = mock_logger.info.call_args_list[1] + assert "latency" in str(success_log_call) + + +class TestEdgeCasesAndErrorHandling: + """ + Test edge cases and error handling scenarios. + + This test class covers unusual inputs, boundary conditions, + and various error scenarios to ensure robust error handling. + """ + + @patch("extensions.ext_mail.dify_config") + def test_mail_init_invalid_smtp_config_missing_server(self, mock_config): + """ + Test mail initialization fails when SMTP server is missing. + + Validates that proper error is raised when required SMTP + configuration parameters are not provided. + """ + # Arrange + from extensions.ext_mail import Mail + + mock_config.MAIL_TYPE = "smtp" + mock_config.SMTP_SERVER = None # Missing required parameter + mock_config.SMTP_PORT = 465 + + mail = Mail() + mock_app = MagicMock() + + # Act & Assert + with pytest.raises(ValueError, match="SMTP_SERVER and SMTP_PORT are required"): + mail.init_app(mock_app) + + @patch("extensions.ext_mail.dify_config") + def test_mail_init_invalid_smtp_opportunistic_tls_without_tls(self, mock_config): + """ + Test mail initialization fails with opportunistic TLS but TLS disabled. + + Opportunistic TLS (STARTTLS) requires TLS to be enabled. + This test ensures the configuration is validated properly. + """ + # Arrange + from extensions.ext_mail import Mail + + mock_config.MAIL_TYPE = "smtp" + mock_config.SMTP_SERVER = "smtp.example.com" + mock_config.SMTP_PORT = 587 + mock_config.SMTP_USE_TLS = False # TLS disabled + mock_config.SMTP_OPPORTUNISTIC_TLS = True # But opportunistic TLS enabled + + mail = Mail() + mock_app = MagicMock() + + # Act & Assert + with pytest.raises(ValueError, match="SMTP_OPPORTUNISTIC_TLS is not supported without enabling SMTP_USE_TLS"): + mail.init_app(mock_app) + + @patch("extensions.ext_mail.dify_config") + def test_mail_init_unsupported_mail_type(self, mock_config): + """ + Test mail initialization fails with unsupported mail type. + + Ensures that only supported mail providers (smtp, sendgrid, resend) + are accepted and invalid types are rejected. + """ + # Arrange + from extensions.ext_mail import Mail + + mock_config.MAIL_TYPE = "unsupported_provider" + + mail = Mail() + mock_app = MagicMock() + + # Act & Assert + with pytest.raises(ValueError, match="Unsupported mail type"): + mail.init_app(mock_app) + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_with_empty_subject(self, mock_smtp_ssl): + """ + Test SMTP client handles empty subject gracefully. + + While not ideal, the SMTP client should be able to send + emails with empty subjects without crashing. + """ + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp_ssl.return_value = mock_server + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + # Email with empty subject + mail_data = {"to": "recipient@example.com", "subject": "", "html": "

Content

"} + + # Act + client.send(mail_data) + + # Assert - should still send successfully + mock_server.sendmail.assert_called_once() + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_with_unicode_characters(self, mock_smtp_ssl): + """ + Test SMTP client handles Unicode characters in email content. + + Ensures proper handling of international characters in + subject lines and email bodies. + """ + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp_ssl.return_value = mock_server + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + # Email with Unicode characters (Chinese, emoji, etc.) + mail_data = { + "to": "recipient@example.com", + "subject": "测试邮件 🎉 Test Email", + "html": "

你好世界 Hello World 🌍

", + } + + # Act + client.send(mail_data) + + # Assert + mock_server.sendmail.assert_called_once() + mock_server.quit.assert_called_once() + + @patch("tasks.mail_inner_task.get_email_i18n_service") + @patch("tasks.mail_inner_task.mail") + @patch("tasks.mail_inner_task._render_template_with_strategy") + def test_inner_email_task_with_empty_recipient_list(self, mock_render, mock_mail, mock_email_service): + """ + Test inner email task handles empty recipient list. + + When no recipients are provided, the task should handle + this gracefully without attempting to send emails. + """ + # Arrange + mock_mail.is_inited.return_value = True + mock_render.return_value = "

Content

" + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_inner_email_task(to=[], subject="Test", body="Body", substitutions={}) + + # Assert + mock_service.send_raw_email.assert_called_once_with(to=[], subject="Test", html_content="

Content

") + + +class TestConcurrencyAndThreadSafety: + """ + Test concurrent execution and thread safety scenarios. + + These tests ensure that mail tasks can handle concurrent + execution without race conditions or resource conflicts. + """ + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + def test_multiple_mail_tasks_concurrent_execution(self, mock_mail, mock_email_service): + """ + Test multiple mail tasks can execute concurrently. + + Simulates concurrent execution of multiple mail tasks + to ensure thread safety and proper resource handling. + """ + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act - simulate concurrent task execution + recipients = [f"user{i}@example.com" for i in range(5)] + for recipient in recipients: + send_email_register_mail_task(language="en-US", to=recipient, code="123456") + + # Assert - all tasks should complete successfully + assert mock_service.send_email.call_count == 5 + + +class TestResendIntegration: + """ + Test Resend email service integration. + + Resend is an alternative email provider that can be used + instead of SMTP or SendGrid. + """ + + @patch("builtins.__import__", side_effect=__import__) + @patch("extensions.ext_mail.dify_config") + def test_mail_init_resend_configuration(self, mock_config, mock_import): + """ + Test mail extension initializes Resend client correctly. + + Validates that Resend API key is properly configured + and the client is initialized. + """ + # Arrange + from extensions.ext_mail import Mail + + mock_config.MAIL_TYPE = "resend" + mock_config.RESEND_API_KEY = "re_test_api_key" + mock_config.RESEND_API_URL = None + mock_config.MAIL_DEFAULT_SEND_FROM = "noreply@example.com" + + # Create mock resend module + mock_resend = MagicMock() + mock_emails = MagicMock() + mock_resend.Emails = mock_emails + + # Override import for resend module + original_import = __import__ + + def custom_import(name, *args, **kwargs): + if name == "resend": + return mock_resend + return original_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + mail = Mail() + mock_app = MagicMock() + + # Act + mail.init_app(mock_app) + + # Assert + assert mail.is_inited() is True + assert mock_resend.api_key == "re_test_api_key" + + @patch("builtins.__import__", side_effect=__import__) + @patch("extensions.ext_mail.dify_config") + def test_mail_init_resend_with_custom_url(self, mock_config, mock_import): + """ + Test mail extension initializes Resend with custom API URL. + + Some deployments may use a custom Resend API endpoint. + This test ensures custom URLs are properly configured. + """ + # Arrange + from extensions.ext_mail import Mail + + mock_config.MAIL_TYPE = "resend" + mock_config.RESEND_API_KEY = "re_test_api_key" + mock_config.RESEND_API_URL = "https://custom-resend.example.com" + mock_config.MAIL_DEFAULT_SEND_FROM = "noreply@example.com" + + # Create mock resend module + mock_resend = MagicMock() + mock_emails = MagicMock() + mock_resend.Emails = mock_emails + + # Override import for resend module + original_import = __import__ + + def custom_import(name, *args, **kwargs): + if name == "resend": + return mock_resend + return original_import(name, *args, **kwargs) + + mock_import.side_effect = custom_import + + mail = Mail() + mock_app = MagicMock() + + # Act + mail.init_app(mock_app) + + # Assert + assert mail.is_inited() is True + assert mock_resend.api_url == "https://custom-resend.example.com" + + @patch("extensions.ext_mail.dify_config") + def test_mail_init_resend_missing_api_key(self, mock_config): + """ + Test mail initialization fails when Resend API key is missing. + + Resend requires an API key to function. This test ensures + proper validation of required configuration. + """ + # Arrange + from extensions.ext_mail import Mail + + mock_config.MAIL_TYPE = "resend" + mock_config.RESEND_API_KEY = None # Missing API key + + mail = Mail() + mock_app = MagicMock() + + # Act & Assert + with pytest.raises(ValueError, match="RESEND_API_KEY is not set"): + mail.init_app(mock_app) + + +class TestTemplateContextValidation: + """ + Test template context validation and rendering. + + These tests ensure that template contexts are properly + validated and rendered with correct variable substitution. + """ + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + def test_mail_task_template_context_includes_all_required_fields(self, mock_mail, mock_email_service): + """ + Test that mail tasks include all required fields in template context. + + Template rendering requires specific context variables. + This test ensures all required fields are present. + """ + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_email_register_mail_task(language="en-US", to="test@example.com", code="ABC123") + + # Assert + call_args = mock_service.send_email.call_args + context = call_args[1]["template_context"] + + # Verify all required fields are present + assert "to" in context + assert "code" in context + assert context["to"] == "test@example.com" + assert context["code"] == "ABC123" + + def test_render_template_with_complex_nested_data(self): + """ + Test template rendering with complex nested data structures. + + Templates may need to access nested dictionaries or lists. + This test ensures complex data structures are handled correctly. + """ + # Arrange + body = ( + "User: {{ user.name }}, Items: " + "{% for item in items %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}" + ) + substitutions = {"user": {"name": "John Doe"}, "items": ["apple", "banana", "cherry"]} + + # Act + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", TemplateMode.SANDBOX): + result = _render_template_with_strategy(body, substitutions) + + # Assert + assert "John Doe" in result + assert "apple" in result + assert "banana" in result + assert "cherry" in result + + def test_render_template_with_conditional_logic(self): + """ + Test template rendering with conditional logic. + + Templates often use conditional statements to customize + content based on context variables. + """ + # Arrange + body = "{% if is_premium %}Premium User{% else %}Free User{% endif %}" + + # Act - Test with premium user + with patch.object(dify_config, "MAIL_TEMPLATING_MODE", TemplateMode.SANDBOX): + result_premium = _render_template_with_strategy(body, {"is_premium": True}) + result_free = _render_template_with_strategy(body, {"is_premium": False}) + + # Assert + assert "Premium User" in result_premium + assert "Free User" in result_free + + +class TestEmailValidation: + """ + Test email address validation and sanitization. + + These tests ensure that email addresses are properly + validated before sending to prevent errors. + """ + + @patch("extensions.ext_mail.dify_config") + def test_mail_send_with_invalid_email_format(self, mock_config): + """ + Test mail send with malformed email address. + + While the Mail class doesn't validate email format, + this test documents the current behavior. + """ + # Arrange + from extensions.ext_mail import Mail + + mail = Mail() + mock_client = MagicMock() + mail._client = mock_client + mail._default_send_from = "noreply@example.com" + + # Act - send to malformed email (no validation in Mail class) + mail.send(to="not-an-email", subject="Test", html="

Content

") + + # Assert - Mail class passes through to client + mock_client.send.assert_called_once() + + +class TestSMTPEdgeCases: + """ + Test SMTP-specific edge cases and error conditions. + + These tests cover various SMTP-specific scenarios that + may occur in production environments. + """ + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_with_very_large_email_body(self, mock_smtp_ssl): + """ + Test SMTP client handles large email bodies. + + Some emails may contain large HTML content with images + or extensive formatting. This test ensures they're handled. + """ + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp_ssl.return_value = mock_server + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + # Create a large HTML body (simulating a newsletter) + large_html = "" + "

Content paragraph

" * 1000 + "" + mail_data = {"to": "recipient@example.com", "subject": "Large Email", "html": large_html} + + # Act + client.send(mail_data) + + # Assert + mock_server.sendmail.assert_called_once() + # Verify the large content was included + sent_message = mock_server.sendmail.call_args[0][2] + assert len(sent_message) > 10000 # Should be a large message + + @patch("libs.smtp.smtplib.SMTP_SSL") + def test_smtp_send_with_multiple_recipients_in_to_field(self, mock_smtp_ssl): + """ + Test SMTP client with single recipient (current implementation). + + The current SMTPClient implementation sends to a single + recipient per call. This test documents that behavior. + """ + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp_ssl.return_value = mock_server + + client = SMTPClient( + server="smtp.example.com", + port=465, + username="user@example.com", + password="password123", + _from="noreply@example.com", + use_tls=True, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act + client.send(mail_data) + + # Assert - sends to single recipient + call_args = mock_server.sendmail.call_args + assert call_args[0][1] == "recipient@example.com" + + @patch("libs.smtp.smtplib.SMTP") + def test_smtp_send_with_whitespace_in_credentials(self, mock_smtp): + """ + Test SMTP client strips whitespace from credentials. + + The SMTPClient checks for non-empty credentials after stripping + whitespace to avoid authentication with blank credentials. + """ + # Arrange + from libs.smtp import SMTPClient + + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + # Credentials with only whitespace + client = SMTPClient( + server="smtp.example.com", + port=25, + username=" ", # Only whitespace + password=" ", # Only whitespace + _from="noreply@example.com", + use_tls=False, + opportunistic_tls=False, + ) + + mail_data = {"to": "recipient@example.com", "subject": "Test", "html": "

Content

"} + + # Act + client.send(mail_data) + + # Assert - should NOT attempt login with whitespace-only credentials + mock_server.login.assert_not_called() + + +class TestLoggingAndMonitoring: + """ + Test logging and monitoring functionality. + + These tests ensure that mail tasks properly log their + execution for debugging and monitoring purposes. + """ + + @patch("tasks.mail_register_task.get_email_i18n_service") + @patch("tasks.mail_register_task.mail") + @patch("tasks.mail_register_task.logger") + def test_mail_task_logs_recipient_information(self, mock_logger, mock_mail, mock_email_service): + """ + Test that mail tasks log recipient information for audit trails. + + Logging recipient information helps with debugging and + tracking email delivery in production. + """ + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_email_register_mail_task(language="en-US", to="audit@example.com", code="123456") + + # Assert + # Check that recipient is logged in start message + start_log_call = mock_logger.info.call_args_list[0] + assert "audit@example.com" in str(start_log_call) + + @patch("tasks.mail_inner_task.get_email_i18n_service") + @patch("tasks.mail_inner_task.mail") + @patch("tasks.mail_inner_task.logger") + def test_inner_email_task_logs_subject_for_tracking(self, mock_logger, mock_mail, mock_email_service): + """ + Test that inner email task logs subject for tracking purposes. + + Logging email subjects helps identify which emails are being + sent and aids in debugging delivery issues. + """ + # Arrange + mock_mail.is_inited.return_value = True + mock_service = MagicMock() + mock_email_service.return_value = mock_service + + # Act + send_inner_email_task( + to=["user@example.com"], subject="Important Notification", body="

Body

", substitutions={} + ) + + # Assert + # Check that subject is logged + start_log_call = mock_logger.info.call_args_list[0] + assert "Important Notification" in str(start_log_call)