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/email_templates.py b/web/src/padelnomics/email_templates.py index 220000c..0ead4c7 100644 --- a/web/src/padelnomics/email_templates.py +++ b/web/src/padelnomics/email_templates.py @@ -107,6 +107,7 @@ def _quote_verification_sample(lang: str) -> dict: "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), } 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