diff --git a/web/src/padelnomics/email_templates.py b/web/src/padelnomics/email_templates.py new file mode 100644 index 0000000..220000c --- /dev/null +++ b/web/src/padelnomics/email_templates.py @@ -0,0 +1,306 @@ +""" +Standalone Jinja2 email template renderer. + +Used by both the worker (outside Quart request context) and admin gallery routes. +Creates a module-level Environment pointing at the same templates/ directory +used by the web app, so templates share the same file tree. + +Usage: + from .email_templates import render_email_template, EMAIL_TEMPLATE_REGISTRY + + html = render_email_template("emails/magic_link.html", lang="en", link=link, expiry_minutes=15) +""" + +from pathlib import Path + +import jinja2 + +from .core import config, utcnow +from .i18n import get_translations + +_TEMPLATES_DIR = Path(__file__).parent / "templates" + +# Standalone environment — not tied to Quart's request context. +# autoescape=True: user-supplied data (names, emails, messages) is auto-escaped. +# Trusted HTML sections use the `| safe` filter explicitly. +_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(_TEMPLATES_DIR)), + autoescape=True, + undefined=jinja2.StrictUndefined, +) + + +def _tformat(s: str, **kwargs) -> str: + """Jinja filter: interpolate {placeholders} into a translation string. + + Mirrors the `tformat` filter registered in app.py so email templates + and web templates use the same syntax: + {{ t.some_key | tformat(name=supplier.name, count=n) }} + """ + if not kwargs: + return s + return s.format(**kwargs) + + +_env.filters["tformat"] = _tformat + + +def render_email_template(template_name: str, lang: str = "en", **kwargs) -> str: + """Render an email template with standard context injected. + + Args: + template_name: Path relative to templates/ (e.g. "emails/magic_link.html"). + lang: Language code ("en" or "de"). Used for translations + html lang attr. + **kwargs: Additional context variables passed to the template. + + Returns: + Rendered HTML string containing a full document. + """ + assert lang in ("en", "de"), f"Unsupported lang: {lang!r}" + assert template_name.startswith("emails/"), f"Expected emails/ prefix: {template_name!r}" + + translations = get_translations(lang) + year = utcnow().year + + # Pre-interpolate footer strings so templates don't need to call tformat on them. + tagline = translations.get("email_footer_tagline", "") + copyright_text = translations.get("email_footer_copyright", "").format( + year=year, app_name=config.APP_NAME + ) + + context = { + "lang": lang, + "app_name": config.APP_NAME, + "base_url": config.BASE_URL, + "t": translations, + "tagline": tagline, + "copyright_text": copyright_text, + **kwargs, + } + + tmpl = _env.get_template(template_name) + rendered = tmpl.render(**context) + + assert "" in rendered, f"Template {template_name!r} must produce a DOCTYPE document" + assert "padelnomics" in rendered.lower(), f"Template {template_name!r} must include the wordmark" + return rendered + + +# ============================================================================= +# Template registry — used by admin gallery for sample preview rendering +# ============================================================================= + +def _magic_link_sample(lang: str) -> dict: + return { + "link": f"{config.BASE_URL}/auth/verify?token=sample_token_abc123", + "expiry_minutes": 15, + "preheader": get_translations(lang).get("email_magic_link_preheader", "").format(expiry_minutes=15), + } + + +def _quote_verification_sample(lang: str) -> dict: + t = get_translations(lang) + court_count = "4" + return { + "link": f"{config.BASE_URL}/{lang}/leads/verify?token=verify123&lead=lead456", + "first_name": "Alex", + "court_count": court_count, + "facility_type": "Indoor Padel Club", + "country": "Germany", + "preheader": t.get("email_quote_verify_preheader_courts", "").format(court_count=court_count), + } + + +def _welcome_sample(lang: str) -> dict: + t = get_translations(lang) + return { + "first_name": "Maria", + "preheader": t.get("email_welcome_preheader", ""), + } + + +def _waitlist_supplier_sample(lang: str) -> dict: + t = get_translations(lang) + return { + "plan_name": "Growth", + "preheader": t.get("email_waitlist_supplier_preheader", ""), + } + + +def _waitlist_general_sample(lang: str) -> dict: + t = get_translations(lang) + return { + "preheader": t.get("email_waitlist_general_preheader", ""), + } + + +def _lead_matched_sample(lang: str) -> dict: + t = get_translations(lang) + return { + "first_name": "Thomas", + "facility_type": "padel", + "court_count": "6", + "country": "Austria", + "preheader": t.get("email_lead_matched_preheader", ""), + } + + +def _lead_forward_sample(lang: str) -> dict: + return { + "heat": "HOT", + "country": "Spain", + "courts": "8", + "budget": "450000", + "facility_type": "Outdoor Padel Club", + "timeline": "Q3 2025", + "contact_email": "ceo@padelclub.es", + "contact_name": "Carlos Rivera", + "contact_phone": "+34 612 345 678", + "contact_company": "PadelClub Madrid SL", + "stakeholder_type": "Developer / Investor", + "build_context": "New build", + "glass_type": "Panoramic", + "lighting_type": "LED", + "location": "Madrid", + "location_status": "Site confirmed", + "financing_status": "Self-financed", + "services_needed": "Full turnkey construction", + "additional_info": "Seeking experienced international suppliers only.", + "cta_url": f"{config.BASE_URL}/suppliers/leads/cta/sample_cta_token", + "preheader": "Outdoor Padel Club project · Q3 2025 timeline — contact details inside", + "brief_rows": [ + ("Facility", "Outdoor Padel Club (New build)"), + ("Courts", "8 | Glass: Panoramic | Lighting: LED"), + ("Location", "Madrid, Spain"), + ("Timeline", "Q3 2025 | Budget: €450000"), + ("Phase", "Site confirmed | Financing: Self-financed"), + ("Services", "Full turnkey construction"), + ("Additional Info", "Seeking experienced international suppliers only."), + ], + } + + +def _lead_match_notify_sample(lang: str) -> dict: + return { + "heat": "WARM", + "country": "Netherlands", + "courts": "4", + "facility_type": "Indoor Padel", + "timeline": "Q1 2026", + "credit_cost": 2, + "preheader": "New matching lead in Netherlands", + } + + +def _weekly_digest_sample(lang: str) -> dict: + return { + "leads": [ + {"heat": "HOT", "facility_type": "Outdoor Padel", "court_count": "6", "country": "Germany", "timeline": "Q2 2025"}, + {"heat": "WARM", "facility_type": "Indoor Club", "court_count": "4", "country": "Austria", "timeline": "Q3 2025"}, + {"heat": "COOL", "facility_type": "Padel Centre", "court_count": "8", "country": "Switzerland", "timeline": "2026"}, + ], + "preheader": "3 new leads matching your service area", + } + + +def _business_plan_sample(lang: str) -> dict: + t = get_translations(lang) + return { + "download_url": f"{config.BASE_URL}/planner/export/sample_export_token", + "quote_url": f"{config.BASE_URL}/{lang}/leads/quote", + "preheader": t.get("email_business_plan_preheader", ""), + } + + +def _admin_compose_sample(lang: str) -> dict: + return { + "body_html": "
Hello,
This is a test message from the admin compose panel.
Best regards,
Padelnomics Team
| + + |
| + {{ label }} + |
{{ text }}
+{% endmacro %} diff --git a/web/src/padelnomics/templates/emails/magic_link.html b/web/src/padelnomics/templates/emails/magic_link.html new file mode 100644 index 0000000..e004747 --- /dev/null +++ b/web/src/padelnomics/templates/emails/magic_link.html @@ -0,0 +1,12 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button %} + +{% block body %} +{{ t.email_magic_link_body | tformat(expiry_minutes=expiry_minutes) }}
+{{ email_button(link, t.email_magic_link_btn) }} +{{ t.email_magic_link_fallback }}
+{{ link }}
+{{ t.email_magic_link_ignore }}
+{% endblock %} diff --git a/web/src/padelnomics/templates/emails/supplier_enquiry.html b/web/src/padelnomics/templates/emails/supplier_enquiry.html new file mode 100644 index 0000000..54dd23d --- /dev/null +++ b/web/src/padelnomics/templates/emails/supplier_enquiry.html @@ -0,0 +1,19 @@ +{% extends "emails/_base.html" %} + +{% block body %} +{{ t.email_enquiry_body | tformat(supplier_name=supplier_name) }}
+| {{ t.email_enquiry_lbl_from }} | +{{ contact_name }} <{{ contact_email }}> | +
| {{ t.email_enquiry_lbl_message }} | +{{ message }} | +
{{ t.email_enquiry_respond_fast }}
+{{ t.email_enquiry_reply | tformat(contact_email=contact_email) }}
+{% endblock %} diff --git a/web/src/padelnomics/templates/emails/welcome.html b/web/src/padelnomics/templates/emails/welcome.html new file mode 100644 index 0000000..d3d3438 --- /dev/null +++ b/web/src/padelnomics/templates/emails/welcome.html @@ -0,0 +1,25 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button %} + +{% block body %} +{{ t.email_welcome_greeting | tformat(first_name=first_name) }}
+{{ t.email_welcome_body }}
+{{ t.email_welcome_quickstart_heading }}
+| ● | +{{ t.email_welcome_link_planner }} | +
| ● | +{{ t.email_welcome_link_markets }} | +
| ● | +{{ t.email_welcome_link_quotes }} | +
{_t("email_magic_link_body", lang, expiry_minutes=expiry_minutes)}
' - f'{_email_button(link, _t("email_magic_link_btn", lang))}' - f'{_t("email_magic_link_fallback", lang)}
' - f'{link}
' - f'{_t("email_magic_link_ignore", lang)}
' + html = render_email_template( + "emails/magic_link.html", + lang=lang, + link=link, + expiry_minutes=expiry_minutes, + preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes), ) await send_email( to=payload["email"], subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME), - html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)), + html=html, from_addr=EMAIL_ADDRESSES["transactional"], email_type="magic_link", ) @@ -315,27 +314,17 @@ async def handle_send_welcome(payload: dict) -> None: name_parts = (payload.get("name") or "").split() first_name = name_parts[0] if name_parts else "there" - body = ( - f'{_t("email_welcome_greeting", lang, first_name=first_name)}
' - f'{_t("email_welcome_body", lang)}
' - f'{_t("email_welcome_quickstart_heading", lang)}
' - f'| ● | ' - f'{_t("email_welcome_link_planner", lang)} |
| ● | ' - f'{_t("email_welcome_link_markets", lang)} |
| ● | ' - f'{_t("email_welcome_link_quotes", lang)} |
{_t("email_enquiry_body", lang, supplier_name=supplier_name)}
' - f'| {_t("email_enquiry_lbl_from", lang)} | ' - f'{contact_name} <{contact_email}> |
| {_t("email_enquiry_lbl_message", lang)} | ' - f'{message} |
{_t("email_enquiry_respond_fast", lang)}
' - f'{_t("email_enquiry_reply", lang, contact_email=contact_email)}
' + html = render_email_template( + "emails/supplier_enquiry.html", + lang=lang, + supplier_name=supplier_name, + contact_name=contact_name, + contact_email=contact_email, + message=message, + preheader=_t("email_enquiry_preheader", lang), ) await send_email( to=supplier_email, subject=_t("email_enquiry_subject", lang, contact_name=contact_name), - html=_email_wrap(body, lang, preheader=_t("email_enquiry_preheader", lang)), + html=html, from_addr=EMAIL_ADDRESSES["transactional"], email_type="supplier_enquiry", )