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:
2025-12-27 21:05:50 +01:00
parent 98d082699c
commit 64fd8b5194
11 changed files with 2540 additions and 0 deletions

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