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>
573 lines
17 KiB
Python
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,
|
|
)
|