import asyncio import re import smtplib from email.message import EmailMessage from email.utils import formataddr from pathlib import Path from string import Template from typing import Any, Dict, List, Optional from domain.config import ConfigService from .types import EmailConfig, EmailSecurity, EmailSendPayload class EmailTemplateRenderer: ROOT = Path("templates/email") @classmethod def _resolve_path(cls, template_name: str) -> Path: if not re.fullmatch(r"[A-Za-z0-9_\-]+", template_name): raise ValueError("Invalid template name") return cls.ROOT / f"{template_name}.html" @classmethod async def list_templates(cls) -> list[str]: cls.ROOT.mkdir(parents=True, exist_ok=True) return sorted( path.stem for path in cls.ROOT.glob("*.html") if path.is_file() ) @classmethod async def load(cls, template_name: str) -> str: path = cls._resolve_path(template_name) if not path.is_file(): raise FileNotFoundError(f"Email template '{template_name}' not found") return await asyncio.to_thread(path.read_text, encoding="utf-8") @classmethod async def save(cls, template_name: str, content: str) -> None: path = cls._resolve_path(template_name) path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(path.write_text, content, encoding="utf-8") @classmethod async def render(cls, template_name: str, context: Dict[str, Any]) -> str: raw = await cls.load(template_name) context = {k: str(v) for k, v in (context or {}).items()} return Template(raw).safe_substitute(context) class EmailService: CONFIG_KEY = "EMAIL_CONFIG" @classmethod async def _load_config(cls) -> EmailConfig: raw_config = await ConfigService.get(cls.CONFIG_KEY) return EmailConfig.parse_config(raw_config) @staticmethod def _html_to_text(html: str) -> str: stripped = re.sub(r"<[^>]+>", " ", html) return " ".join(stripped.split()) @classmethod async def _deliver(cls, config: EmailConfig, payload: EmailSendPayload, html_body: str): message = EmailMessage() message["Subject"] = payload.subject message["From"] = formataddr( (config.sender_name or str(config.sender_email), str(config.sender_email)) ) message["To"] = ", ".join([str(addr) for addr in payload.recipients]) plain_body = cls._html_to_text(html_body) message.set_content(plain_body or html_body) message.add_alternative(html_body, subtype="html") await asyncio.to_thread(cls._deliver_sync, config, message) @staticmethod def _deliver_sync(config: EmailConfig, message: EmailMessage): if config.security == EmailSecurity.SSL: smtp: smtplib.SMTP = smtplib.SMTP_SSL( config.host, config.port, timeout=config.timeout ) else: smtp = smtplib.SMTP(config.host, config.port, timeout=config.timeout) try: if config.security == EmailSecurity.STARTTLS: smtp.starttls() if config.username and config.password: smtp.login(config.username, config.password) smtp.send_message(message) finally: try: smtp.quit() except Exception: pass @classmethod async def enqueue_email( cls, recipients: List[str], subject: str, template: str, context: Optional[Dict[str, Any]] = None, ): from domain.tasks import TaskProgress, task_queue_service payload = EmailSendPayload( recipients=recipients, subject=subject, template=template, context=context or {}, ) task = await task_queue_service.add_task( "send_email", payload.model_dump(mode="json"), ) await task_queue_service.update_progress( task.id, TaskProgress(stage="queued", percent=0.0, detail="Waiting to send"), ) return task @classmethod async def send_from_task(cls, task_id: str, data: Dict[str, Any]): from domain.tasks import TaskProgress, task_queue_service payload = EmailSendPayload(**data) await task_queue_service.update_progress( task_id, TaskProgress(stage="preparing", percent=10.0, detail="Rendering template"), ) config = await cls._load_config() html_body = await EmailTemplateRenderer.render(payload.template, payload.context) await task_queue_service.update_progress( task_id, TaskProgress(stage="sending", percent=60.0, detail="Sending message"), ) await cls._deliver(config, payload, html_body) await task_queue_service.update_progress( task_id, TaskProgress(stage="completed", percent=100.0, detail="Email sent"), )