feat: add email system with multi-provider support
Implements a comprehensive email system with: - Multi-provider support (SMTP, SendGrid, Mailgun, Amazon SES) - Database-stored templates with i18n (EN, FR, DE, LB) - Jinja2 template rendering with variable interpolation - Email logging for debugging and compliance - Debug mode for development (logs instead of sending) - Welcome email integration in signup flow New files: - models/database/email.py: EmailTemplate and EmailLog models - app/services/email_service.py: Provider abstraction and service - scripts/seed_email_templates.py: Template seeding script - tests/unit/services/test_email_service.py: 28 unit tests - docs/features/email-system.md: Complete documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
617
tests/unit/services/test_email_service.py
Normal file
617
tests/unit/services/test_email_service.py
Normal file
@@ -0,0 +1,617 @@
|
||||
# tests/unit/services/test_email_service.py
|
||||
"""Unit tests for EmailService - email sending and template rendering."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.email_service import (
|
||||
DebugProvider,
|
||||
EmailProvider,
|
||||
EmailService,
|
||||
SMTPProvider,
|
||||
get_provider,
|
||||
)
|
||||
from models.database.email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailProviders:
|
||||
"""Test suite for email providers."""
|
||||
|
||||
def test_debug_provider_send(self):
|
||||
"""Test DebugProvider logs instead of sending."""
|
||||
provider = DebugProvider()
|
||||
|
||||
success, message_id, error = provider.send(
|
||||
to_email="test@example.com",
|
||||
to_name="Test User",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
body_text="Hello",
|
||||
from_email="noreply@wizamart.com",
|
||||
from_name="Wizamart",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert message_id == "debug-test@example.com"
|
||||
assert error is None
|
||||
|
||||
def test_debug_provider_with_reply_to(self):
|
||||
"""Test DebugProvider with reply-to header."""
|
||||
provider = DebugProvider()
|
||||
|
||||
success, message_id, error = provider.send(
|
||||
to_email="test@example.com",
|
||||
to_name="Test User",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
body_text=None,
|
||||
from_email="noreply@wizamart.com",
|
||||
from_name="Wizamart",
|
||||
reply_to="support@wizamart.com",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
|
||||
@patch("app.services.email_service.settings")
|
||||
def test_get_provider_debug_mode(self, mock_settings):
|
||||
"""Test get_provider returns DebugProvider in debug mode."""
|
||||
mock_settings.email_debug = True
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, DebugProvider)
|
||||
|
||||
@patch("app.services.email_service.settings")
|
||||
def test_get_provider_smtp(self, mock_settings):
|
||||
"""Test get_provider returns SMTPProvider for smtp config."""
|
||||
mock_settings.email_debug = False
|
||||
mock_settings.email_provider = "smtp"
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, SMTPProvider)
|
||||
|
||||
@patch("app.services.email_service.settings")
|
||||
def test_get_provider_unknown_defaults_to_smtp(self, mock_settings):
|
||||
"""Test get_provider defaults to SMTP for unknown providers."""
|
||||
mock_settings.email_debug = False
|
||||
mock_settings.email_provider = "unknown_provider"
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, SMTPProvider)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailService:
|
||||
"""Test suite for EmailService."""
|
||||
|
||||
def test_render_template_simple(self, db):
|
||||
"""Test simple template rendering."""
|
||||
service = EmailService(db)
|
||||
|
||||
result = service.render_template(
|
||||
"Hello {{ name }}!", {"name": "World"}
|
||||
)
|
||||
|
||||
assert result == "Hello World!"
|
||||
|
||||
def test_render_template_multiple_vars(self, db):
|
||||
"""Test template rendering with multiple variables."""
|
||||
service = EmailService(db)
|
||||
|
||||
result = service.render_template(
|
||||
"Hi {{ first_name }}, your code is {{ vendor_code }}.",
|
||||
{"first_name": "John", "vendor_code": "ACME"}
|
||||
)
|
||||
|
||||
assert result == "Hi John, your code is ACME."
|
||||
|
||||
def test_render_template_missing_var(self, db):
|
||||
"""Test template rendering with missing variable returns empty."""
|
||||
service = EmailService(db)
|
||||
|
||||
result = service.render_template(
|
||||
"Hello {{ name }}!",
|
||||
{} # No name provided
|
||||
)
|
||||
|
||||
# Jinja2 renders missing vars as empty string by default
|
||||
assert "Hello" in result
|
||||
|
||||
def test_render_template_error_returns_original(self, db):
|
||||
"""Test template rendering error returns original string."""
|
||||
service = EmailService(db)
|
||||
|
||||
# Invalid Jinja2 syntax
|
||||
template = "Hello {{ name"
|
||||
result = service.render_template(template, {"name": "World"})
|
||||
|
||||
assert result == template
|
||||
|
||||
def test_get_template_not_found(self, db):
|
||||
"""Test get_template returns None for non-existent template."""
|
||||
service = EmailService(db)
|
||||
|
||||
result = service.get_template("nonexistent_template", "en")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_template_with_language_fallback(self, db):
|
||||
"""Test get_template falls back to English."""
|
||||
# Create English template only
|
||||
template = EmailTemplate(
|
||||
code="test_template",
|
||||
language="en",
|
||||
name="Test Template",
|
||||
subject="Test",
|
||||
body_html="<p>Test</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
# Request German, should fallback to English
|
||||
result = service.get_template("test_template", "de")
|
||||
|
||||
assert result is not None
|
||||
assert result.language == "en"
|
||||
|
||||
# Cleanup
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
def test_get_template_specific_language(self, db):
|
||||
"""Test get_template returns specific language if available."""
|
||||
# Create templates in both languages
|
||||
template_en = EmailTemplate(
|
||||
code="test_lang_template",
|
||||
language="en",
|
||||
name="Test Template EN",
|
||||
subject="English Subject",
|
||||
body_html="<p>English</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
)
|
||||
template_fr = EmailTemplate(
|
||||
code="test_lang_template",
|
||||
language="fr",
|
||||
name="Test Template FR",
|
||||
subject="French Subject",
|
||||
body_html="<p>Français</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
)
|
||||
db.add(template_en)
|
||||
db.add(template_fr)
|
||||
db.commit()
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
# Request French
|
||||
result = service.get_template("test_lang_template", "fr")
|
||||
|
||||
assert result is not None
|
||||
assert result.language == "fr"
|
||||
assert result.subject == "French Subject"
|
||||
|
||||
# Cleanup
|
||||
db.delete(template_en)
|
||||
db.delete(template_fr)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailSending:
|
||||
"""Test suite for email sending functionality."""
|
||||
|
||||
@patch("app.services.email_service.get_provider")
|
||||
@patch("app.services.email_service.settings")
|
||||
def test_send_raw_success(self, mock_settings, mock_get_provider, db):
|
||||
"""Test successful raw email sending."""
|
||||
# Setup mocks
|
||||
mock_settings.email_enabled = True
|
||||
mock_settings.email_from_address = "noreply@test.com"
|
||||
mock_settings.email_from_name = "Test"
|
||||
mock_settings.email_reply_to = ""
|
||||
mock_settings.email_provider = "smtp"
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.send.return_value = (True, "msg-123", None)
|
||||
mock_get_provider.return_value = mock_provider
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_raw(
|
||||
to_email="user@example.com",
|
||||
to_name="User",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.SENT.value
|
||||
assert log.recipient_email == "user@example.com"
|
||||
assert log.subject == "Test Subject"
|
||||
assert log.provider_message_id == "msg-123"
|
||||
|
||||
@patch("app.services.email_service.get_provider")
|
||||
@patch("app.services.email_service.settings")
|
||||
def test_send_raw_failure(self, mock_settings, mock_get_provider, db):
|
||||
"""Test failed raw email sending."""
|
||||
# Setup mocks
|
||||
mock_settings.email_enabled = True
|
||||
mock_settings.email_from_address = "noreply@test.com"
|
||||
mock_settings.email_from_name = "Test"
|
||||
mock_settings.email_reply_to = ""
|
||||
mock_settings.email_provider = "smtp"
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.send.return_value = (False, None, "Connection refused")
|
||||
mock_get_provider.return_value = mock_provider
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_raw(
|
||||
to_email="user@example.com",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.FAILED.value
|
||||
assert log.error_message == "Connection refused"
|
||||
|
||||
@patch("app.services.email_service.settings")
|
||||
def test_send_raw_email_disabled(self, mock_settings, db):
|
||||
"""Test email sending when disabled."""
|
||||
mock_settings.email_enabled = False
|
||||
mock_settings.email_from_address = "noreply@test.com"
|
||||
mock_settings.email_from_name = "Test"
|
||||
mock_settings.email_reply_to = ""
|
||||
mock_settings.email_provider = "smtp"
|
||||
mock_settings.email_debug = False
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_raw(
|
||||
to_email="user@example.com",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.FAILED.value
|
||||
assert "disabled" in log.error_message.lower()
|
||||
|
||||
@patch("app.services.email_service.get_provider")
|
||||
@patch("app.services.email_service.settings")
|
||||
def test_send_template_success(self, mock_settings, mock_get_provider, db):
|
||||
"""Test successful template email sending."""
|
||||
# Create test template
|
||||
template = EmailTemplate(
|
||||
code="test_send_template",
|
||||
language="en",
|
||||
name="Test Send Template",
|
||||
subject="Hello {{ first_name }}",
|
||||
body_html="<p>Welcome {{ first_name }} to {{ company }}</p>",
|
||||
body_text="Welcome {{ first_name }} to {{ company }}",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
|
||||
# Setup mocks
|
||||
mock_settings.email_enabled = True
|
||||
mock_settings.email_from_address = "noreply@test.com"
|
||||
mock_settings.email_from_name = "Test"
|
||||
mock_settings.email_reply_to = ""
|
||||
mock_settings.email_provider = "smtp"
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.send.return_value = (True, "msg-456", None)
|
||||
mock_get_provider.return_value = mock_provider
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_template(
|
||||
template_code="test_send_template",
|
||||
to_email="user@example.com",
|
||||
language="en",
|
||||
variables={
|
||||
"first_name": "John",
|
||||
"company": "ACME Corp"
|
||||
},
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.SENT.value
|
||||
assert log.template_code == "test_send_template"
|
||||
assert log.subject == "Hello John"
|
||||
|
||||
# Cleanup
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
def test_send_template_not_found(self, db):
|
||||
"""Test sending with non-existent template."""
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_template(
|
||||
template_code="nonexistent_template",
|
||||
to_email="user@example.com",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.FAILED.value
|
||||
assert "not found" in log.error_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailLog:
|
||||
"""Test suite for EmailLog model methods."""
|
||||
|
||||
def test_mark_sent(self, db):
|
||||
"""Test EmailLog.mark_sent method."""
|
||||
log = EmailLog(
|
||||
recipient_email="test@example.com",
|
||||
subject="Test",
|
||||
from_email="noreply@test.com",
|
||||
status=EmailStatus.PENDING.value,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
log.mark_sent("provider-msg-id")
|
||||
|
||||
assert log.status == EmailStatus.SENT.value
|
||||
assert log.sent_at is not None
|
||||
assert log.provider_message_id == "provider-msg-id"
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_mark_failed(self, db):
|
||||
"""Test EmailLog.mark_failed method."""
|
||||
log = EmailLog(
|
||||
recipient_email="test@example.com",
|
||||
subject="Test",
|
||||
from_email="noreply@test.com",
|
||||
status=EmailStatus.PENDING.value,
|
||||
retry_count=0,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
log.mark_failed("Connection timeout")
|
||||
|
||||
assert log.status == EmailStatus.FAILED.value
|
||||
assert log.error_message == "Connection timeout"
|
||||
assert log.retry_count == 1
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_mark_delivered(self, db):
|
||||
"""Test EmailLog.mark_delivered method."""
|
||||
log = EmailLog(
|
||||
recipient_email="test@example.com",
|
||||
subject="Test",
|
||||
from_email="noreply@test.com",
|
||||
status=EmailStatus.SENT.value,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
log.mark_delivered()
|
||||
|
||||
assert log.status == EmailStatus.DELIVERED.value
|
||||
assert log.delivered_at is not None
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_mark_opened(self, db):
|
||||
"""Test EmailLog.mark_opened method."""
|
||||
log = EmailLog(
|
||||
recipient_email="test@example.com",
|
||||
subject="Test",
|
||||
from_email="noreply@test.com",
|
||||
status=EmailStatus.DELIVERED.value,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
log.mark_opened()
|
||||
|
||||
assert log.status == EmailStatus.OPENED.value
|
||||
assert log.opened_at is not None
|
||||
|
||||
db.rollback()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailTemplate:
|
||||
"""Test suite for EmailTemplate model."""
|
||||
|
||||
def test_variables_list_property(self, db):
|
||||
"""Test EmailTemplate.variables_list property."""
|
||||
template = EmailTemplate(
|
||||
code="test_vars",
|
||||
language="en",
|
||||
name="Test",
|
||||
subject="Test",
|
||||
body_html="<p>Test</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
variables=json.dumps(["first_name", "last_name", "email"]),
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
assert template.variables_list == ["first_name", "last_name", "email"]
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_variables_list_empty(self, db):
|
||||
"""Test EmailTemplate.variables_list with no variables."""
|
||||
template = EmailTemplate(
|
||||
code="test_no_vars",
|
||||
language="en",
|
||||
name="Test",
|
||||
subject="Test",
|
||||
body_html="<p>Test</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
variables=None,
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
assert template.variables_list == []
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_variables_list_invalid_json(self, db):
|
||||
"""Test EmailTemplate.variables_list with invalid JSON."""
|
||||
template = EmailTemplate(
|
||||
code="test_invalid_json",
|
||||
language="en",
|
||||
name="Test",
|
||||
subject="Test",
|
||||
body_html="<p>Test</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
variables="not valid json",
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
assert template.variables_list == []
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_template_repr(self, db):
|
||||
"""Test EmailTemplate string representation."""
|
||||
template = EmailTemplate(
|
||||
code="signup_welcome",
|
||||
language="en",
|
||||
name="Welcome",
|
||||
subject="Welcome",
|
||||
body_html="<p>Welcome</p>",
|
||||
category=EmailCategory.AUTH.value,
|
||||
)
|
||||
|
||||
assert "signup_welcome" in repr(template)
|
||||
assert "en" in repr(template)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestSignupWelcomeEmail:
|
||||
"""Test suite for signup welcome email integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def welcome_template(self, db):
|
||||
"""Create a welcome template for testing."""
|
||||
import json
|
||||
|
||||
template = EmailTemplate(
|
||||
code="signup_welcome",
|
||||
language="en",
|
||||
name="Signup Welcome",
|
||||
subject="Welcome {{ first_name }}!",
|
||||
body_html="<p>Welcome {{ first_name }} to {{ company_name }}</p>",
|
||||
body_text="Welcome {{ first_name }} to {{ company_name }}",
|
||||
category=EmailCategory.AUTH.value,
|
||||
variables=json.dumps([
|
||||
"first_name", "company_name", "email", "vendor_code",
|
||||
"login_url", "trial_days", "tier_name"
|
||||
]),
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
|
||||
yield template
|
||||
|
||||
# Cleanup
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
def test_welcome_template_rendering(self, db, welcome_template):
|
||||
"""Test that welcome template renders correctly."""
|
||||
service = EmailService(db)
|
||||
|
||||
template = service.get_template("signup_welcome", "en")
|
||||
assert template is not None
|
||||
assert template.code == "signup_welcome"
|
||||
|
||||
# Test rendering
|
||||
rendered = service.render_template(
|
||||
template.subject,
|
||||
{"first_name": "John"}
|
||||
)
|
||||
assert rendered == "Welcome John!"
|
||||
|
||||
def test_welcome_template_has_required_variables(self, db, welcome_template):
|
||||
"""Test welcome template has all required variables."""
|
||||
template = (
|
||||
db.query(EmailTemplate)
|
||||
.filter(
|
||||
EmailTemplate.code == "signup_welcome",
|
||||
EmailTemplate.language == "en",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
assert template is not None
|
||||
|
||||
required_vars = [
|
||||
"first_name",
|
||||
"company_name",
|
||||
"vendor_code",
|
||||
"login_url",
|
||||
"trial_days",
|
||||
"tier_name",
|
||||
]
|
||||
|
||||
for var in required_vars:
|
||||
assert var in template.variables_list, f"Missing variable: {var}"
|
||||
|
||||
@patch("app.services.email_service.get_provider")
|
||||
@patch("app.services.email_service.settings")
|
||||
def test_welcome_email_send(self, mock_settings, mock_get_provider, db, welcome_template):
|
||||
"""Test sending welcome email."""
|
||||
# Setup mocks
|
||||
mock_settings.email_enabled = True
|
||||
mock_settings.email_from_address = "noreply@test.com"
|
||||
mock_settings.email_from_name = "Test"
|
||||
mock_settings.email_reply_to = ""
|
||||
mock_settings.email_provider = "smtp"
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.send.return_value = (True, "welcome-msg-123", None)
|
||||
mock_get_provider.return_value = mock_provider
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_template(
|
||||
template_code="signup_welcome",
|
||||
to_email="newuser@example.com",
|
||||
to_name="John Doe",
|
||||
language="en",
|
||||
variables={
|
||||
"first_name": "John",
|
||||
"company_name": "ACME Corp",
|
||||
"email": "newuser@example.com",
|
||||
"vendor_code": "ACME",
|
||||
"login_url": "https://wizamart.com/vendor/ACME/dashboard",
|
||||
"trial_days": 30,
|
||||
"tier_name": "Essential",
|
||||
},
|
||||
vendor_id=1,
|
||||
user_id=1,
|
||||
related_type="signup",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.SENT.value
|
||||
assert log.template_code == "signup_welcome"
|
||||
assert log.subject == "Welcome John!"
|
||||
assert log.recipient_email == "newuser@example.com"
|
||||
Reference in New Issue
Block a user