Files
orion/app/services/email_service.py
Samir Boulahtit c9a7850b37 fix: add noqa comments for email service db.commit() calls
Email logs are side effects that need immediate persistence,
so db.commit() is intentional in these cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 22:08:40 +01:00

573 lines
17 KiB
Python

# app/services/email_service.py
"""
Email service with multi-provider support.
Supports:
- SMTP (default)
- SendGrid
- Mailgun
- Amazon SES
Features:
- Multi-language templates from database
- Jinja2 template rendering
- Email logging and tracking
- Queue support via background tasks
"""
import json
import logging
import smtplib
from abc import ABC, abstractmethod
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
from jinja2 import Environment, BaseLoader
from sqlalchemy.orm import Session
from app.core.config import settings
from models.database.email import EmailLog, EmailStatus, EmailTemplate
logger = logging.getLogger(__name__)
# =============================================================================
# EMAIL PROVIDER ABSTRACTION
# =============================================================================
class EmailProvider(ABC):
"""Abstract base class for email providers."""
@abstractmethod
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
"""
Send an email.
Returns:
tuple: (success, provider_message_id, error_message)
"""
pass
class SMTPProvider(EmailProvider):
"""SMTP email provider."""
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
if reply_to:
msg["Reply-To"] = reply_to
# Attach text and HTML parts
if body_text:
msg.attach(MIMEText(body_text, "plain", "utf-8"))
msg.attach(MIMEText(body_html, "html", "utf-8"))
# Connect and send
if settings.smtp_use_ssl:
server = smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port)
else:
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port)
try:
if settings.smtp_use_tls and not settings.smtp_use_ssl:
server.starttls()
if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password)
server.sendmail(from_email, [to_email], msg.as_string())
return True, None, None
finally:
server.quit()
except Exception as e:
logger.error(f"SMTP send error: {e}")
return False, None, str(e)
class SendGridProvider(EmailProvider):
"""SendGrid email provider."""
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Email, To, Content
message = Mail(
from_email=Email(from_email, from_name),
to_emails=To(to_email, to_name),
subject=subject,
)
message.add_content(Content("text/html", body_html))
if body_text:
message.add_content(Content("text/plain", body_text))
if reply_to:
message.reply_to = Email(reply_to)
sg = SendGridAPIClient(settings.sendgrid_api_key)
response = sg.send(message)
if response.status_code in (200, 201, 202):
message_id = response.headers.get("X-Message-Id")
return True, message_id, None
else:
return False, None, f"SendGrid error: {response.status_code}"
except ImportError:
return False, None, "SendGrid library not installed. Run: pip install sendgrid"
except Exception as e:
logger.error(f"SendGrid send error: {e}")
return False, None, str(e)
class MailgunProvider(EmailProvider):
"""Mailgun email provider."""
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import requests
from_str = f"{from_name} <{from_email}>" if from_name else from_email
to_str = f"{to_name} <{to_email}>" if to_name else to_email
data = {
"from": from_str,
"to": to_str,
"subject": subject,
"html": body_html,
}
if body_text:
data["text"] = body_text
if reply_to:
data["h:Reply-To"] = reply_to
response = requests.post(
f"https://api.mailgun.net/v3/{settings.mailgun_domain}/messages",
auth=("api", settings.mailgun_api_key),
data=data,
timeout=30,
)
if response.status_code == 200:
result = response.json()
return True, result.get("id"), None
else:
return False, None, f"Mailgun error: {response.status_code} - {response.text}"
except Exception as e:
logger.error(f"Mailgun send error: {e}")
return False, None, str(e)
class SESProvider(EmailProvider):
"""Amazon SES email provider."""
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import boto3
ses = boto3.client(
"ses",
region_name=settings.aws_region,
aws_access_key_id=settings.aws_access_key_id,
aws_secret_access_key=settings.aws_secret_access_key,
)
from_str = f"{from_name} <{from_email}>" if from_name else from_email
body = {"Html": {"Charset": "UTF-8", "Data": body_html}}
if body_text:
body["Text"] = {"Charset": "UTF-8", "Data": body_text}
kwargs = {
"Source": from_str,
"Destination": {"ToAddresses": [to_email]},
"Message": {
"Subject": {"Charset": "UTF-8", "Data": subject},
"Body": body,
},
}
if reply_to:
kwargs["ReplyToAddresses"] = [reply_to]
response = ses.send_email(**kwargs)
return True, response.get("MessageId"), None
except ImportError:
return False, None, "boto3 library not installed. Run: pip install boto3"
except Exception as e:
logger.error(f"SES send error: {e}")
return False, None, str(e)
class DebugProvider(EmailProvider):
"""Debug provider - logs emails instead of sending."""
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
logger.info(
f"\n{'='*60}\n"
f"DEBUG EMAIL\n"
f"{'='*60}\n"
f"To: {to_name} <{to_email}>\n"
f"From: {from_name} <{from_email}>\n"
f"Reply-To: {reply_to}\n"
f"Subject: {subject}\n"
f"{'='*60}\n"
f"Body (text):\n{body_text or '(none)'}\n"
f"{'='*60}\n"
f"Body (html):\n{body_html[:500]}...\n"
f"{'='*60}\n"
)
return True, f"debug-{to_email}", None
# =============================================================================
# EMAIL SERVICE
# =============================================================================
def get_provider() -> EmailProvider:
"""Get the configured email provider."""
if settings.email_debug:
return DebugProvider()
provider_map = {
"smtp": SMTPProvider,
"sendgrid": SendGridProvider,
"mailgun": MailgunProvider,
"ses": SESProvider,
}
provider_class = provider_map.get(settings.email_provider.lower())
if not provider_class:
logger.warning(f"Unknown email provider: {settings.email_provider}, using SMTP")
return SMTPProvider()
return provider_class()
class EmailService:
"""
Email service for sending templated emails.
Usage:
email_service = EmailService(db)
# Send using database template
email_service.send_template(
template_code="signup_welcome",
language="en",
to_email="user@example.com",
to_name="John Doe",
variables={"first_name": "John", "login_url": "https://..."},
vendor_id=1,
)
# Send raw email
email_service.send_raw(
to_email="user@example.com",
subject="Hello",
body_html="<h1>Hello</h1>",
)
"""
def __init__(self, db: Session):
self.db = db
self.provider = get_provider()
self.jinja_env = Environment(loader=BaseLoader())
def get_template(
self, template_code: str, language: str = "en"
) -> EmailTemplate | None:
"""Get email template from database with fallback to English."""
template = (
self.db.query(EmailTemplate)
.filter(
EmailTemplate.code == template_code,
EmailTemplate.language == language,
EmailTemplate.is_active == True,
)
.first()
)
# Fallback to English if not found
if not template and language != "en":
template = (
self.db.query(EmailTemplate)
.filter(
EmailTemplate.code == template_code,
EmailTemplate.language == "en",
EmailTemplate.is_active == True,
)
.first()
)
return template
def render_template(self, template_string: str, variables: dict[str, Any]) -> str:
"""Render a Jinja2 template string with variables."""
try:
template = self.jinja_env.from_string(template_string)
return template.render(**variables)
except Exception as e:
logger.error(f"Template rendering error: {e}")
return template_string
def send_template(
self,
template_code: str,
to_email: str,
to_name: str | None = None,
language: str = "en",
variables: dict[str, Any] | None = None,
vendor_id: int | None = None,
user_id: int | None = None,
related_type: str | None = None,
related_id: int | None = None,
) -> EmailLog:
"""
Send an email using a database template.
Args:
template_code: Template code (e.g., "signup_welcome")
to_email: Recipient email address
to_name: Recipient name (optional)
language: Language code (default: "en")
variables: Template variables dict
vendor_id: Related vendor ID for logging
user_id: Related user ID for logging
related_type: Related entity type (e.g., "order")
related_id: Related entity ID
Returns:
EmailLog record
"""
variables = variables or {}
# Get template
template = self.get_template(template_code, language)
if not template:
logger.error(f"Email template not found: {template_code} ({language})")
# Create failed log entry
log = EmailLog(
template_code=template_code,
recipient_email=to_email,
recipient_name=to_name,
subject=f"[Template not found: {template_code}]",
from_email=settings.email_from_address,
from_name=settings.email_from_name,
status=EmailStatus.FAILED.value,
error_message=f"Template not found: {template_code} ({language})",
provider=settings.email_provider,
vendor_id=vendor_id,
user_id=user_id,
related_type=related_type,
related_id=related_id,
)
self.db.add(log)
self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately
return log
# Render template
subject = self.render_template(template.subject, variables)
body_html = self.render_template(template.body_html, variables)
body_text = (
self.render_template(template.body_text, variables)
if template.body_text
else None
)
return self.send_raw(
to_email=to_email,
to_name=to_name,
subject=subject,
body_html=body_html,
body_text=body_text,
template_code=template_code,
template_id=template.id,
vendor_id=vendor_id,
user_id=user_id,
related_type=related_type,
related_id=related_id,
extra_data=json.dumps(variables) if variables else None,
)
def send_raw(
self,
to_email: str,
subject: str,
body_html: str,
to_name: str | None = None,
body_text: str | None = None,
from_email: str | None = None,
from_name: str | None = None,
reply_to: str | None = None,
template_code: str | None = None,
template_id: int | None = None,
vendor_id: int | None = None,
user_id: int | None = None,
related_type: str | None = None,
related_id: int | None = None,
extra_data: str | None = None,
) -> EmailLog:
"""
Send a raw email without using a template.
Returns:
EmailLog record
"""
from_email = from_email or settings.email_from_address
from_name = from_name or settings.email_from_name
reply_to = reply_to or settings.email_reply_to or None
# Create log entry
log = EmailLog(
template_code=template_code,
template_id=template_id,
recipient_email=to_email,
recipient_name=to_name,
subject=subject,
body_html=body_html,
body_text=body_text,
from_email=from_email,
from_name=from_name,
reply_to=reply_to,
status=EmailStatus.PENDING.value,
provider=settings.email_provider,
vendor_id=vendor_id,
user_id=user_id,
related_type=related_type,
related_id=related_id,
extra_data=extra_data,
)
self.db.add(log)
self.db.flush()
# Check if emails are disabled
if not settings.email_enabled:
log.status = EmailStatus.FAILED.value
log.error_message = "Email sending is disabled"
self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately
logger.info(f"Email sending disabled, skipping: {to_email}")
return log
# Send email
success, message_id, error = self.provider.send(
to_email=to_email,
to_name=to_name,
subject=subject,
body_html=body_html,
body_text=body_text,
from_email=from_email,
from_name=from_name,
reply_to=reply_to,
)
if success:
log.mark_sent(message_id)
logger.info(f"Email sent to {to_email}: {subject}")
else:
log.mark_failed(error or "Unknown error")
logger.error(f"Email failed to {to_email}: {error}")
self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately
return log
# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================
def send_email(
db: Session,
template_code: str,
to_email: str,
to_name: str | None = None,
language: str = "en",
variables: dict[str, Any] | None = None,
**kwargs,
) -> EmailLog:
"""Convenience function to send a templated email."""
service = EmailService(db)
return service.send_template(
template_code=template_code,
to_email=to_email,
to_name=to_name,
language=language,
variables=variables,
**kwargs,
)