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