""" 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)