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

", + "preheader": "Test message from admin", + } + + +# Registry entry shape: +# template: path relative to templates/ +# label: human-readable name shown in gallery +# description: one-line description +# email_type: email_type value stored in email_log (for cross-linking) +# sample_data: callable(lang) → dict of template context +EMAIL_TEMPLATE_REGISTRY: dict[str, dict] = { + "magic_link": { + "template": "emails/magic_link.html", + "label": "Magic Link", + "description": "Passwordless sign-in link sent to users requesting access.", + "email_type": "magic_link", + "sample_data": _magic_link_sample, + }, + "quote_verification": { + "template": "emails/quote_verification.html", + "label": "Quote Verification", + "description": "Email address verification for new project quote requests.", + "email_type": "quote_verification", + "sample_data": _quote_verification_sample, + }, + "welcome": { + "template": "emails/welcome.html", + "label": "Welcome", + "description": "Sent to new users after their first successful sign-in.", + "email_type": "welcome", + "sample_data": _welcome_sample, + }, + "waitlist_supplier": { + "template": "emails/waitlist_supplier.html", + "label": "Waitlist — Supplier", + "description": "Confirmation for suppliers who joined the Growth/Pro waitlist.", + "email_type": "waitlist", + "sample_data": _waitlist_supplier_sample, + }, + "waitlist_general": { + "template": "emails/waitlist_general.html", + "label": "Waitlist — General", + "description": "Confirmation for general sign-up waitlist submissions.", + "email_type": "waitlist", + "sample_data": _waitlist_general_sample, + }, + "lead_matched": { + "template": "emails/lead_matched.html", + "label": "Lead Matched", + "description": "Notifies the project owner that suppliers are now reviewing their brief.", + "email_type": "lead_matched", + "sample_data": _lead_matched_sample, + }, + "lead_forward": { + "template": "emails/lead_forward.html", + "label": "Lead Forward", + "description": "Full project brief sent to a supplier after they unlock a lead.", + "email_type": "lead_forward", + "sample_data": _lead_forward_sample, + }, + "lead_match_notify": { + "template": "emails/lead_match_notify.html", + "label": "Lead Match Notify", + "description": "Notifies matching suppliers that a new lead is available in their area.", + "email_type": "lead_match_notify", + "sample_data": _lead_match_notify_sample, + }, + "weekly_digest": { + "template": "emails/weekly_digest.html", + "label": "Weekly Digest", + "description": "Monday digest of new leads matching a supplier's service area.", + "email_type": "weekly_digest", + "sample_data": _weekly_digest_sample, + }, + "business_plan": { + "template": "emails/business_plan.html", + "label": "Business Plan Ready", + "description": "Notifies the user when their business plan PDF export is ready.", + "email_type": "business_plan", + "sample_data": _business_plan_sample, + }, + "admin_compose": { + "template": "emails/admin_compose.html", + "label": "Admin Compose", + "description": "Branded wrapper used for ad-hoc emails sent from the compose panel.", + "email_type": "admin_compose", + "sample_data": _admin_compose_sample, + }, +} diff --git a/web/src/padelnomics/templates/emails/_base.html b/web/src/padelnomics/templates/emails/_base.html new file mode 100644 index 0000000..f15d81d --- /dev/null +++ b/web/src/padelnomics/templates/emails/_base.html @@ -0,0 +1,54 @@ + + + + + + {{ app_name }} + + + + {%- if preheader %} + {# Hidden preheader trick: visible text + invisible padding to prevent + email clients from pulling body text into the preview. #} + {{ preheader }}{% for _ in range(30) %}͏ ‌ {% endfor %} + {%- endif %} + + + +
+ + + + + + + + + + + + + + + + + +
 
+ + padelnomics + +
+ {% block body %}{% endblock %} +
+

+ padelnomics.io +  ·  + {{ tagline }} +

+

+ {{ copyright_text }} +

+
+
+ + diff --git a/web/src/padelnomics/templates/emails/_macros.html b/web/src/padelnomics/templates/emails/_macros.html new file mode 100644 index 0000000..1d043d4 --- /dev/null +++ b/web/src/padelnomics/templates/emails/_macros.html @@ -0,0 +1,61 @@ +{# + Shared macros for transactional email templates. + + Import in child templates: + {% from "emails/_macros.html" import email_button, heat_badge, section_heading, info_box %} +#} + + +{# ─── CTA Button ───────────────────────────────────────────────────────────── + Table-based blue button — works in all major email clients. + Uses display:block for full-width tap target on mobile. +#} +{% macro email_button(url, label) %} + + +
+ {{ label }} +
+{% endmacro %} + + +{# ─── Heat Badge ───────────────────────────────────────────────────────────── + Inline colored badge: HOT (red), WARM (orange), COOL (blue). + heat: uppercase string "HOT" | "WARM" | "COOL" +#} +{% macro heat_badge(heat) %} +{%- set colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} -%} +{%- set bg = colors.get(heat, "#2563EB") -%} +{{ heat }} +{%- endmacro %} + + +{# ─── Small heat badge (compact variant for table rows) ────────────────────── + 1px smaller padding, used in weekly_digest lead table rows. +#} +{% macro heat_badge_sm(heat) %} +{%- set colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} -%} +{%- set bg = colors.get(heat, "#2563EB") -%} +{{ heat }} +{%- endmacro %} + + +{# ─── Section Heading ───────────────────────────────────────────────────────── + Small uppercase label above a data table section. +#} +{% macro section_heading(text) %} +

{{ text }}

+{% endmacro %} + + +{# ─── Info Box ──────────────────────────────────────────────────────────────── + Left-bordered callout box. color: "blue" (default) or "yellow". +#} +{% macro info_box(text, color="blue") %} +{%- if color == "yellow" -%} + {%- set bg = "#FEF3C7" -%}{%- set border = "#F59E0B" -%} +{%- else -%} + {%- set bg = "#F0F9FF" -%}{%- set border = "#1D4ED8" -%} +{%- endif -%} +

{{ 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_heading | tformat(app_name=app_name) }}

+
+

{{ 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_heading | tformat(contact_name=contact_name) }}

+
+

{{ 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_heading | tformat(app_name=app_name) }}

+
+

{{ 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 }}
+{{ email_button(base_url ~ "/planner", t.email_welcome_btn) }} +{% endblock %} diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py index 387987a..fe872c1 100644 --- a/web/src/padelnomics/worker.py +++ b/web/src/padelnomics/worker.py @@ -22,6 +22,7 @@ from .core import ( utcnow, utcnow_iso, ) +from .email_templates import render_email_template from .i18n import get_translations logger = logging.getLogger(__name__) @@ -228,20 +229,18 @@ async def handle_send_magic_link(payload: dict) -> None: logger.debug("MAGIC LINK for %s: %s", payload["email"], link) expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES - body = ( - f'

{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}

' - f'
' - f'

{_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_heading", lang, app_name=config.APP_NAME)}

' - f'
' - 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'' - f'' - f'' - f'' - f'' - f'' - f'
{_t("email_welcome_link_planner", lang)}
{_t("email_welcome_link_markets", lang)}
{_t("email_welcome_link_quotes", lang)}
' - f'{_email_button(f"{config.BASE_URL}/planner", _t("email_welcome_btn", lang))}' + html = render_email_template( + "emails/welcome.html", + lang=lang, + first_name=first_name, + preheader=_t("email_welcome_preheader", lang), ) await send_email( to=payload["email"], subject=_t("email_welcome_subject", lang), - html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)), + html=html, from_addr=EMAIL_ADDRESSES["transactional"], email_type="welcome", ) @@ -727,25 +716,20 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None: contact_email = payload.get("contact_email", "") message = payload.get("message", "") - body = ( - f'

' - f'{_t("email_enquiry_heading", lang, contact_name=contact_name)}

' - f'
' - f'

{_t("email_enquiry_body", lang, supplier_name=supplier_name)}

' - f'' - f'' - f'' - f'' - f'' - f'
{_t("email_enquiry_lbl_from", lang)}{contact_name} <{contact_email}>
{_t("email_enquiry_lbl_message", lang)}{message}
' - f'

{_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", )