diff --git a/CHANGELOG.md b/CHANGELOG.md index 6189c7e..832ea1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **Email template system** — all 11 transactional emails migrated from inline f-string HTML in `worker.py` to Jinja2 templates: + - **Standalone renderer** (`email_templates.py`) — `render_email_template()` uses a module-level `jinja2.Environment` with `autoescape=True`, works outside Quart request context (worker process); `tformat` filter mirrors the one in `app.py` + - **`_base.html`** — branded shell (dark header, 3px blue accent, white card body, footer with tagline + copyright); replaces the old `_email_wrap()` helper + - **`_macros.html`** — reusable Jinja2 macros: `email_button`, `heat_badge`, `heat_badge_sm`, `section_heading`, `info_box` + - **11 email templates**: `magic_link`, `quote_verification`, `welcome`, `waitlist_supplier`, `waitlist_general`, `lead_matched`, `lead_forward`, `lead_match_notify`, `weekly_digest`, `business_plan`, `admin_compose` + - **`EMAIL_TEMPLATE_REGISTRY`** — dict mapping slug → `{template, label, description, email_type, sample_data}` with realistic sample data callables for each template + - **Admin email gallery** (`/admin/emails/gallery`) — card grid of all email types; preview page with EN/DE language toggle renders each template in a sandboxed iframe (`srcdoc`); "View in sent log →" cross-link; gallery link added to admin sidebar + - **Compose live preview** — two-column compose layout: form on the left, HTMX-powered preview iframe on the right; `hx-trigger="input delay:500ms"` on the textarea; `POST /admin/emails/compose/preview` endpoint supports plain body or branded wrapper via `wrap` checkbox + - 50 new tests covering all template renders (EN + DE), registry structure, gallery routes (access control, list, preview, lang fallback), and compose preview endpoint + +### Removed +- `_email_wrap()` and `_email_button()` helper functions removed from `worker.py` — replaced by templates + - **Marketplace admin dashboard** (`/admin/marketplace`) — single-screen health view for the two-sided market: - **Lead funnel** — total / verified-new (ready to unlock) / unlocked / won / conversion rate - **Credit economy** — total credits issued, consumed (lead unlocks), outstanding balance across all paid suppliers, 30-day burn rate diff --git a/PROJECT.md b/PROJECT.md index 6e94289..9feaa0c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -107,6 +107,8 @@ - [x] Task queue management (list, retry, delete) - [x] Lead funnel stats on admin dashboard - [x] Email hub (`/admin/emails`) — sent log, inbox, compose, audiences, delivery event tracking via Resend webhooks +- [x] **Email template system** — 11 transactional emails as Jinja2 templates (`emails/*.html`); standalone `render_email_template()` renderer works in worker + admin; `_base.html` + `_macros.html` shared shell; `EMAIL_TEMPLATE_REGISTRY` with sample data for gallery previews; `_email_wrap()` / `_email_button()` helpers removed +- [x] **Admin email gallery** (`/admin/emails/gallery`) — card grid of all templates, EN/DE preview in sandboxed iframe, "View in sent log" cross-link; compose page now has HTMX live preview pane - [x] **pSEO Engine tab** (`/admin/pseo`) — content gap detection, data freshness signals, article health checks (hreflang orphans, missing build files, broken scenario refs), generation job monitoring with live progress bars - [x] **Marketplace admin dashboard** (`/admin/marketplace`) — lead funnel, credit economy, supplier engagement, live activity stream, inline feature flag toggles - [x] **Lead matching notifications** — `notify_matching_suppliers` task on quote verification + `send_weekly_lead_digest` every Monday; one-click CTA token in forward emails diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 32bf36d..c578d99 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -33,6 +33,7 @@ from ..core import ( utcnow, utcnow_iso, ) +from ..email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template logger = logging.getLogger(__name__) @@ -1248,6 +1249,45 @@ async def emails(): ) +@bp.route("/emails/gallery") +@role_required("admin") +async def email_gallery(): + """Gallery of all email template types with sample previews.""" + return await render_template( + "admin/email_gallery.html", + registry=EMAIL_TEMPLATE_REGISTRY, + ) + + +@bp.route("/emails/gallery/") +@role_required("admin") +async def email_gallery_preview(slug: str): + """Rendered preview of a single email template with sample data.""" + entry = EMAIL_TEMPLATE_REGISTRY.get(slug) + if not entry: + await flash(f"Unknown email template: {slug!r}", "error") + return redirect(url_for("admin.email_gallery")) + + lang = request.args.get("lang", "en") + if lang not in ("en", "de"): + lang = "en" + + try: + sample = entry["sample_data"](lang) + rendered_html = render_email_template(entry["template"], lang=lang, **sample) + except Exception: + logger.exception("email_gallery_preview: render failed for %s (lang=%s)", slug, lang) + rendered_html = "

Render error — see logs.

" + + return await render_template( + "admin/email_gallery_preview.html", + slug=slug, + entry=entry, + lang=lang, + rendered_html=rendered_html, + ) + + @bp.route("/emails/results") @role_required("admin") async def email_results(): @@ -1398,10 +1438,16 @@ async def email_compose(): email_addresses=EMAIL_ADDRESSES, ) - html = f"

{body.replace(chr(10), '
')}

" + body_html = f"

{body.replace(chr(10), '
')}

" if wrap: - from ..worker import _email_wrap - html = _email_wrap(html) + html = render_email_template( + "emails/admin_compose.html", + lang="en", + body_html=body_html, + preheader="", + ) + else: + html = body_html result = await send_email( to=to, subject=subject, html=html, @@ -1424,6 +1470,36 @@ async def email_compose(): ) +@bp.route("/emails/compose/preview", methods=["POST"]) +@role_required("admin") +async def compose_preview(): + """HTMX endpoint: render live preview for compose textarea (no CSRF — read-only).""" + form = await request.form + body = form.get("body", "").strip() + wrap = form.get("wrap", "") == "1" + + body_html = f"

{body.replace(chr(10), '
')}

" if body else "" + + if wrap and body_html: + try: + rendered_html = render_email_template( + "emails/admin_compose.html", + lang="en", + body_html=body_html, + preheader="", + ) + except Exception: + logger.exception("compose_preview: template render failed") + rendered_html = body_html + else: + rendered_html = body_html + + return await render_template( + "admin/partials/email_preview_frame.html", + rendered_html=rendered_html, + ) + + # --- Audiences --- @bp.route("/emails/audiences") diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index 06883f3..d5ffbf0 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -118,6 +118,10 @@ Compose + + + Gallery + Audiences diff --git a/web/src/padelnomics/admin/templates/admin/email_compose.html b/web/src/padelnomics/admin/templates/admin/email_compose.html index dfd5a7a..1ab8846 100644 --- a/web/src/padelnomics/admin/templates/admin/email_compose.html +++ b/web/src/padelnomics/admin/templates/admin/email_compose.html @@ -2,51 +2,91 @@ {% set admin_page = "compose" %} {% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %} +{% block admin_head %} + +{% endblock %} + {% block admin_content %}
← Sent Log

Compose Email

-
-
- +
+ {# ── Left: form ────────────────────────────────────── #} +
+
+ + -
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- -
+
+ +
-
- - Cancel +
+ + Cancel +
+
- +
+ + {# ── Right: live preview panel ─────────────────────── #} +
+
Live preview
+
+

Start typing to see a preview…

+
+
{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/email_gallery.html b/web/src/padelnomics/admin/templates/admin/email_gallery.html new file mode 100644 index 0000000..6e03f13 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/email_gallery.html @@ -0,0 +1,81 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "gallery" %} +{% block title %}Email Gallery - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block admin_head %} + +{% endblock %} + +{% block admin_content %} +
+
+

Email Gallery

+ {{ registry | length }} template{{ 's' if registry | length != 1 else '' }} +
+

Rendered previews of all transactional email templates with sample data.

+
+ + +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/email_gallery_preview.html b/web/src/padelnomics/admin/templates/admin/email_gallery_preview.html new file mode 100644 index 0000000..3fc6bfa --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/email_gallery_preview.html @@ -0,0 +1,61 @@ +{% extends "admin/base_admin.html" %} +{% set admin_page = "gallery" %} +{% block title %}{{ entry.label }} Preview - Email Gallery - Admin{% endblock %} + +{% block admin_head %} + +{% endblock %} + +{% block admin_content %} +
+ ← Email Gallery +

{{ entry.label }}

+

{{ entry.description }}

+
+ +
+
+ {% if entry.email_type %} + + View in sent log → + + {% endif %} +
+ + {# Language toggle #} +
+ EN + DE +
+
+ + +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/partials/email_preview_frame.html b/web/src/padelnomics/admin/templates/admin/partials/email_preview_frame.html new file mode 100644 index 0000000..5943fe3 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/partials/email_preview_frame.html @@ -0,0 +1,8 @@ +{# HTMX partial: sandboxed iframe showing a rendered email preview. + Rendered by POST /admin/emails/compose/preview. #} + diff --git a/web/src/padelnomics/email_templates.py b/web/src/padelnomics/email_templates.py new file mode 100644 index 0000000..0ead4c7 --- /dev/null +++ b/web/src/padelnomics/email_templates.py @@ -0,0 +1,307 @@ +""" +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", + "recap_parts": ["4 courts", "Indoor Padel Club", "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/admin_compose.html b/web/src/padelnomics/templates/emails/admin_compose.html new file mode 100644 index 0000000..657ad07 --- /dev/null +++ b/web/src/padelnomics/templates/emails/admin_compose.html @@ -0,0 +1,5 @@ +{% extends "emails/_base.html" %} + +{% block body %} +{{ body_html | safe }} +{% endblock %} diff --git a/web/src/padelnomics/templates/emails/business_plan.html b/web/src/padelnomics/templates/emails/business_plan.html new file mode 100644 index 0000000..1443d87 --- /dev/null +++ b/web/src/padelnomics/templates/emails/business_plan.html @@ -0,0 +1,11 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button %} + +{% block body %} +

{{ t.email_business_plan_heading }}

+
+

{{ t.email_business_plan_body }}

+

{{ t.email_business_plan_includes }}

+{{ email_button(download_url, t.email_business_plan_btn) }} +

{{ t.email_business_plan_quote_cta | tformat(quote_url=quote_url) }}

+{% endblock %} diff --git a/web/src/padelnomics/templates/emails/lead_forward.html b/web/src/padelnomics/templates/emails/lead_forward.html new file mode 100644 index 0000000..fdac780 --- /dev/null +++ b/web/src/padelnomics/templates/emails/lead_forward.html @@ -0,0 +1,53 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button, heat_badge, section_heading %} + +{% block body %} +{# Yellow urgency banner #} +

{{ t.email_lead_forward_urgency }}

+ +

{{ t.email_lead_forward_heading }} {{ heat_badge(heat) }}

+
+ +{{ section_heading(t.email_lead_forward_section_brief) }} + + {% for label, value in brief_rows %} + + + + + {% endfor %} +
{{ label }}{{ value }}
+ +{{ section_heading(t.email_lead_forward_section_contact) }} + + + + + + + + + + + + + + + + + + + + + +
{{ t.email_lead_forward_lbl_name }}{{ contact_name }}
{{ t.email_lead_forward_lbl_email }}{{ contact_email }}
{{ t.email_lead_forward_lbl_phone }}{{ contact_phone }}
{{ t.email_lead_forward_lbl_company }}{{ contact_company }}
{{ t.email_lead_forward_lbl_role }}{{ stakeholder_type }}
+ +{{ email_button(base_url ~ "/suppliers/leads", t.email_lead_forward_btn) }} +

{{ t.email_lead_forward_reply_direct | tformat(contact_email=contact_email) }}

+ +{%- if cta_url %} +

+ ✓ Mark as contacted +

+{%- endif %} +{% endblock %} diff --git a/web/src/padelnomics/templates/emails/lead_match_notify.html b/web/src/padelnomics/templates/emails/lead_match_notify.html new file mode 100644 index 0000000..918625d --- /dev/null +++ b/web/src/padelnomics/templates/emails/lead_match_notify.html @@ -0,0 +1,30 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button, heat_badge %} + +{% block body %} +

New [{{ heat }}] lead in {{ country }} {{ heat_badge(heat) }}

+
+

A new project brief has been submitted that matches your service area.

+ + + + + + + + + + + + + + + + + + +
Facility{{ facility_type }}
Courts{{ courts }}
Country{{ country }}
Timeline{{ timeline or "-" }}
+ +

Contact details are available after unlocking. Credits required: {{ credit_cost }}.

+{{ email_button(base_url ~ "/suppliers/leads", "View lead feed") }} +{% endblock %} diff --git a/web/src/padelnomics/templates/emails/lead_matched.html b/web/src/padelnomics/templates/emails/lead_matched.html new file mode 100644 index 0000000..ec934b5 --- /dev/null +++ b/web/src/padelnomics/templates/emails/lead_matched.html @@ -0,0 +1,17 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button %} + +{% block body %} +

{{ t.email_lead_matched_heading }}

+
+

{{ t.email_lead_matched_greeting | tformat(first_name=first_name) }}

+

{{ t.email_lead_matched_body }}

+

{{ t.email_lead_matched_context | tformat(facility_type=facility_type, court_count=court_count, country=country) }}

+ +

{{ t.email_lead_matched_next_heading }}

+

{{ t.email_lead_matched_next_body }}

+

{{ t.email_lead_matched_tip }}

+ +{{ email_button(base_url ~ "/dashboard", t.email_lead_matched_btn) }} +

{{ t.email_lead_matched_note }}

+{% endblock %} 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/quote_verification.html b/web/src/padelnomics/templates/emails/quote_verification.html new file mode 100644 index 0000000..c48e7f0 --- /dev/null +++ b/web/src/padelnomics/templates/emails/quote_verification.html @@ -0,0 +1,24 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button %} + +{% block body %} +

{{ t.email_quote_verify_heading }}

+
+

{{ t.email_quote_verify_greeting | tformat(first_name=first_name) }}

+

{{ t.email_quote_verify_body | tformat(app_name=app_name) }}

+ +{%- if recap_parts %} + + +
+ {{ t.email_quote_verify_project_label }} {{ recap_parts | join(" · ") | safe }} +
+{%- endif %} + +

{{ t.email_quote_verify_urgency }}

+{{ email_button(link, t.email_quote_verify_btn) }} +

{{ t.email_quote_verify_expires }}

+

{{ t.email_quote_verify_fallback }}

+

{{ link }}

+

{{ t.email_quote_verify_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/waitlist_general.html b/web/src/padelnomics/templates/emails/waitlist_general.html new file mode 100644 index 0000000..6546027 --- /dev/null +++ b/web/src/padelnomics/templates/emails/waitlist_general.html @@ -0,0 +1,14 @@ +{% extends "emails/_base.html" %} + +{% block body %} +

{{ t.email_waitlist_general_heading }}

+
+

{{ t.email_waitlist_general_body }}

+

{{ t.email_waitlist_general_perks_intro }}

+
    +
  • {{ t.email_waitlist_general_perk_1 }}
  • +
  • {{ t.email_waitlist_general_perk_2 }}
  • +
  • {{ t.email_waitlist_general_perk_3 }}
  • +
+

{{ t.email_waitlist_general_outro }}

+{% endblock %} diff --git a/web/src/padelnomics/templates/emails/waitlist_supplier.html b/web/src/padelnomics/templates/emails/waitlist_supplier.html new file mode 100644 index 0000000..689868e --- /dev/null +++ b/web/src/padelnomics/templates/emails/waitlist_supplier.html @@ -0,0 +1,18 @@ +{% extends "emails/_base.html" %} + +{% block body %} +

{{ t.email_waitlist_supplier_heading }}

+
+

{{ t.email_waitlist_supplier_body | tformat(plan_name=plan_name) }}

+

{{ t.email_waitlist_supplier_perks_intro }}

+
    +
  • {{ t.email_waitlist_supplier_perk_1 }}
  • +
  • {{ t.email_waitlist_supplier_perk_2 }}
  • +
  • {{ t.email_waitlist_supplier_perk_3 }}
  • +
+

{{ t.email_waitlist_supplier_meanwhile }}

+ +{% endblock %} diff --git a/web/src/padelnomics/templates/emails/weekly_digest.html b/web/src/padelnomics/templates/emails/weekly_digest.html new file mode 100644 index 0000000..3f74930 --- /dev/null +++ b/web/src/padelnomics/templates/emails/weekly_digest.html @@ -0,0 +1,33 @@ +{% extends "emails/_base.html" %} +{% from "emails/_macros.html" import email_button, heat_badge_sm %} + +{% block body %} +

+ Your weekly lead digest — {{ leads | length }} new {{ "lead" if leads | length == 1 else "leads" }} +

+
+

New matching leads in your service area this week:

+ + + + + + + + + + + {% for lead in leads %} + + + + + + {% endfor %} + +
ProjectCountryTimeline
+ {{ heat_badge_sm(lead.heat | upper) }} {{ lead.facility_type or "Padel" }}, {{ lead.court_count or "?" }} courts + {{ lead.country or "-" }}{{ lead.timeline or "-" }}
+ +{{ email_button(base_url ~ "/suppliers/leads", "Unlock leads →") }} +{% 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..25b3e29 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__) @@ -41,89 +42,6 @@ def _t(key: str, lang: str = "en", **kwargs) -> str: return raw.format(**kwargs) if kwargs else raw -def _email_wrap(body: str, lang: str = "en", preheader: str = "") -> str: - """Wrap email body in a branded layout with inline CSS. - - preheader: hidden preview text shown in email client list views. - """ - year = utcnow().year - tagline = _t("email_footer_tagline", lang) - copyright_text = _t("email_footer_copyright", lang, year=year, app_name=config.APP_NAME) - # Hidden preheader trick: visible text + invisible padding to prevent - # email clients from pulling body text into the preview. - preheader_html = "" - if preheader: - preheader_html = ( - f'' - f'{preheader}{"͏ ‌ " * 30}' - ) - return f"""\ - - - - - - {config.APP_NAME} - - - {preheader_html} - - -
- - - - - - - - - - - - - - - - - -
 
- - padelnomics - -
- {body} -
-

- padelnomics.io -  ·  - {tagline} -

-

- {copyright_text} -

-
-
- -""" - - -def _email_button(url: str, label: str) -> str: - """Render a branded CTA button for email. - - Uses display:block for full-width tap target on mobile. - """ - return ( - f'' - f'
' - f'' - f"{label}
" - ) - - def task(name: str): """Decorator to register a task handler.""" @@ -228,20 +146,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", ) @@ -266,8 +182,6 @@ async def handle_send_quote_verification(payload: dict) -> None: facility_type = payload.get("facility_type", "") country = payload.get("country", "") - # Project recap card - project_card = "" recap_parts = [] if court_count: recap_parts.append(f"{court_count} courts") @@ -275,34 +189,22 @@ async def handle_send_quote_verification(payload: dict) -> None: recap_parts.append(facility_type) if country: recap_parts.append(country) - if recap_parts: - project_card = ( - f'' - f'
' - f'{_t("email_quote_verify_project_label", lang)} {" · ".join(recap_parts)}' - f'
' - ) preheader = _t("email_quote_verify_preheader_courts", lang, court_count=court_count) if court_count else _t("email_quote_verify_preheader", lang) - body = ( - f'

{_t("email_quote_verify_heading", lang)}

' - f'
' - f'

{_t("email_quote_verify_greeting", lang, first_name=first_name)}

' - f'

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

' - f'{project_card}' - f'

{_t("email_quote_verify_urgency", lang)}

' - f'{_email_button(link, _t("email_quote_verify_btn", lang))}' - f'

{_t("email_quote_verify_expires", lang)}

' - f'

{_t("email_quote_verify_fallback", lang)}

' - f'

{link}

' - f'

{_t("email_quote_verify_ignore", lang)}

' + html = render_email_template( + "emails/quote_verification.html", + lang=lang, + link=link, + first_name=first_name, + recap_parts=recap_parts, + preheader=preheader, ) await send_email( to=payload["email"], subject=_t("email_quote_verify_subject", lang), - html=_email_wrap(body, lang, preheader=preheader), + html=html, from_addr=EMAIL_ADDRESSES["transactional"], email_type="quote_verification", ) @@ -315,27 +217,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", ) @@ -351,43 +243,24 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None: if intent.startswith("supplier_"): plan_name = intent.replace("supplier_", "").title() subject = _t("email_waitlist_supplier_subject", lang, plan_name=plan_name) - preheader = _t("email_waitlist_supplier_preheader", lang) - body = ( - f'

{_t("email_waitlist_supplier_heading", lang)}

' - f'
' - f'

{_t("email_waitlist_supplier_body", lang, plan_name=plan_name)}

' - f'

{_t("email_waitlist_supplier_perks_intro", lang)}

' - f'
    ' - f'
  • {_t("email_waitlist_supplier_perk_1", lang)}
  • ' - f'
  • {_t("email_waitlist_supplier_perk_2", lang)}
  • ' - f'
  • {_t("email_waitlist_supplier_perk_3", lang)}
  • ' - f'
' - f'

{_t("email_waitlist_supplier_meanwhile", lang)}

' - f'' + html = render_email_template( + "emails/waitlist_supplier.html", + lang=lang, + plan_name=plan_name, + preheader=_t("email_waitlist_supplier_preheader", lang), ) else: subject = _t("email_waitlist_general_subject", lang) - preheader = _t("email_waitlist_general_preheader", lang) - body = ( - f'

{_t("email_waitlist_general_heading", lang)}

' - f'
' - f'

{_t("email_waitlist_general_body", lang)}

' - f'

{_t("email_waitlist_general_perks_intro", lang)}

' - f'
    ' - f'
  • {_t("email_waitlist_general_perk_1", lang)}
  • ' - f'
  • {_t("email_waitlist_general_perk_2", lang)}
  • ' - f'
  • {_t("email_waitlist_general_perk_3", lang)}
  • ' - f'
' - f'

{_t("email_waitlist_general_outro", lang)}

' + html = render_email_template( + "emails/waitlist_general.html", + lang=lang, + preheader=_t("email_waitlist_general_preheader", lang), ) await send_email( to=email, subject=subject, - html=_email_wrap(body, lang, preheader=preheader), + html=html, from_addr=EMAIL_ADDRESSES["transactional"], email_type="waitlist", ) @@ -428,15 +301,6 @@ async def handle_send_lead_forward_email(payload: dict) -> None: subject = f"[{heat}] New padel project in {country} \u2014 {courts} courts, \u20ac{budget}" - # Heat badge color - heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} - heat_bg = heat_colors.get(heat, "#2563EB") - heat_badge = ( - f'{heat}' - ) - tl = lambda key: _t(key, lang) # noqa: E731 brief_rows = [ @@ -449,50 +313,11 @@ async def handle_send_lead_forward_email(payload: dict) -> None: (tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"), ] - brief_html = "" - for label, value in brief_rows: - brief_html += ( - f'{label}' - f'{value}' - ) - - contact_name = lead["contact_name"] or "-" - contact_phone = lead["contact_phone"] or "-" - - # Contact section with prominent email - contact_html = ( - f'{tl("email_lead_forward_lbl_name")}' - f'{contact_name}' - f'{tl("email_lead_forward_lbl_email")}' - f'{contact_email}' - f'{tl("email_lead_forward_lbl_phone")}' - f'{contact_phone}' - f'{tl("email_lead_forward_lbl_company")}' - f'{lead["contact_company"] or "-"}' - f'{tl("email_lead_forward_lbl_role")}' - f'{lead["stakeholder_type"] or "-"}' - ) - preheader_parts = [f"{facility_type} project"] if timeline: preheader_parts.append(f"{timeline} timeline") preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang)) - body = ( - f'

' - f'{_t("email_lead_forward_urgency", lang)}

' - f'

{tl("email_lead_forward_heading")} {heat_badge}

' - f'
' - f'

{tl("email_lead_forward_section_brief")}

' - f'{brief_html}
' - f'

{tl("email_lead_forward_section_contact")}

' - f'{contact_html}
' - f'{_email_button(f"{config.BASE_URL}/suppliers/leads", tl("email_lead_forward_btn"))}' - f'

' - f'{_t("email_lead_forward_reply_direct", lang, contact_email=contact_email)}

' - ) - # Send to supplier contact email or general contact to_email = supplier.get("contact_email") or supplier.get("contact") or "" if not to_email: @@ -502,16 +327,25 @@ async def handle_send_lead_forward_email(payload: dict) -> None: # Generate one-click "I've contacted this lead" CTA token cta_token = secrets.token_urlsafe(24) cta_url = f"{config.BASE_URL}/suppliers/leads/cta/{cta_token}" - body += ( - f'

' - f'' - f'✓ Mark as contacted

' + + html = render_email_template( + "emails/lead_forward.html", + lang=lang, + heat=heat, + brief_rows=brief_rows, + contact_name=lead["contact_name"] or "-", + contact_email=contact_email, + contact_phone=lead["contact_phone"] or "-", + contact_company=lead["contact_company"] or "-", + stakeholder_type=lead["stakeholder_type"] or "-", + cta_url=cta_url, + preheader=", ".join(preheader_parts), ) await send_email( to=to_email, subject=subject, - html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)), + html=html, from_addr=EMAIL_ADDRESSES["leads"], email_type="lead_forward", ) @@ -535,26 +369,20 @@ async def handle_send_lead_matched_notification(payload: dict) -> None: first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there" - body = ( - f'

{_t("email_lead_matched_heading", lang)}

' - f'
' - f'

{_t("email_lead_matched_greeting", lang, first_name=first_name)}

' - f'

{_t("email_lead_matched_body", lang)}

' - f'

{_t("email_lead_matched_context", lang, facility_type=lead["facility_type"] or "padel", court_count=lead["court_count"] or "?", country=lead["country"] or "your area")}

' - # What happens next - f'

{_t("email_lead_matched_next_heading", lang)}

' - f'

{_t("email_lead_matched_next_body", lang)}

' - f'

' - f'{_t("email_lead_matched_tip", lang)}

' - f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_lead_matched_btn", lang))}' - f'

{_t("email_lead_matched_note", lang)}

' + html = render_email_template( + "emails/lead_matched.html", + lang=lang, + first_name=first_name, + facility_type=lead["facility_type"] or "padel", + court_count=lead["court_count"] or "?", + country=lead["country"] or "your area", + preheader=_t("email_lead_matched_preheader", lang), ) await send_email( to=lead["contact_email"], subject=_t("email_lead_matched_subject", lang, first_name=first_name), - html=_email_wrap(body, lang, preheader=_t("email_lead_matched_preheader", lang)), + html=html, from_addr=EMAIL_ADDRESSES["leads"], email_type="lead_matched", ) @@ -599,30 +427,22 @@ async def handle_notify_matching_suppliers(payload: dict) -> None: if not to_email: continue - body = ( - f'

' - f'New [{heat}] lead in {country}

' - f'
' - f'

A new project brief has been submitted that matches your service area.

' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'
Facility{facility_type}
Courts{courts}
Country{country}
Timeline{timeline or "-"}
' - f'

' - f'Contact details are available after unlocking. Credits required: {lead.get("credit_cost", "?")}.

' - f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "View lead feed")}' + notify_html = render_email_template( + "emails/lead_match_notify.html", + lang=lang, + heat=heat, + country=country, + facility_type=facility_type, + courts=courts, + timeline=timeline, + credit_cost=lead.get("credit_cost", "?"), + preheader=f"New matching lead in {country}", ) await send_email( to=to_email, subject=f"[{heat}] New {facility_type} project in {country} — {courts} courts", - html=_email_wrap(body, lang, preheader=f"New matching lead in {country}"), + html=notify_html, from_addr=EMAIL_ADDRESSES["leads"], email_type="lead_match_notify", ) @@ -667,48 +487,27 @@ async def handle_send_weekly_lead_digest(payload: dict) -> None: if not new_leads: continue - lead_rows_html = "" - for ld in new_leads: - heat = (ld["heat_score"] or "cool").upper() - heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} - hc = heat_colors.get(heat, "#2563EB") - badge = ( - f'{heat}' - ) - lead_rows_html += ( - f'' - f'' - f'{badge} {ld["facility_type"] or "Padel"}, {ld["court_count"] or "?"} courts' - f'{ld["country"] or "-"}' - f'{ld["timeline"] or "-"}' - f'' - ) - - body = ( - f'

' - f'Your weekly lead digest — {len(new_leads)} new {"lead" if len(new_leads) == 1 else "leads"}

' - f'
' - f'

New matching leads in your service area this week:

' - f'' - f'' - f'' - f'' - f'' - f'' - f'{lead_rows_html}' - f'
ProjectCountryTimeline
' - f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "Unlock leads →")}' - ) + # Normalise lead dicts for template — heat_score → heat (uppercase) + digest_leads = [ + {**ld, "heat": (ld["heat_score"] or "cool").upper()} + for ld in new_leads + ] area_summary = ", ".join(countries[:3]) if len(countries) > 3: area_summary += f" +{len(countries) - 3}" + digest_html = render_email_template( + "emails/weekly_digest.html", + lang="en", + leads=digest_leads, + preheader=f"{len(new_leads)} new leads matching your service area", + ) + await send_email( to=to_email, subject=f"{len(new_leads)} new padel {'lead' if len(new_leads) == 1 else 'leads'} in {area_summary}", - html=_email_wrap(body, "en", preheader=f"{len(new_leads)} new leads matching your service area"), + html=digest_html, from_addr=EMAIL_ADDRESSES["leads"], email_type="weekly_digest", ) @@ -727,25 +526,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", ) @@ -831,19 +625,17 @@ async def handle_generate_business_plan(payload: dict) -> None: export_token = export_row["token"] user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,)) if user: - body = ( - f'

{_t("email_business_plan_heading", language)}

' - f'
' - f'

{_t("email_business_plan_body", language)}

' - f'

{_t("email_business_plan_includes", language)}

' - f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", language))}' - f'

' - f'{_t("email_business_plan_quote_cta", language, quote_url=f"{config.BASE_URL}/{language}/leads/quote")}

' + bp_html = render_email_template( + "emails/business_plan.html", + lang=language, + download_url=f"{config.BASE_URL}/planner/export/{export_token}", + quote_url=f"{config.BASE_URL}/{language}/leads/quote", + preheader=_t("email_business_plan_preheader", language), ) await send_email( to=user["email"], subject=_t("email_business_plan_subject", language), - html=_email_wrap(body, language, preheader=_t("email_business_plan_preheader", language)), + html=bp_html, from_addr=EMAIL_ADDRESSES["transactional"], email_type="business_plan", ) diff --git a/web/tests/test_email_templates.py b/web/tests/test_email_templates.py new file mode 100644 index 0000000..b5dc04e --- /dev/null +++ b/web/tests/test_email_templates.py @@ -0,0 +1,248 @@ +""" +Tests for the standalone email template renderer and admin gallery routes. + +render_email_template() tests: each registry entry renders without error, +produces a valid DOCTYPE document, includes the wordmark, and supports both +EN and DE languages. + +Admin gallery tests: access control, list page, preview page, error handling. +""" + +import pytest +from padelnomics.core import utcnow_iso +from padelnomics.email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template + + +# ── render_email_template() ────────────────────────────────────────────────── + + +class TestRenderEmailTemplate: + """render_email_template() produces valid HTML for all registry entries.""" + + @pytest.mark.parametrize("slug", list(EMAIL_TEMPLATE_REGISTRY.keys())) + def test_all_templates_render_en(self, slug): + entry = EMAIL_TEMPLATE_REGISTRY[slug] + sample = entry["sample_data"]("en") + html = render_email_template(entry["template"], lang="en", **sample) + assert "" in html + assert "padelnomics" in html.lower() + assert 'lang="en"' in html + + @pytest.mark.parametrize("slug", list(EMAIL_TEMPLATE_REGISTRY.keys())) + def test_all_templates_render_de(self, slug): + entry = EMAIL_TEMPLATE_REGISTRY[slug] + sample = entry["sample_data"]("de") + html = render_email_template(entry["template"], lang="de", **sample) + assert "" in html + assert "padelnomics" in html.lower() + assert 'lang="de"' in html + + def test_magic_link_contains_verify_link(self): + entry = EMAIL_TEMPLATE_REGISTRY["magic_link"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "/auth/verify?token=" in html + + def test_magic_link_has_preheader(self): + entry = EMAIL_TEMPLATE_REGISTRY["magic_link"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "display:none" in html # hidden preheader span + + def test_lead_forward_has_heat_badge(self): + entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "HOT" in html + assert "#DC2626" in html # HOT badge color + + def test_lead_forward_has_brief_rows(self): + entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + # Brief rows table is rendered (e.g. "Facility" label) + assert "Facility" in html + + def test_lead_forward_has_contact_info(self): + entry = EMAIL_TEMPLATE_REGISTRY["lead_forward"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "ceo@padelclub.es" in html + assert "Carlos Rivera" in html + + def test_weekly_digest_loops_over_leads(self): + entry = EMAIL_TEMPLATE_REGISTRY["weekly_digest"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + # Sample data has 3 leads — all 3 countries should appear + assert "Germany" in html + assert "Austria" in html + assert "Switzerland" in html + + def test_weekly_digest_has_heat_badges(self): + entry = EMAIL_TEMPLATE_REGISTRY["weekly_digest"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "HOT" in html + assert "WARM" in html + assert "COOL" in html + + def test_welcome_has_quickstart_links(self): + entry = EMAIL_TEMPLATE_REGISTRY["welcome"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "/planner" in html + assert "/markets" in html + + def test_admin_compose_renders_body_html(self): + entry = EMAIL_TEMPLATE_REGISTRY["admin_compose"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "test message" in html.lower() + + def test_business_plan_has_download_link(self): + entry = EMAIL_TEMPLATE_REGISTRY["business_plan"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "/planner/export/" in html + + def test_invalid_lang_raises(self): + entry = EMAIL_TEMPLATE_REGISTRY["magic_link"] + with pytest.raises(AssertionError, match="Unsupported lang"): + render_email_template(entry["template"], lang="fr", **entry["sample_data"]("en")) + + def test_non_emails_prefix_raises(self): + with pytest.raises(AssertionError, match="Expected emails/ prefix"): + render_email_template("base.html", lang="en") + + def test_common_design_elements_present(self): + """Branded shell must include font + blue accent across all templates.""" + entry = EMAIL_TEMPLATE_REGISTRY["magic_link"] + html = render_email_template(entry["template"], lang="en", **entry["sample_data"]("en")) + assert "Bricolage Grotesque" in html + assert "#1D4ED8" in html + assert "padelnomics.io" in html + + def test_registry_has_required_keys(self): + for slug, entry in EMAIL_TEMPLATE_REGISTRY.items(): + assert "template" in entry, f"{slug}: missing 'template'" + assert "label" in entry, f"{slug}: missing 'label'" + assert "description" in entry, f"{slug}: missing 'description'" + assert callable(entry.get("sample_data")), f"{slug}: sample_data must be callable" + assert entry["template"].startswith("emails/"), f"{slug}: template must start with emails/" + + +# ── Admin gallery routes ────────────────────────────────────────────────────── + + +@pytest.fixture +async def admin_client(app, db): + """Test client with a user that has the admin role.""" + now = utcnow_iso() + async with db.execute( + "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", + ("gallery_admin@test.com", "Gallery Admin", now), + ) as cursor: + admin_id = cursor.lastrowid + await db.execute( + "INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,) + ) + await db.commit() + + async with app.test_client() as c: + async with c.session_transaction() as sess: + sess["user_id"] = admin_id + yield c + + +class TestEmailGalleryRoutes: + @pytest.mark.asyncio + async def test_gallery_requires_auth(self, client): + resp = await client.get("/admin/emails/gallery") + assert resp.status_code == 302 + + @pytest.mark.asyncio + async def test_gallery_list_returns_200(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery") + assert resp.status_code == 200 + + @pytest.mark.asyncio + async def test_gallery_list_shows_all_template_labels(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery") + html = (await resp.get_data(as_text=True)) + for entry in EMAIL_TEMPLATE_REGISTRY.values(): + assert entry["label"] in html, f"Expected label {entry['label']!r} on gallery page" + + @pytest.mark.asyncio + async def test_gallery_preview_magic_link_en(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery/magic_link") + assert resp.status_code == 200 + html = (await resp.get_data(as_text=True)) + assert "srcdoc" in html # sandboxed iframe is present + assert "Magic Link" in html + + @pytest.mark.asyncio + async def test_gallery_preview_magic_link_de(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery/magic_link?lang=de") + assert resp.status_code == 200 + html = (await resp.get_data(as_text=True)) + assert 'lang="de"' in html or "de" in html # lang toggle shows active state + + @pytest.mark.asyncio + async def test_gallery_preview_lead_forward(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery/lead_forward") + assert resp.status_code == 200 + html = (await resp.get_data(as_text=True)) + assert "Lead Forward" in html + assert "srcdoc" in html + + @pytest.mark.asyncio + async def test_gallery_preview_weekly_digest(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery/weekly_digest") + assert resp.status_code == 200 + + @pytest.mark.asyncio + async def test_gallery_preview_nonexistent_slug_redirects(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery/does-not-exist") + assert resp.status_code == 302 + + @pytest.mark.asyncio + async def test_gallery_preview_invalid_lang_falls_back(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery/magic_link?lang=fr") + assert resp.status_code == 200 # invalid lang → falls back to "en" + + @pytest.mark.asyncio + async def test_gallery_preview_requires_auth(self, client): + resp = await client.get("/admin/emails/gallery/magic_link") + assert resp.status_code == 302 + + @pytest.mark.asyncio + async def test_gallery_list_has_preview_links(self, admin_client): + resp = await admin_client.get("/admin/emails/gallery") + html = (await resp.get_data(as_text=True)) + # Each card links to the preview page + for slug in EMAIL_TEMPLATE_REGISTRY: + assert f"/admin/emails/gallery/{slug}" in html + + @pytest.mark.asyncio + async def test_compose_preview_plain_body(self, admin_client): + """POST to compose/preview with wrap=0 returns plain HTML body.""" + resp = await admin_client.post( + "/admin/emails/compose/preview", + form={"body": "Hello world", "wrap": "0"}, + ) + assert resp.status_code == 200 + html = (await resp.get_data(as_text=True)) + assert "Hello world" in html + + @pytest.mark.asyncio + async def test_compose_preview_wrapped_body(self, admin_client): + """POST to compose/preview with wrap=1 wraps body in branded layout.""" + resp = await admin_client.post( + "/admin/emails/compose/preview", + form={"body": "Test preview content", "wrap": "1"}, + ) + assert resp.status_code == 200 + html = (await resp.get_data(as_text=True)) + assert "Test preview content" in html + # Branded wrapper includes padelnomics wordmark + assert "padelnomics" in html.lower() + + @pytest.mark.asyncio + async def test_compose_preview_empty_body(self, admin_client): + """Empty body returns an empty but valid partial.""" + resp = await admin_client.post( + "/admin/emails/compose/preview", + form={"body": "", "wrap": "1"}, + ) + assert resp.status_code == 200