"""Email template renderer — Jinja2-based with personalization support.
Provides:
- Template loading from disk (built-in defaults + user custom templates)
- Personalization: {{recipient.email}}, {{recipient.first_name}}, {{form.field_name}}
- Fallback chain: campaign template → email_settings template → default
- Plain-text extraction from HTML for email fallback
"""
import logging
import os
import re
from jinja2 import Environment, FileSystemLoader, select_autoescape
logger = logging.getLogger("agentforms.emails")
# Directories
_EMAILS_DIR = os.path.dirname(__file__)
_BUILTIN_TEMPLATES_DIR = os.path.join(_EMAILS_DIR, "templates")
_CUSTOM_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(_EMAILS_DIR)), "data", "custom_templates")
# Base URL for tracking
_BASE_URL = os.environ.get("APP_URL", "https://agentforms.io")
class EmailTemplateRenderer:
"""Jinja2-based email template renderer with personalization."""
def __init__(self, base_url=None):
self.base_url = base_url or _BASE_URL
# Combined loader: custom templates take precedence over builtins
searchpaths = [
_CUSTOM_TEMPLATES_DIR,
_BUILTIN_TEMPLATES_DIR,
]
self.env = Environment(
loader=FileSystemLoader(searchpaths),
autoescape=select_autoescape(default=True),
trim_blocks=True,
lstrip_blocks=True,
)
# Register email-specific filters
self.env.filters["truncate_html"] = self._truncate_html
self.env.filters["plain_text"] = self._html_to_plain
self.env.filters["default"] = self._default_filter
@staticmethod
def _truncate_html(html, length=200):
"""Truncate HTML content to approx N characters, stripping tags."""
if not html:
return ""
plain = re.sub(r"<[^>]+>", "", html)
if len(plain) <= length:
return html
return plain[:length] + "..."
@staticmethod
def _html_to_plain(html):
"""Strip HTML tags for plain-text fallback."""
if not html:
return ""
return re.sub(r"<[^>]+>", "", html).strip()
@staticmethod
def _default_filter(value, default=""):
"""Safe default filter that handles None."""
if value is None:
return default
return value if value else default
def render(self, template_id, context=None):
"""Render a template with the given context.
Args:
template_id: Template identifier (e.g. 'default', 'survey', 'onboarding')
context: Dict of template variables. Supports:
- subject: Email subject line
- form_name: Name of the form
- body: Email body content (HTML)
- cta_url: Call-to-action URL (form link)
- cta_text: CTA button text
- sender_name: Sender display name
- recipient: Dict with recipient data (email, first_name, etc.)
- field_preview: HTML preview of form fields
- preview_text: Inbox preview text
- unsubscribe_url: CAN-SPAM unsubscribe link
- physical_address: CAN-SPAM physical address
- base_url: Application base URL
Returns:
Rendered HTML string
"""
context = context or {}
context.setdefault("base_url", self.base_url)
# Resolve template name
name = template_id or "default"
if not name.endswith(".html"):
name = f"{name}.html"
try:
template = self.env.get_template(name)
return template.render(**context)
except Exception as e:
logger.error("Template render failed for %s: %s", template_id, e)
# Fallback to default template on error
try:
template = self.env.get_template("default.html")
return template.render(**context)
except Exception as fallback_err:
logger.error("Fallback template also failed: %s", fallback_err)
return "<html><body><p>Error rendering email.</p></body></html>"
def render_subject(self, subject_template, context=None):
"""Render a subject line template with personalization.
Supports {{recipient.email}}, {{recipient.first_name}}, {{form_name}}, etc.
Args:
subject_template: Subject line with Jinja2 variables
context: Dict of template variables (same as render())
Returns:
Rendered subject line string
"""
context = context or {}
context.setdefault("base_url", self.base_url)
try:
tpl = self.env.from_string(subject_template)
return tpl.render(**context)
except Exception as e:
logger.warning("Subject render failed: %s — returning raw", e)
return subject_template
def list_templates(self):
"""List all available templates (built-in + custom).
Returns:
List of dicts with 'name', 'type' (builtin/custom), 'path'
"""
templates = []
# Built-in templates
if os.path.isdir(_BUILTIN_TEMPLATES_DIR):
for fname in sorted(os.listdir(_BUILTIN_TEMPLATES_DIR)):
if fname.endswith(".html"):
templates.append(
{
"name": fname.replace(".html", ""),
"type": "builtin",
"path": os.path.join(_BUILTIN_TEMPLATES_DIR, fname),
}
)
# Custom templates
if os.path.isdir(_CUSTOM_TEMPLATES_DIR):
for fname in sorted(os.listdir(_CUSTOM_TEMPLATES_DIR)):
if fname.endswith(".html"):
templates.append(
{
"name": fname.replace(".html", ""),
"type": "custom",
"path": os.path.join(_CUSTOM_TEMPLATES_DIR, fname),
}
)
return templates
def save_custom_template(self, name, content):
"""Save a custom template to disk.
Args:
name: Template name (without .html extension)
content: HTML template content
Returns:
True on success, False on failure
"""
os.makedirs(_CUSTOM_TEMPLATES_DIR, exist_ok=True)
filename = f"{name}.html"
filepath = os.path.join(_CUSTOM_TEMPLATES_DIR, filename)
try:
# Validate by rendering with empty context first
self.env.from_string(content)
with open(filepath, "w") as f:
f.write(content)
return True
except Exception as e:
logger.error("Failed to save template %s: %s", name, e)
return False
def delete_custom_template(self, name):
"""Delete a custom template.
Args:
name: Template name (without .html extension)
Returns:
True on success, False on failure
"""
filename = f"{name}.html"
filepath = os.path.join(_CUSTOM_TEMPLATES_DIR, filename)
if os.path.exists(filepath):
os.remove(filepath)
return True
return False
def get_custom_template_content(self, name):
"""Get the raw content of a custom template.
Returns:
Template content string or None if not found
"""
filename = f"{name}.html"
filepath = os.path.join(_CUSTOM_TEMPLATES_DIR, filename)
if os.path.exists(filepath):
with open(filepath) as f:
return f.read()
return None
# Module-level singleton
_default_renderer = None
def _get_renderer():
"""Get or create the default renderer singleton."""
global _default_renderer
if _default_renderer is None:
_default_renderer = EmailTemplateRenderer()
return _default_renderer
def render_email(template_id, context=None):
"""Convenience function to render an email template.
Args:
template_id: Template identifier
context: Dict of template variables
Returns:
Rendered HTML string
"""
return _get_renderer().render(template_id, context)
def get_default_template():
"""Return the raw default template content."""
return _get_renderer().get_custom_template_content("default") or _get_renderer().render("default", {})