# app/modules/messaging/services/email_template_service.py """ Email Template Service Handles business logic for email template management: - Platform template CRUD operations - Store template override management - Template preview and testing - Email log queries This service layer separates business logic from API endpoints to follow the project's layered architecture. """ import logging from dataclasses import dataclass from typing import Any from jinja2 import BaseLoader, Environment, TemplateError from sqlalchemy import func from sqlalchemy.orm import Session from app.exceptions.base import ( AuthorizationException, ResourceNotFoundException, ValidationException, ) from app.modules.messaging.models import ( EmailCategory, EmailLog, EmailTemplate, StoreEmailTemplate, ) logger = logging.getLogger(__name__) _jinja_env = Environment(loader=BaseLoader(), autoescape=True) # Supported languages SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"] @dataclass class TemplateData: """Template data container.""" code: str language: str name: str description: str | None category: str subject: str body_html: str body_text: str | None variables: list[str] required_variables: list[str] is_platform_only: bool @dataclass class StoreOverrideData: """Store override data container.""" code: str language: str subject: str body_html: str body_text: str | None name: str | None updated_at: str | None class EmailTemplateService: """Service for managing email templates.""" def __init__(self, db: Session): self.db = db # ========================================================================= # ADMIN OPERATIONS # ========================================================================= def list_platform_templates(self) -> list[dict[str, Any]]: """ List all platform email templates grouped by code. Returns: List of template summaries with language availability. """ templates = ( self.db.query(EmailTemplate) .order_by(EmailTemplate.category, EmailTemplate.code) .all() ) # Group by code grouped: dict[str, dict] = {} for template in templates: if template.code not in grouped: grouped[template.code] = { "code": template.code, "name": template.name, "category": template.category, "description": template.description, "is_platform_only": template.is_platform_only, "languages": [], "variables": [], } grouped[template.code]["languages"].append(template.language) if template.variables and not grouped[template.code]["variables"]: try: import json grouped[template.code]["variables"] = json.loads(template.variables) except (json.JSONDecodeError, TypeError): pass return list(grouped.values()) def get_template_categories(self) -> list[str]: """Get list of all template categories.""" return [cat.value for cat in EmailCategory] def get_platform_template(self, code: str) -> dict[str, Any]: """ Get a platform template with all language versions. Args: code: Template code Returns: Template details with all language versions Raises: NotFoundError: If template not found """ templates = ( self.db.query(EmailTemplate) .filter(EmailTemplate.code == code) .all() ) if not templates: raise ResourceNotFoundException(f"Template not found: {code}") first = templates[0] languages = {} for t in templates: languages[t.language] = { "subject": t.subject, "body_html": t.body_html, "body_text": t.body_text, } return { "code": code, "name": first.name, "description": first.description, "category": first.category, "is_platform_only": first.is_platform_only, "variables": self._parse_variables(first.variables), "required_variables": self._parse_required_variables(first.required_variables), "languages": languages, } def get_platform_template_language(self, code: str, language: str) -> TemplateData: """ Get a specific language version of a platform template. Args: code: Template code language: Language code Returns: Template data for the specific language Raises: NotFoundError: If template or language not found """ template = EmailTemplate.get_by_code_and_language(self.db, code, language) if not template: raise ResourceNotFoundException(f"Template not found: {code}/{language}") return TemplateData( code=template.code, language=template.language, name=template.name, description=template.description, category=template.category, subject=template.subject, body_html=template.body_html, body_text=template.body_text, variables=self._parse_variables(template.variables), required_variables=self._parse_required_variables(template.required_variables), is_platform_only=template.is_platform_only, ) def update_platform_template( self, code: str, language: str, subject: str, body_html: str, body_text: str | None = None, ) -> None: """ Update a platform email template. Args: code: Template code language: Language code subject: New subject line body_html: New HTML body body_text: New plain text body (optional) Raises: NotFoundError: If template not found ValidationError: If template syntax is invalid """ template = EmailTemplate.get_by_code_and_language(self.db, code, language) if not template: raise ResourceNotFoundException(f"Template not found: {code}/{language}") # Validate Jinja2 syntax self._validate_template_syntax(subject, body_html, body_text) template.subject = subject template.body_html = body_html template.body_text = body_text logger.info(f"Updated platform template: {code}/{language}") def preview_template( self, code: str, language: str, variables: dict[str, Any], ) -> dict[str, str]: """ Preview a template with sample variables. Args: code: Template code language: Language code variables: Variables to render Returns: Rendered subject and body Raises: NotFoundError: If template not found ValidationError: If rendering fails """ template = EmailTemplate.get_by_code_and_language(self.db, code, language) if not template: raise ResourceNotFoundException(f"Template not found: {code}/{language}") try: rendered_subject = _jinja_env.from_string(template.subject).render(variables) rendered_html = _jinja_env.from_string(template.body_html).render(variables) rendered_text = _jinja_env.from_string(template.body_text).render(variables) if template.body_text else None except TemplateError as e: raise ValidationException(f"Template rendering error: {str(e)}") return { "subject": rendered_subject, "body_html": rendered_html, "body_text": rendered_text, } def get_template_logs( self, code: str, limit: int = 50, offset: int = 0, ) -> tuple[list[dict], int]: """ Get email logs for a specific template. Args: code: Template code limit: Max results offset: Skip results Returns: Tuple of (logs list, total count) """ query = ( self.db.query(EmailLog) .filter(EmailLog.template_code == code) .order_by(EmailLog.created_at.desc()) ) total = query.count() logs = query.offset(offset).limit(limit).all() return ( [ { "id": log.id, "to_email": log.to_email, "status": log.status, "language": log.language, "created_at": log.created_at.isoformat() if log.created_at else None, "error_message": log.error_message, } for log in logs ], total, ) # ========================================================================= # STORE OPERATIONS # ========================================================================= def list_overridable_templates(self, store_id: int) -> dict[str, Any]: """ List all templates that a store can customize. Args: store_id: Store ID Returns: Dict with templates list and supported languages """ # Get all overridable platform templates platform_templates = EmailTemplate.get_overridable_templates(self.db) # Get all store overrides store_overrides = StoreEmailTemplate.get_all_overrides_for_store( self.db, store_id ) # Build override lookup override_lookup = {} for override in store_overrides: key = (override.template_code, override.language) override_lookup[key] = override # Build response templates = [] for template in platform_templates: # Check which languages have overrides override_languages = [] for lang in SUPPORTED_LANGUAGES: if (template.code, lang) in override_lookup: override_languages.append(lang) templates.append({ "code": template.code, "name": template.name, "category": template.category, "description": template.description, "available_languages": self._get_template_languages(template.code), "override_languages": override_languages, "has_override": len(override_languages) > 0, "variables": self._parse_required_variables(template.required_variables), }) return { "templates": templates, "supported_languages": SUPPORTED_LANGUAGES, } def get_store_template(self, store_id: int, code: str) -> dict[str, Any]: """ Get a template with all language versions for a store. Args: store_id: Store ID code: Template code Returns: Template details with store overrides status Raises: NotFoundError: If template not found ForbiddenError: If template is platform-only """ # Get platform template platform_template = ( self.db.query(EmailTemplate) .filter(EmailTemplate.code == code) .first() ) if not platform_template: raise ResourceNotFoundException(f"Template not found: {code}") if platform_template.is_platform_only: raise AuthorizationException("This is a platform-only template and cannot be customized") # Get all language versions platform_versions = ( self.db.query(EmailTemplate) .filter(EmailTemplate.code == code) .all() ) # Get store overrides store_overrides = ( self.db.query(StoreEmailTemplate) .filter( StoreEmailTemplate.store_id == store_id, StoreEmailTemplate.template_code == code, ) .all() ) override_lookup = {v.language: v for v in store_overrides} platform_lookup = {t.language: t for t in platform_versions} # Build language versions languages = {} for lang in SUPPORTED_LANGUAGES: platform_ver = platform_lookup.get(lang) override_ver = override_lookup.get(lang) languages[lang] = { "has_platform_template": platform_ver is not None, "has_store_override": override_ver is not None, "platform": { "subject": platform_ver.subject, "body_html": platform_ver.body_html, "body_text": platform_ver.body_text, } if platform_ver else None, "store_override": { "subject": override_ver.subject, "body_html": override_ver.body_html, "body_text": override_ver.body_text, "name": override_ver.name, "updated_at": override_ver.updated_at.isoformat() if override_ver else None, } if override_ver else None, } return { "code": code, "name": platform_template.name, "category": platform_template.category, "description": platform_template.description, "variables": self._parse_required_variables(platform_template.required_variables), "languages": languages, } def get_store_template_language( self, store_id: int, code: str, language: str, ) -> dict[str, Any]: """ Get a specific language version for a store (override or platform). Args: store_id: Store ID code: Template code language: Language code Returns: Template data with source indicator Raises: NotFoundError: If template not found ForbiddenError: If template is platform-only ValidationError: If language not supported """ if language not in SUPPORTED_LANGUAGES: raise ValidationException(f"Unsupported language: {language}") # Check if template is overridable platform_template = ( self.db.query(EmailTemplate) .filter(EmailTemplate.code == code) .first() ) if not platform_template: raise ResourceNotFoundException(f"Template not found: {code}") if platform_template.is_platform_only: raise AuthorizationException("This is a platform-only template and cannot be customized") # Check for store override store_override = StoreEmailTemplate.get_override( self.db, store_id, code, language ) # Get platform version platform_version = EmailTemplate.get_by_code_and_language( self.db, code, language ) if store_override: return { "code": code, "language": language, "source": "store_override", "subject": store_override.subject, "body_html": store_override.body_html, "body_text": store_override.body_text, "name": store_override.name, "variables": self._parse_required_variables(platform_template.required_variables), "platform_template": { "subject": platform_version.subject, "body_html": platform_version.body_html, } if platform_version else None, } if platform_version: return { "code": code, "language": language, "source": "platform", "subject": platform_version.subject, "body_html": platform_version.body_html, "body_text": platform_version.body_text, "name": platform_version.name, "variables": self._parse_required_variables(platform_template.required_variables), "platform_template": None, } raise ResourceNotFoundException(f"No template found for language: {language}") def create_or_update_store_override( self, store_id: int, code: str, language: str, subject: str, body_html: str, body_text: str | None = None, name: str | None = None, ) -> dict[str, Any]: """ Create or update a store template override. Args: store_id: Store ID code: Template code language: Language code subject: Custom subject body_html: Custom HTML body body_text: Custom plain text body name: Custom template name Returns: Result with is_new indicator Raises: NotFoundError: If template not found ForbiddenError: If template is platform-only ValidationError: If syntax invalid or language not supported """ if language not in SUPPORTED_LANGUAGES: raise ValidationException(f"Unsupported language: {language}") # Check if template exists and is overridable platform_template = ( self.db.query(EmailTemplate) .filter(EmailTemplate.code == code) .first() ) if not platform_template: raise ResourceNotFoundException(f"Template not found: {code}") if platform_template.is_platform_only: raise AuthorizationException("This is a platform-only template and cannot be customized") # Validate template syntax self._validate_template_syntax(subject, body_html, body_text) # Create or update override = StoreEmailTemplate.create_or_update( db=self.db, store_id=store_id, template_code=code, language=language, subject=subject, body_html=body_html, body_text=body_text, name=name, ) logger.info(f"Store {store_id} updated template override: {code}/{language}") return { "message": "Template override saved", "code": code, "language": language, "is_new": override.created_at == override.updated_at, } def delete_store_override( self, store_id: int, code: str, language: str, ) -> None: """ Delete a store template override. Args: store_id: Store ID code: Template code language: Language code Raises: NotFoundError: If override not found ValidationError: If language not supported """ if language not in SUPPORTED_LANGUAGES: raise ValidationException(f"Unsupported language: {language}") deleted = StoreEmailTemplate.delete_override( self.db, store_id, code, language ) if not deleted: raise ResourceNotFoundException("No override found for this template and language") logger.info(f"Store {store_id} deleted template override: {code}/{language}") def preview_store_template( self, store_id: int, code: str, language: str, variables: dict[str, Any], ) -> dict[str, Any]: """ Preview a store template (override or platform). Args: store_id: Store ID code: Template code language: Language code variables: Variables to render Returns: Rendered template with source indicator Raises: NotFoundError: If template not found ValidationError: If rendering fails """ # Get template content store_override = StoreEmailTemplate.get_override( self.db, store_id, code, language ) platform_version = EmailTemplate.get_by_code_and_language( self.db, code, language ) if store_override: subject = store_override.subject body_html = store_override.body_html body_text = store_override.body_text source = "store_override" elif platform_version: subject = platform_version.subject body_html = platform_version.body_html body_text = platform_version.body_text source = "platform" else: raise ResourceNotFoundException(f"No template found for language: {language}") try: rendered_subject = _jinja_env.from_string(subject).render(variables) rendered_html = _jinja_env.from_string(body_html).render(variables) rendered_text = _jinja_env.from_string(body_text).render(variables) if body_text else None except TemplateError as e: raise ValidationException(f"Template rendering error: {str(e)}") return { "source": source, "language": language, "subject": rendered_subject, "body_html": rendered_html, "body_text": rendered_text, } # ========================================================================= # EMAIL LOG OPERATIONS (Audit) # ========================================================================= def get_email_logs( self, filters: dict[str, Any] | None = None, skip: int = 0, limit: int = 50, ) -> tuple[list[dict[str, Any]], int]: """ Get paginated email logs with optional filters. Args: filters: Optional dict with keys: search, status, template_code, store_id, date_from, date_to skip: Offset for pagination limit: Max results per page Returns: Tuple of (log items list, total count) """ query = self.db.query(EmailLog).order_by(EmailLog.created_at.desc()) if filters: if filters.get("search"): search = f"%{filters['search']}%" query = query.filter(EmailLog.recipient_email.ilike(search)) if filters.get("status"): query = query.filter(EmailLog.status == filters["status"]) if filters.get("template_code"): query = query.filter(EmailLog.template_code == filters["template_code"]) if filters.get("store_id"): query = query.filter(EmailLog.store_id == int(filters["store_id"])) if filters.get("date_from"): query = query.filter(EmailLog.created_at >= filters["date_from"]) if filters.get("date_to"): query = query.filter(EmailLog.created_at <= filters["date_to"]) total = query.count() logs = query.offset(skip).limit(limit).all() items = [] for log in logs: items.append({ "id": log.id, "recipient_email": log.recipient_email, "recipient_name": log.recipient_name, "subject": log.subject, "status": log.status, "template_code": log.template_code, "provider": log.provider, "store_id": log.store_id, "related_type": log.related_type, "related_id": log.related_id, "created_at": log.created_at.isoformat() if log.created_at else None, "sent_at": log.sent_at.isoformat() if log.sent_at else None, "error_message": log.error_message, }) return items, total def get_email_log_detail(self, log_id: int) -> dict[str, Any]: """ Get full detail for a single email log including body content. Args: log_id: Email log ID Returns: Full log details including body_html/body_text Raises: ResourceNotFoundException: If log not found """ log = self.db.query(EmailLog).filter(EmailLog.id == log_id).first() if not log: raise ResourceNotFoundException("EmailLog", str(log_id)) return { "id": log.id, "recipient_email": log.recipient_email, "recipient_name": log.recipient_name, "subject": log.subject, "status": log.status, "template_code": log.template_code, "provider": log.provider, "store_id": log.store_id, "user_id": log.user_id, "related_type": log.related_type, "related_id": log.related_id, "from_email": log.from_email, "from_name": log.from_name, "reply_to": log.reply_to, "body_html": log.body_html, "body_text": log.body_text, "error_message": log.error_message, "retry_count": log.retry_count, "provider_message_id": log.provider_message_id, "created_at": log.created_at.isoformat() if log.created_at else None, "sent_at": log.sent_at.isoformat() if log.sent_at else None, "delivered_at": log.delivered_at.isoformat() if log.delivered_at else None, "opened_at": log.opened_at.isoformat() if log.opened_at else None, "clicked_at": log.clicked_at.isoformat() if log.clicked_at else None, "extra_data": log.extra_data, } def get_email_log_stats(self) -> dict[str, Any]: """ Get aggregate email log statistics. Returns: Dict with by_status, by_template, and total count. """ # Count by status status_rows = ( self.db.query(EmailLog.status, func.count(EmailLog.id)) .group_by(EmailLog.status) .all() ) by_status = {row[0]: row[1] for row in status_rows} # Count by template_code template_rows = ( self.db.query(EmailLog.template_code, func.count(EmailLog.id)) .filter(EmailLog.template_code.isnot(None)) .group_by(EmailLog.template_code) .all() ) by_template = {row[0]: row[1] for row in template_rows} total = sum(by_status.values()) return { "by_status": by_status, "by_template": by_template, "total": total, } # ========================================================================= # HELPER METHODS # ========================================================================= def _validate_template_syntax( self, subject: str, body_html: str, body_text: str | None, ) -> None: """Validate Jinja2 template syntax.""" try: _jinja_env.from_string(subject).render({}) _jinja_env.from_string(body_html).render({}) if body_text: _jinja_env.from_string(body_text).render({}) except TemplateError as e: raise ValidationException(f"Invalid template syntax: {str(e)}") def _parse_variables(self, variables_json: str | None) -> list[str]: """Parse variables JSON string to list.""" if not variables_json: return [] try: import json return json.loads(variables_json) except (json.JSONDecodeError, TypeError): return [] def _parse_required_variables(self, required_vars: str | None) -> list[str]: """Parse required variables comma-separated string to list.""" if not required_vars: return [] return [v.strip() for v in required_vars.split(",") if v.strip()] def _get_template_languages(self, code: str) -> list[str]: """Get list of languages available for a template.""" templates = ( self.db.query(EmailTemplate.language) .filter(EmailTemplate.code == code) .all() ) return [t.language for t in templates]