Files
orion/models/database/email.py
Samir Boulahtit 64fd8b5194 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>
2025-12-27 21:05:50 +01:00

193 lines
5.9 KiB
Python

# models/database/email.py
"""
Email system database models.
Provides:
- EmailTemplate: Multi-language email templates stored in database
- EmailLog: Email sending history and tracking
"""
import enum
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from .base import TimestampMixin
class EmailCategory(str, enum.Enum):
"""Email template categories."""
AUTH = "auth" # signup, password reset, verification
ORDERS = "orders" # order confirmations, shipping
BILLING = "billing" # invoices, payment failures
SYSTEM = "system" # team invites, notifications
MARKETING = "marketing" # newsletters, promotions
class EmailStatus(str, enum.Enum):
"""Email sending status."""
PENDING = "pending"
SENT = "sent"
FAILED = "failed"
BOUNCED = "bounced"
DELIVERED = "delivered"
OPENED = "opened"
CLICKED = "clicked"
class EmailTemplate(Base, TimestampMixin):
"""
Multi-language email templates.
Templates use Jinja2 syntax for variable interpolation.
Each template can have multiple language versions.
"""
__tablename__ = "email_templates"
id = Column(Integer, primary_key=True, index=True)
# Template identification
code = Column(String(100), nullable=False, index=True) # e.g., "signup_welcome"
language = Column(String(5), nullable=False, default="en") # e.g., "en", "fr", "de", "lb"
# Template metadata
name = Column(String(255), nullable=False) # Human-readable name
description = Column(Text, nullable=True) # Template purpose description
category = Column(
String(50), default=EmailCategory.SYSTEM.value, nullable=False, index=True
)
# Email content
subject = Column(String(500), nullable=False) # Subject line (supports variables)
body_html = Column(Text, nullable=False) # HTML body
body_text = Column(Text, nullable=True) # Plain text fallback
# Template variables (JSON list of expected variables)
# e.g., ["first_name", "company_name", "login_url"]
variables = Column(Text, nullable=True)
# Status
is_active = Column(Boolean, default=True, nullable=False)
# Unique constraint: one template per code+language
__table_args__ = (
{"sqlite_autoincrement": True},
)
def __repr__(self):
return f"<EmailTemplate(code='{self.code}', language='{self.language}')>"
@property
def variables_list(self) -> list[str]:
"""Parse variables JSON to list."""
import json
if not self.variables:
return []
try:
return json.loads(self.variables)
except (json.JSONDecodeError, TypeError):
return []
class EmailLog(Base, TimestampMixin):
"""
Email sending history and tracking.
Logs all sent emails for debugging, analytics, and compliance.
"""
__tablename__ = "email_logs"
id = Column(Integer, primary_key=True, index=True)
# Template reference
template_code = Column(String(100), nullable=True, index=True)
template_id = Column(Integer, ForeignKey("email_templates.id"), nullable=True)
# Recipient info
recipient_email = Column(String(255), nullable=False, index=True)
recipient_name = Column(String(255), nullable=True)
# Email content (snapshot at send time)
subject = Column(String(500), nullable=False)
body_html = Column(Text, nullable=True)
body_text = Column(Text, nullable=True)
# Sending info
from_email = Column(String(255), nullable=False)
from_name = Column(String(255), nullable=True)
reply_to = Column(String(255), nullable=True)
# Status tracking
status = Column(
String(20), default=EmailStatus.PENDING.value, nullable=False, index=True
)
sent_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
opened_at = Column(DateTime, nullable=True)
clicked_at = Column(DateTime, nullable=True)
# Error handling
error_message = Column(Text, nullable=True)
retry_count = Column(Integer, default=0, nullable=False)
# Provider info
provider = Column(String(50), nullable=True) # smtp, sendgrid, mailgun, ses
provider_message_id = Column(String(255), nullable=True, index=True)
# Context linking (optional - link to related entities)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
related_type = Column(String(50), nullable=True) # e.g., "order", "subscription"
related_id = Column(Integer, nullable=True)
# Extra data (JSON for additional context)
extra_data = Column(Text, nullable=True)
# Relationships
template = relationship("EmailTemplate", foreign_keys=[template_id])
vendor = relationship("Vendor", foreign_keys=[vendor_id])
user = relationship("User", foreign_keys=[user_id])
def __repr__(self):
return f"<EmailLog(id={self.id}, recipient='{self.recipient_email}', status='{self.status}')>"
def mark_sent(self, provider_message_id: str | None = None):
"""Mark email as sent."""
self.status = EmailStatus.SENT.value
self.sent_at = datetime.utcnow()
if provider_message_id:
self.provider_message_id = provider_message_id
def mark_failed(self, error_message: str):
"""Mark email as failed."""
self.status = EmailStatus.FAILED.value
self.error_message = error_message
self.retry_count += 1
def mark_delivered(self):
"""Mark email as delivered."""
self.status = EmailStatus.DELIVERED.value
self.delivered_at = datetime.utcnow()
def mark_opened(self):
"""Mark email as opened."""
self.status = EmailStatus.OPENED.value
self.opened_at = datetime.utcnow()