# 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="

Hello

", ) """ 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, )