feat(email-templates): Jinja2 template system + admin gallery + compose preview

Converts all 11 transactional emails from inline f-string HTML in worker.py
to standalone Jinja2 templates. Adds an admin gallery for rendered previews
and a live HTMX preview pane on the compose page.

Changes:
- email_templates.py: standalone render_email_template() + EMAIL_TEMPLATE_REGISTRY
- templates/emails/_base.html + _macros.html: branded shell + reusable macros
- 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
- worker.py: all 10 handlers updated; _email_wrap/_email_button removed
- admin/routes.py: gallery routes + compose_preview endpoint
- admin gallery: email_gallery.html + email_gallery_preview.html
- email_compose.html: two-column layout with HTMX live preview
- base_admin.html: Gallery sidebar link
- 50 new tests (test_email_templates.py)
- CHANGELOG.md + PROJECT.md updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 12:26:33 +01:00
25 changed files with 1347 additions and 339 deletions

View File

@@ -7,6 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added ### 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: - **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 - **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 - **Credit economy** — total credits issued, consumed (lead unlocks), outstanding balance across all paid suppliers, 30-day burn rate

View File

@@ -107,6 +107,8 @@
- [x] Task queue management (list, retry, delete) - [x] Task queue management (list, retry, delete)
- [x] Lead funnel stats on admin dashboard - [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 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] **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] **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 - [x] **Lead matching notifications**`notify_matching_suppliers` task on quote verification + `send_weekly_lead_digest` every Monday; one-click CTA token in forward emails

View File

@@ -33,6 +33,7 @@ from ..core import (
utcnow, utcnow,
utcnow_iso, utcnow_iso,
) )
from ..email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
logger = logging.getLogger(__name__) 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/<slug>")
@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 = "<p style='padding:2rem;color:#DC2626;'>Render error — see logs.</p>"
return await render_template(
"admin/email_gallery_preview.html",
slug=slug,
entry=entry,
lang=lang,
rendered_html=rendered_html,
)
@bp.route("/emails/results") @bp.route("/emails/results")
@role_required("admin") @role_required("admin")
async def email_results(): async def email_results():
@@ -1398,10 +1438,16 @@ async def email_compose():
email_addresses=EMAIL_ADDRESSES, email_addresses=EMAIL_ADDRESSES,
) )
html = f"<p>{body.replace(chr(10), '<br>')}</p>" body_html = f"<p>{body.replace(chr(10), '<br>')}</p>"
if wrap: if wrap:
from ..worker import _email_wrap html = render_email_template(
html = _email_wrap(html) "emails/admin_compose.html",
lang="en",
body_html=body_html,
preheader="",
)
else:
html = body_html
result = await send_email( result = await send_email(
to=to, subject=subject, html=html, 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"<p>{body.replace(chr(10), '<br>')}</p>" 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 --- # --- Audiences ---
@bp.route("/emails/audiences") @bp.route("/emails/audiences")

View File

@@ -118,6 +118,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
Compose Compose
</a> </a>
<a href="{{ url_for('admin.email_gallery') }}" class="{% if admin_page == 'gallery' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z"/></svg>
Gallery
</a>
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}"> <a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
Audiences Audiences

View File

@@ -2,51 +2,91 @@
{% set admin_page = "compose" %} {% set admin_page = "compose" %}
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %} {% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.compose-layout { display: grid; grid-template-columns: 480px 1fr; gap: 1.5rem; align-items: start; }
@media (max-width: 1100px) { .compose-layout { grid-template-columns: 1fr; } }
.preview-panel { position: sticky; top: 1rem; }
.preview-label { font-size: 0.75rem; font-weight: 600; color: #64748B; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
#preview-pane { min-height: 400px; background: #F1F5F9; border-radius: 8px; }
#preview-pane.loading { opacity: 0.5; }
</style>
{% endblock %}
{% block admin_content %} {% block admin_content %}
<header class="mb-6"> <header class="mb-6">
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">&larr; Sent Log</a> <a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">&larr; Sent Log</a>
<h1 class="text-2xl mt-1">Compose Email</h1> <h1 class="text-2xl mt-1">Compose Email</h1>
</header> </header>
<div class="card" style="padding:1.5rem;max-width:640px"> <div class="compose-layout">
<form method="post" action="{{ url_for('admin.email_compose') }}"> {# ── Left: form ────────────────────────────────────── #}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <div>
<div class="card" style="padding:1.5rem;">
<form id="compose-form" method="post" action="{{ url_for('admin.email_compose') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4"> <div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">From</label> <label class="text-xs font-semibold text-slate block mb-1">From</label>
<select name="from_addr" class="form-input"> <select name="from_addr" class="form-input">
{% for key, addr in email_addresses.items() %} {% for key, addr in email_addresses.items() %}
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option> <option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">To</label> <label class="text-xs font-semibold text-slate block mb-1">To</label>
<input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required> <input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">Subject</label> <label class="text-xs font-semibold text-slate block mb-1">Subject</label>
<input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required> <input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">Body</label> <label class="text-xs font-semibold text-slate block mb-1">Body</label>
<textarea name="body" rows="12" class="form-input" placeholder="Plain text (line breaks become &lt;br&gt;)" required>{{ data.get('body', '') }}</textarea> <textarea
</div> name="body" rows="14" class="form-input"
placeholder="Plain text (line breaks become &lt;br&gt;)"
required
hx-post="{{ url_for('admin.compose_preview') }}"
hx-trigger="input delay:500ms, change"
hx-target="#preview-pane"
hx-include="#compose-form [name='wrap']"
hx-indicator="#preview-pane"
>{{ data.get('body', '') }}</textarea>
</div>
<div class="mb-6"> <div class="mb-6">
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="wrap" value="1" checked> <input
Wrap in branded email template type="checkbox" name="wrap" value="1"
</label> {% if data.get('wrap', True) %}checked{% endif %}
</div> hx-post="{{ url_for('admin.compose_preview') }}"
hx-trigger="change"
hx-target="#preview-pane"
hx-include="#compose-form textarea[name='body']"
>
Wrap in branded email template
</label>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" class="btn">Send Email</button> <button type="submit" class="btn">Send Email</button>
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a> <a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
</div>
</form>
</div> </div>
</form> </div>
{# ── Right: live preview panel ─────────────────────── #}
<div class="preview-panel">
<div class="preview-label">Live preview</div>
<div id="preview-pane">
<p style="padding:2rem;font-size:0.875rem;color:#94A3B8;text-align:center;">Start typing to see a preview…</p>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -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 %}
<style>
.gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 1100px) { .gallery-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 720px) { .gallery-grid { grid-template-columns: 1fr; } }
.gallery-card {
display: block;
text-decoration: none;
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 10px;
padding: 1.25rem 1.25rem 1rem;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
position: relative;
overflow: hidden;
}
.gallery-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: #1D4ED8;
transform: scaleY(0);
transform-origin: bottom;
transition: transform 0.2s ease;
}
.gallery-card:hover {
border-color: #BFDBFE;
box-shadow: 0 4px 16px rgba(29,78,216,0.08);
transform: translateY(-1px);
}
.gallery-card:hover::before { transform: scaleY(1); }
.gallery-card__icon {
width: 36px; height: 36px;
background: #EFF6FF;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 0.875rem;
flex-shrink: 0;
}
.gallery-card__icon svg { width: 18px; height: 18px; color: #1D4ED8; }
.gallery-card__label { font-size: 0.9375rem; font-weight: 600; color: #0F172A; margin-bottom: 0.25rem; }
.gallery-card__desc { font-size: 0.8125rem; color: #64748B; line-height: 1.5; margin-bottom: 0.875rem; }
.gallery-card__cta { font-size: 0.8125rem; font-weight: 500; color: #1D4ED8; }
</style>
{% endblock %}
{% block admin_content %}
<header class="mb-6">
<div style="display:flex;align-items:baseline;gap:1rem;flex-wrap:wrap;">
<h1 class="text-2xl">Email Gallery</h1>
<span style="font-size:0.8125rem;color:#94A3B8;">{{ registry | length }} template{{ 's' if registry | length != 1 else '' }}</span>
</div>
<p style="margin-top:0.25rem;font-size:0.875rem;color:#64748B;">Rendered previews of all transactional email templates with sample data.</p>
</header>
<div class="gallery-grid">
{% for slug, entry in registry.items() %}
<a class="gallery-card" href="{{ url_for('admin.email_gallery_preview', slug=slug) }}">
<div class="gallery-card__icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/>
</svg>
</div>
<div class="gallery-card__label">{{ entry.label }}</div>
<div class="gallery-card__desc">{{ entry.description }}</div>
<div class="gallery-card__cta">Preview &rarr;</div>
</a>
{% endfor %}
</div>
{% endblock %}

View File

@@ -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 %}
<style>
.preview-toolbar {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.preview-toolbar__title { flex: 1; min-width: 0; }
.lang-toggle { display: flex; gap: 2px; background: #F1F5F9; border-radius: 6px; padding: 2px; }
.lang-toggle a {
padding: 4px 12px; font-size: 0.75rem; font-weight: 600;
border-radius: 4px; text-decoration: none; color: #64748B; transition: all 0.1s;
}
.lang-toggle a.active { background: #FFFFFF; color: #1D4ED8; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.preview-iframe {
width: 100%; height: 700px;
border: 1px solid #E2E8F0; border-radius: 8px;
background: #F1F5F9;
}
</style>
{% endblock %}
{% block admin_content %}
<header class="mb-4">
<a href="{{ url_for('admin.email_gallery') }}" class="text-sm text-slate">&larr; Email Gallery</a>
<h1 class="text-2xl mt-1">{{ entry.label }}</h1>
<p style="margin-top:0.25rem;font-size:0.875rem;color:#64748B;">{{ entry.description }}</p>
</header>
<div class="preview-toolbar">
<div class="preview-toolbar__title">
{% if entry.email_type %}
<a href="{{ url_for('admin.emails') }}?email_type={{ entry.email_type }}"
style="font-size:0.8125rem;color:#1D4ED8;text-decoration:none;">
View in sent log &rarr;
</a>
{% endif %}
</div>
{# Language toggle #}
<div class="lang-toggle">
<a href="{{ url_for('admin.email_gallery_preview', slug=slug, lang='en') }}"
class="{{ 'active' if lang == 'en' else '' }}">EN</a>
<a href="{{ url_for('admin.email_gallery_preview', slug=slug, lang='de') }}"
class="{{ 'active' if lang == 'de' else '' }}">DE</a>
</div>
</div>
<iframe
class="preview-iframe"
srcdoc="{{ rendered_html | e }}"
sandbox="allow-same-origin"
title="{{ entry.label }} email preview"
></iframe>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{# HTMX partial: sandboxed iframe showing a rendered email preview.
Rendered by POST /admin/emails/compose/preview. #}
<iframe
srcdoc="{{ rendered_html | e }}"
style="width:100%;height:600px;border:none;border-radius:8px;background:#F1F5F9;"
sandbox="allow-same-origin"
title="Email preview"
></iframe>

View File

@@ -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 <!DOCTYPE html> 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 "<!DOCTYPE html>" 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": "<p>Hello,</p><p>This is a test message from the admin compose panel.</p><p>Best regards,<br>Padelnomics Team</p>",
"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,
},
}

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ app_name }}</title>
</head>
<body style="margin:0;padding:0;background-color:#F1F5F9;font-family:Helvetica,Arial,sans-serif;">
{%- if preheader %}
{# Hidden preheader trick: visible text + invisible padding to prevent
email clients from pulling body text into the preview. #}
<span style="display:none;font-size:1px;color:#F1F5F9;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">{{ preheader }}{% for _ in range(30) %}&#847; &zwnj; {% endfor %}</span>
{%- endif %}
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F1F5F9;padding:40px 16px;">
<tr><td align="center">
<table width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background-color:#FFFFFF;border-radius:10px;border:1px solid #E2E8F0;overflow:hidden;">
<!-- Blue accent border -->
<tr><td style="height:3px;background-color:#1D4ED8;font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- Wordmark header -->
<tr><td style="background-color:#0F172A;padding:24px 36px;">
<a href="{{ base_url }}" style="text-decoration:none;">
<span style="color:#FFFFFF;font-size:18px;font-weight:800;letter-spacing:-0.02em;font-family:'Bricolage Grotesque',Georgia,'Times New Roman',serif;">padelnomics</span>
</a>
</td></tr>
<!-- Body -->
<tr><td style="padding:36px;color:#334155;font-size:15px;line-height:1.65;">
{% block body %}{% endblock %}
</td></tr>
<!-- Divider -->
<tr><td style="height:1px;background-color:#E2E8F0;"></td></tr>
<!-- Footer -->
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
<a href="{{ base_url }}" style="color:#64748B;text-decoration:none;font-weight:500;">padelnomics.io</a>
&nbsp;&middot;&nbsp;
{{ tagline }}
</p>
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
{{ copyright_text }}
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -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) %}
<table cellpadding="0" cellspacing="0" width="100%" style="margin:28px 0 8px;">
<tr><td style="background-color:#1D4ED8;border-radius:8px;text-align:center;">
<a href="{{ url }}" style="display:block;padding:14px 32px;color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">{{ label }}</a>
</td></tr>
</table>
{% 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") -%}
<span style="display:inline-block;padding:2px 8px;border-radius:4px;background-color:{{ bg }};color:#FFFFFF;font-size:11px;font-weight:700;letter-spacing:0.04em;vertical-align:middle;margin-left:8px;">{{ heat }}</span>
{%- 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") -%}
<span style="display:inline-block;padding:1px 6px;border-radius:4px;background-color:{{ bg }};color:#fff;font-size:10px;font-weight:700;">{{ heat }}</span>
{%- endmacro %}
{# ─── Section Heading ─────────────────────────────────────────────────────────
Small uppercase label above a data table section.
#}
{% macro section_heading(text) %}
<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px;">{{ text }}</h3>
{% 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 -%}
<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;background-color:{{ bg }};border-radius:6px;border-left:3px solid {{ border }};">{{ text }}</p>
{% endmacro %}

View File

@@ -0,0 +1,5 @@
{% extends "emails/_base.html" %}
{% block body %}
{{ body_html | safe }}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_business_plan_heading }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_business_plan_body }}</p>
<p style="font-size:14px;color:#334155;">{{ t.email_business_plan_includes }}</p>
{{ email_button(download_url, t.email_business_plan_btn) }}
<p style="font-size:13px;color:#64748B;text-align:center;margin:12px 0 0;">{{ t.email_business_plan_quote_cta | tformat(quote_url=quote_url) }}</p>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button, heat_badge, section_heading %}
{% block body %}
{# Yellow urgency banner #}
<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;background-color:#FEF3C7;border-radius:6px;border-left:3px solid #F59E0B;">{{ t.email_lead_forward_urgency }}</p>
<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{{ t.email_lead_forward_heading }} {{ heat_badge(heat) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:8px 0 16px;">
{{ section_heading(t.email_lead_forward_section_brief) }}
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
{% for label, value in brief_rows %}
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;vertical-align:top;">{{ label }}</td>
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ value }}</td>
</tr>
{% endfor %}
</table>
{{ section_heading(t.email_lead_forward_section_contact) }}
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_name }}</td>
<td style="padding:4px 0;font-size:14px;color:#0F172A;font-weight:600;">{{ contact_name }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_email }}</td>
<td style="padding:4px 0;font-size:14px;"><a href="mailto:{{ contact_email }}" style="color:#1D4ED8;font-weight:600;text-decoration:none;">{{ contact_email }}</a></td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_phone }}</td>
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ contact_phone }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_company }}</td>
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ contact_company }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">{{ t.email_lead_forward_lbl_role }}</td>
<td style="padding:4px 0;font-size:13px;color:#1E293B;">{{ stakeholder_type }}</td>
</tr>
</table>
{{ email_button(base_url ~ "/suppliers/leads", t.email_lead_forward_btn) }}
<p style="font-size:13px;color:#64748B;text-align:center;margin:8px 0 0;">{{ t.email_lead_forward_reply_direct | tformat(contact_email=contact_email) }}</p>
{%- if cta_url %}
<p style="font-size:12px;color:#94A3B8;text-align:center;margin:16px 0 0;">
<a href="{{ cta_url }}" style="color:#94A3B8;">&#10003; Mark as contacted</a>
</p>
{%- endif %}
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button, heat_badge %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">New [{{ heat }}] lead in {{ country }} {{ heat_badge(heat) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p style="font-size:14px;color:#334155;">A new project brief has been submitted that matches your service area.</p>
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;">
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Facility</td>
<td style="font-size:13px;color:#1E293B;">{{ facility_type }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Courts</td>
<td style="font-size:13px;color:#1E293B;">{{ courts }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Country</td>
<td style="font-size:13px;color:#1E293B;">{{ country }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;">Timeline</td>
<td style="font-size:13px;color:#1E293B;">{{ timeline or "-" }}</td>
</tr>
</table>
<p style="font-size:13px;color:#64748B;">Contact details are available after unlocking. Credits required: {{ credit_cost }}.</p>
{{ email_button(base_url ~ "/suppliers/leads", "View lead feed") }}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_lead_matched_heading }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_lead_matched_greeting | tformat(first_name=first_name) }}</p>
<p>{{ t.email_lead_matched_body }}</p>
<p style="font-size:13px;color:#64748B;">{{ t.email_lead_matched_context | tformat(facility_type=facility_type, court_count=court_count, country=country) }}</p>
<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{{ t.email_lead_matched_next_heading }}</p>
<p style="font-size:14px;color:#334155;">{{ t.email_lead_matched_next_body }}</p>
<p style="font-size:13px;color:#64748B;padding:10px 14px;background-color:#F0F9FF;border-radius:6px;border-left:3px solid #1D4ED8;">{{ t.email_lead_matched_tip }}</p>
{{ email_button(base_url ~ "/dashboard", t.email_lead_matched_btn) }}
<p style="font-size:12px;color:#94A3B8;">{{ t.email_lead_matched_note }}</p>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_magic_link_heading | tformat(app_name=app_name) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_magic_link_body | tformat(expiry_minutes=expiry_minutes) }}</p>
{{ email_button(link, t.email_magic_link_btn) }}
<p style="font-size:13px;color:#94A3B8;">{{ t.email_magic_link_fallback }}</p>
<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{{ link }}</p>
<p style="font-size:13px;color:#94A3B8;">{{ t.email_magic_link_ignore }}</p>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_quote_verify_heading }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_quote_verify_greeting | tformat(first_name=first_name) }}</p>
<p>{{ t.email_quote_verify_body | tformat(app_name=app_name) }}</p>
{%- if recap_parts %}
<table cellpadding="0" cellspacing="0" width="100%" style="margin:16px 0;border:1px solid #E2E8F0;border-radius:8px;overflow:hidden;">
<tr><td style="padding:14px 18px;background-color:#F8FAFC;font-size:13px;color:#64748B;">
<strong style="color:#0F172A;">{{ t.email_quote_verify_project_label }}</strong> {{ recap_parts | join(" &middot; ") | safe }}
</td></tr>
</table>
{%- endif %}
<p style="font-size:13px;color:#334155;">{{ t.email_quote_verify_urgency }}</p>
{{ email_button(link, t.email_quote_verify_btn) }}
<p style="font-size:13px;color:#94A3B8;">{{ t.email_quote_verify_expires }}</p>
<p style="font-size:13px;color:#94A3B8;">{{ t.email_quote_verify_fallback }}</p>
<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{{ link }}</p>
<p style="font-size:13px;color:#94A3B8;">{{ t.email_quote_verify_ignore }}</p>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "emails/_base.html" %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_enquiry_heading | tformat(contact_name=contact_name) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_enquiry_body | tformat(supplier_name=supplier_name) }}</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
<tr>
<td style="padding:6px 0;color:#64748B;width:120px;">{{ t.email_enquiry_lbl_from }}</td>
<td style="padding:6px 0;"><strong>{{ contact_name }}</strong> &lt;{{ contact_email }}&gt;</td>
</tr>
<tr>
<td style="padding:6px 0;color:#64748B;vertical-align:top;">{{ t.email_enquiry_lbl_message }}</td>
<td style="padding:6px 0;white-space:pre-wrap;">{{ message }}</td>
</tr>
</table>
<p style="font-size:13px;color:#64748B;">{{ t.email_enquiry_respond_fast }}</p>
<p style="font-size:13px;color:#64748B;">{{ t.email_enquiry_reply | tformat(contact_email=contact_email) }}</p>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "emails/_base.html" %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_waitlist_general_heading }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_waitlist_general_body }}</p>
<p>{{ t.email_waitlist_general_perks_intro }}</p>
<ul style="font-size:14px;color:#1E293B;margin:16px 0;">
<li>{{ t.email_waitlist_general_perk_1 }}</li>
<li>{{ t.email_waitlist_general_perk_2 }}</li>
<li>{{ t.email_waitlist_general_perk_3 }}</li>
</ul>
<p style="font-size:13px;color:#64748B;">{{ t.email_waitlist_general_outro }}</p>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "emails/_base.html" %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_waitlist_supplier_heading }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_waitlist_supplier_body | tformat(plan_name=plan_name) }}</p>
<p>{{ t.email_waitlist_supplier_perks_intro }}</p>
<ul style="font-size:14px;color:#1E293B;margin:16px 0;">
<li>{{ t.email_waitlist_supplier_perk_1 }}</li>
<li>{{ t.email_waitlist_supplier_perk_2 }}</li>
<li>{{ t.email_waitlist_supplier_perk_3 }}</li>
</ul>
<p style="font-size:13px;color:#64748B;">{{ t.email_waitlist_supplier_meanwhile }}</p>
<ul style="font-size:13px;color:#64748B;">
<li><a href="{{ base_url }}/planner" style="color:#1D4ED8;">{{ t.email_waitlist_supplier_link_planner }}</a></li>
<li><a href="{{ base_url }}/directory" style="color:#1D4ED8;">{{ t.email_waitlist_supplier_link_directory }}</a></li>
</ul>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button, heat_badge_sm %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">
Your weekly lead digest &mdash; {{ leads | length }} new {{ "lead" if leads | length == 1 else "leads" }}
</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p style="font-size:14px;color:#334155;">New matching leads in your service area this week:</p>
<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;width:100%;">
<thead>
<tr>
<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase;">Project</th>
<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase;">Country</th>
<th style="text-align:left;font-size:11px;color:#94A3B8;text-transform:uppercase;">Timeline</th>
</tr>
</thead>
<tbody>
{% for lead in leads %}
<tr>
<td style="padding:6px 12px 6px 0;font-size:13px;color:#1E293B;">
{{ heat_badge_sm(lead.heat | upper) }} {{ lead.facility_type or "Padel" }}, {{ lead.court_count or "?" }} courts
</td>
<td style="padding:6px 12px 6px 0;font-size:13px;color:#64748B;">{{ lead.country or "-" }}</td>
<td style="padding:6px 0;font-size:13px;color:#64748B;">{{ lead.timeline or "-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ email_button(base_url ~ "/suppliers/leads", "Unlock leads →") }}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "emails/_base.html" %}
{% from "emails/_macros.html" import email_button %}
{% block body %}
<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{{ t.email_welcome_heading | tformat(app_name=app_name) }}</h2>
<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">
<p>{{ t.email_welcome_greeting | tformat(first_name=first_name) }}</p>
<p>{{ t.email_welcome_body }}</p>
<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{{ t.email_welcome_quickstart_heading }}</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 20px;font-size:14px;">
<tr>
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>
<td style="padding:4px 0;"><a href="{{ base_url }}/planner" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_planner }}</a></td>
</tr>
<tr>
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>
<td style="padding:4px 0;"><a href="{{ base_url }}/{{ lang }}/markets" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_markets }}</a></td>
</tr>
<tr>
<td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>
<td style="padding:4px 0;"><a href="{{ base_url }}/{{ lang }}/leads/quote" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{{ t.email_welcome_link_quotes }}</a></td>
</tr>
</table>
{{ email_button(base_url ~ "/planner", t.email_welcome_btn) }}
{% endblock %}

View File

@@ -22,6 +22,7 @@ from .core import (
utcnow, utcnow,
utcnow_iso, utcnow_iso,
) )
from .email_templates import render_email_template
from .i18n import get_translations from .i18n import get_translations
logger = logging.getLogger(__name__) 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 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'<span style="display:none;font-size:1px;color:#F1F5F9;line-height:1px;'
f'max-height:0;max-width:0;opacity:0;overflow:hidden;">'
f'{preheader}{"&#847; &zwnj; " * 30}</span>'
)
return f"""\
<!DOCTYPE html>
<html lang="{lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{config.APP_NAME}</title>
</head>
<body style="margin:0;padding:0;background-color:#F1F5F9;font-family:Helvetica,Arial,sans-serif;">
{preheader_html}
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F1F5F9;padding:40px 16px;">
<tr><td align="center">
<table width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background-color:#FFFFFF;border-radius:10px;border:1px solid #E2E8F0;overflow:hidden;">
<!-- Blue accent border -->
<tr><td style="height:3px;background-color:#1D4ED8;font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- Wordmark header -->
<tr><td style="background-color:#0F172A;padding:24px 36px;">
<a href="{config.BASE_URL}" style="text-decoration:none;">
<span style="color:#FFFFFF;font-size:18px;font-weight:800;letter-spacing:-0.02em;font-family:'Bricolage Grotesque',Georgia,'Times New Roman',serif;">padelnomics</span>
</a>
</td></tr>
<!-- Body -->
<tr><td style="padding:36px;color:#334155;font-size:15px;line-height:1.65;">
{body}
</td></tr>
<!-- Divider -->
<tr><td style="height:1px;background-color:#E2E8F0;"></td></tr>
<!-- Footer -->
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
<a href="{config.BASE_URL}" style="color:#64748B;text-decoration:none;font-weight:500;">padelnomics.io</a>
&nbsp;&middot;&nbsp;
{tagline}
</p>
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
{copyright_text}
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>"""
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'<table cellpadding="0" cellspacing="0" width="100%" style="margin:28px 0 8px;">'
f'<tr><td style="background-color:#1D4ED8;border-radius:8px;text-align:center;">'
f'<a href="{url}" style="display:block;padding:14px 32px;'
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;'
f'letter-spacing:-0.01em;">'
f"{label}</a></td></tr></table>"
)
def task(name: str): def task(name: str):
"""Decorator to register a task handler.""" """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) logger.debug("MAGIC LINK for %s: %s", payload["email"], link)
expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES
body = ( html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>' "emails/magic_link.html",
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' lang=lang,
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=expiry_minutes)}</p>' link=link,
f'{_email_button(link, _t("email_magic_link_btn", lang))}' expiry_minutes=expiry_minutes,
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_fallback", lang)}</p>' preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes),
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_ignore", lang)}</p>'
) )
await send_email( await send_email(
to=payload["email"], to=payload["email"],
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME), 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"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="magic_link", email_type="magic_link",
) )
@@ -266,8 +182,6 @@ async def handle_send_quote_verification(payload: dict) -> None:
facility_type = payload.get("facility_type", "") facility_type = payload.get("facility_type", "")
country = payload.get("country", "") country = payload.get("country", "")
# Project recap card
project_card = ""
recap_parts = [] recap_parts = []
if court_count: if court_count:
recap_parts.append(f"{court_count} courts") 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) recap_parts.append(facility_type)
if country: if country:
recap_parts.append(country) recap_parts.append(country)
if recap_parts:
project_card = (
f'<table cellpadding="0" cellspacing="0" width="100%" style="margin:16px 0;border:1px solid #E2E8F0;border-radius:8px;overflow:hidden;">'
f'<tr><td style="padding:14px 18px;background-color:#F8FAFC;font-size:13px;color:#64748B;">'
f'<strong style="color:#0F172A;">{_t("email_quote_verify_project_label", lang)}</strong> {" &middot; ".join(recap_parts)}'
f'</td></tr></table>'
)
preheader = _t("email_quote_verify_preheader_courts", lang, court_count=court_count) if court_count else _t("email_quote_verify_preheader", lang) preheader = _t("email_quote_verify_preheader_courts", lang, court_count=court_count) if court_count else _t("email_quote_verify_preheader", lang)
body = ( html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_quote_verify_heading", lang)}</h2>' "emails/quote_verification.html",
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' lang=lang,
f'<p>{_t("email_quote_verify_greeting", lang, first_name=first_name)}</p>' link=link,
f'<p>{_t("email_quote_verify_body", lang, app_name=config.APP_NAME)}</p>' first_name=first_name,
f'{project_card}' recap_parts=recap_parts,
f'<p style="font-size:13px;color:#334155;">{_t("email_quote_verify_urgency", lang)}</p>' preheader=preheader,
f'{_email_button(link, _t("email_quote_verify_btn", lang))}'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_expires", lang)}</p>'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_fallback", lang)}</p>'
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_ignore", lang)}</p>'
) )
await send_email( await send_email(
to=payload["email"], to=payload["email"],
subject=_t("email_quote_verify_subject", lang), subject=_t("email_quote_verify_subject", lang),
html=_email_wrap(body, lang, preheader=preheader), html=html,
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="quote_verification", email_type="quote_verification",
) )
@@ -315,27 +217,17 @@ async def handle_send_welcome(payload: dict) -> None:
name_parts = (payload.get("name") or "").split() name_parts = (payload.get("name") or "").split()
first_name = name_parts[0] if name_parts else "there" first_name = name_parts[0] if name_parts else "there"
body = ( html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>' "emails/welcome.html",
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' lang=lang,
f'<p>{_t("email_welcome_greeting", lang, first_name=first_name)}</p>' first_name=first_name,
f'<p>{_t("email_welcome_body", lang)}</p>' preheader=_t("email_welcome_preheader", lang),
f'<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{_t("email_welcome_quickstart_heading", lang)}</p>'
f'<table cellpadding="0" cellspacing="0" style="margin:0 0 20px;font-size:14px;">'
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>'
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/planner" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_planner", lang)}</a></td></tr>'
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>'
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/{lang}/markets" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_markets", lang)}</a></td></tr>'
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">&#9679;</td>'
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/{lang}/leads/quote" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_quotes", lang)}</a></td></tr>'
f'</table>'
f'{_email_button(f"{config.BASE_URL}/planner", _t("email_welcome_btn", lang))}'
) )
await send_email( await send_email(
to=payload["email"], to=payload["email"],
subject=_t("email_welcome_subject", lang), subject=_t("email_welcome_subject", lang),
html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)), html=html,
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="welcome", email_type="welcome",
) )
@@ -351,43 +243,24 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
if intent.startswith("supplier_"): if intent.startswith("supplier_"):
plan_name = intent.replace("supplier_", "").title() plan_name = intent.replace("supplier_", "").title()
subject = _t("email_waitlist_supplier_subject", lang, plan_name=plan_name) subject = _t("email_waitlist_supplier_subject", lang, plan_name=plan_name)
preheader = _t("email_waitlist_supplier_preheader", lang) html = render_email_template(
body = ( "emails/waitlist_supplier.html",
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_waitlist_supplier_heading", lang)}</h2>' lang=lang,
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' plan_name=plan_name,
f'<p>{_t("email_waitlist_supplier_body", lang, plan_name=plan_name)}</p>' preheader=_t("email_waitlist_supplier_preheader", lang),
f'<p>{_t("email_waitlist_supplier_perks_intro", lang)}</p>'
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
f'<li>{_t("email_waitlist_supplier_perk_1", lang)}</li>'
f'<li>{_t("email_waitlist_supplier_perk_2", lang)}</li>'
f'<li>{_t("email_waitlist_supplier_perk_3", lang)}</li>'
f'</ul>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_waitlist_supplier_meanwhile", lang)}</p>'
f'<ul style="font-size:13px;color:#64748B;">'
f'<li><a href="{config.BASE_URL}/planner" style="color:#1D4ED8;">{_t("email_waitlist_supplier_link_planner", lang)}</a></li>'
f'<li><a href="{config.BASE_URL}/directory" style="color:#1D4ED8;">{_t("email_waitlist_supplier_link_directory", lang)}</a></li>'
f'</ul>'
) )
else: else:
subject = _t("email_waitlist_general_subject", lang) subject = _t("email_waitlist_general_subject", lang)
preheader = _t("email_waitlist_general_preheader", lang) html = render_email_template(
body = ( "emails/waitlist_general.html",
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_waitlist_general_heading", lang)}</h2>' lang=lang,
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' preheader=_t("email_waitlist_general_preheader", lang),
f'<p>{_t("email_waitlist_general_body", lang)}</p>'
f'<p>{_t("email_waitlist_general_perks_intro", lang)}</p>'
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
f'<li>{_t("email_waitlist_general_perk_1", lang)}</li>'
f'<li>{_t("email_waitlist_general_perk_2", lang)}</li>'
f'<li>{_t("email_waitlist_general_perk_3", lang)}</li>'
f'</ul>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_waitlist_general_outro", lang)}</p>'
) )
await send_email( await send_email(
to=email, to=email,
subject=subject, subject=subject,
html=_email_wrap(body, lang, preheader=preheader), html=html,
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="waitlist", 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}" 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'<span style="display:inline-block;padding:2px 8px;border-radius:4px;'
f'background-color:{heat_bg};color:#FFFFFF;font-size:11px;font-weight:700;'
f'letter-spacing:0.04em;vertical-align:middle;margin-left:8px;">{heat}</span>'
)
tl = lambda key: _t(key, lang) # noqa: E731 tl = lambda key: _t(key, lang) # noqa: E731
brief_rows = [ 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 "-"), (tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
] ]
brief_html = ""
for label, value in brief_rows:
brief_html += (
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px;vertical-align:top">{label}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
)
contact_name = lead["contact_name"] or "-"
contact_phone = lead["contact_phone"] or "-"
# Contact section with prominent email
contact_html = (
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_name")}</td>'
f'<td style="padding:4px 0;font-size:14px;color:#0F172A;font-weight:600">{contact_name}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_email")}</td>'
f'<td style="padding:4px 0;font-size:14px;"><a href="mailto:{contact_email}" style="color:#1D4ED8;font-weight:600;text-decoration:none;">{contact_email}</a></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_phone")}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{contact_phone}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_company")}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{lead["contact_company"] or "-"}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_role")}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{lead["stakeholder_type"] or "-"}</td></tr>'
)
preheader_parts = [f"{facility_type} project"] preheader_parts = [f"{facility_type} project"]
if timeline: if timeline:
preheader_parts.append(f"{timeline} timeline") preheader_parts.append(f"{timeline} timeline")
preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang)) preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang))
body = (
f'<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;'
f'background-color:#FEF3C7;border-radius:6px;border-left:3px solid #F59E0B;">'
f'{_t("email_lead_forward_urgency", lang)}</p>'
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{tl("email_lead_forward_heading")} {heat_badge}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:8px 0 16px;">'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{tl("email_lead_forward_section_brief")}</h3>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{brief_html}</table>'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{tl("email_lead_forward_section_contact")}</h3>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{contact_html}</table>'
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", tl("email_lead_forward_btn"))}'
f'<p style="font-size:13px;color:#64748B;text-align:center;margin:8px 0 0;">'
f'{_t("email_lead_forward_reply_direct", lang, contact_email=contact_email)}</p>'
)
# Send to supplier contact email or general contact # Send to supplier contact email or general contact
to_email = supplier.get("contact_email") or supplier.get("contact") or "" to_email = supplier.get("contact_email") or supplier.get("contact") or ""
if not to_email: 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 # Generate one-click "I've contacted this lead" CTA token
cta_token = secrets.token_urlsafe(24) cta_token = secrets.token_urlsafe(24)
cta_url = f"{config.BASE_URL}/suppliers/leads/cta/{cta_token}" cta_url = f"{config.BASE_URL}/suppliers/leads/cta/{cta_token}"
body += (
f'<p style="font-size:12px;color:#94A3B8;text-align:center;margin:16px 0 0;">' html = render_email_template(
f'<a href="{cta_url}" style="color:#94A3B8;">' "emails/lead_forward.html",
f'&#10003; Mark as contacted</a></p>' 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( await send_email(
to=to_email, to=to_email,
subject=subject, subject=subject,
html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)), html=html,
from_addr=EMAIL_ADDRESSES["leads"], from_addr=EMAIL_ADDRESSES["leads"],
email_type="lead_forward", 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" first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there"
body = ( html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_lead_matched_heading", lang)}</h2>' "emails/lead_matched.html",
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' lang=lang,
f'<p>{_t("email_lead_matched_greeting", lang, first_name=first_name)}</p>' first_name=first_name,
f'<p>{_t("email_lead_matched_body", lang)}</p>' facility_type=lead["facility_type"] or "padel",
f'<p style="font-size:13px;color:#64748B;">{_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")}</p>' court_count=lead["court_count"] or "?",
# What happens next country=lead["country"] or "your area",
f'<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{_t("email_lead_matched_next_heading", lang)}</p>' preheader=_t("email_lead_matched_preheader", lang),
f'<p style="font-size:14px;color:#334155;">{_t("email_lead_matched_next_body", lang)}</p>'
f'<p style="font-size:13px;color:#64748B;padding:10px 14px;background-color:#F0F9FF;'
f'border-radius:6px;border-left:3px solid #1D4ED8;">'
f'{_t("email_lead_matched_tip", lang)}</p>'
f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_lead_matched_btn", lang))}'
f'<p style="font-size:12px;color:#94A3B8;">{_t("email_lead_matched_note", lang)}</p>'
) )
await send_email( await send_email(
to=lead["contact_email"], to=lead["contact_email"],
subject=_t("email_lead_matched_subject", lang, first_name=first_name), 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"], from_addr=EMAIL_ADDRESSES["leads"],
email_type="lead_matched", email_type="lead_matched",
) )
@@ -599,30 +427,22 @@ async def handle_notify_matching_suppliers(payload: dict) -> None:
if not to_email: if not to_email:
continue continue
body = ( notify_html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">' "emails/lead_match_notify.html",
f'New [{heat}] lead in {country}</h2>' lang=lang,
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' heat=heat,
f'<p style="font-size:14px;color:#334155;">A new project brief has been submitted that matches your service area.</p>' country=country,
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">' facility_type=facility_type,
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Facility</td>' courts=courts,
f'<td style="font-size:13px;color:#1E293B">{facility_type}</td></tr>' timeline=timeline,
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Courts</td>' credit_cost=lead.get("credit_cost", "?"),
f'<td style="font-size:13px;color:#1E293B">{courts}</td></tr>' preheader=f"New matching lead in {country}",
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Country</td>'
f'<td style="font-size:13px;color:#1E293B">{country}</td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">Timeline</td>'
f'<td style="font-size:13px;color:#1E293B">{timeline or "-"}</td></tr>'
f'</table>'
f'<p style="font-size:13px;color:#64748B;">'
f'Contact details are available after unlocking. Credits required: {lead.get("credit_cost", "?")}.</p>'
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "View lead feed")}'
) )
await send_email( await send_email(
to=to_email, to=to_email,
subject=f"[{heat}] New {facility_type} project in {country}{courts} courts", 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"], from_addr=EMAIL_ADDRESSES["leads"],
email_type="lead_match_notify", email_type="lead_match_notify",
) )
@@ -667,48 +487,27 @@ async def handle_send_weekly_lead_digest(payload: dict) -> None:
if not new_leads: if not new_leads:
continue continue
lead_rows_html = "" # Normalise lead dicts for template — heat_score → heat (uppercase)
for ld in new_leads: digest_leads = [
heat = (ld["heat_score"] or "cool").upper() {**ld, "heat": (ld["heat_score"] or "cool").upper()}
heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"} for ld in new_leads
hc = heat_colors.get(heat, "#2563EB") ]
badge = (
f'<span style="display:inline-block;padding:1px 6px;border-radius:4px;'
f'background-color:{hc};color:#fff;font-size:10px;font-weight:700">{heat}</span>'
)
lead_rows_html += (
f'<tr>'
f'<td style="padding:6px 12px 6px 0;font-size:13px;color:#1E293B">'
f'{badge} {ld["facility_type"] or "Padel"}, {ld["court_count"] or "?"} courts</td>'
f'<td style="padding:6px 12px 6px 0;font-size:13px;color:#64748B">{ld["country"] or "-"}</td>'
f'<td style="padding:6px 0;font-size:13px;color:#64748B">{ld["timeline"] or "-"}</td>'
f'</tr>'
)
body = (
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:18px;">'
f'Your weekly lead digest — {len(new_leads)} new {"lead" if len(new_leads) == 1 else "leads"}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p style="font-size:14px;color:#334155;">New matching leads in your service area this week:</p>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px;width:100%">'
f'<thead><tr>'
f'<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase">Project</th>'
f'<th style="text-align:left;font-size:11px;color:#94A3B8;padding:0 12px 6px 0;text-transform:uppercase">Country</th>'
f'<th style="text-align:left;font-size:11px;color:#94A3B8;text-transform:uppercase">Timeline</th>'
f'</tr></thead>'
f'<tbody>{lead_rows_html}</tbody>'
f'</table>'
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "Unlock leads →")}'
)
area_summary = ", ".join(countries[:3]) area_summary = ", ".join(countries[:3])
if len(countries) > 3: if len(countries) > 3:
area_summary += f" +{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( await send_email(
to=to_email, to=to_email,
subject=f"{len(new_leads)} new padel {'lead' if len(new_leads) == 1 else 'leads'} in {area_summary}", 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"], from_addr=EMAIL_ADDRESSES["leads"],
email_type="weekly_digest", email_type="weekly_digest",
) )
@@ -727,25 +526,20 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
contact_email = payload.get("contact_email", "") contact_email = payload.get("contact_email", "")
message = payload.get("message", "") message = payload.get("message", "")
body = ( html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">' "emails/supplier_enquiry.html",
f'{_t("email_enquiry_heading", lang, contact_name=contact_name)}</h2>' lang=lang,
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' supplier_name=supplier_name,
f'<p>{_t("email_enquiry_body", lang, supplier_name=supplier_name)}</p>' contact_name=contact_name,
f'<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">' contact_email=contact_email,
f'<tr><td style="padding:6px 0;color:#64748B;width:120px">{_t("email_enquiry_lbl_from", lang)}</td>' message=message,
f'<td style="padding:6px 0"><strong>{contact_name}</strong> &lt;{contact_email}&gt;</td></tr>' preheader=_t("email_enquiry_preheader", lang),
f'<tr><td style="padding:6px 0;color:#64748B;vertical-align:top">{_t("email_enquiry_lbl_message", lang)}</td>'
f'<td style="padding:6px 0;white-space:pre-wrap">{message}</td></tr>'
f'</table>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_respond_fast", lang)}</p>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_reply", lang, contact_email=contact_email)}</p>'
) )
await send_email( await send_email(
to=supplier_email, to=supplier_email,
subject=_t("email_enquiry_subject", lang, contact_name=contact_name), 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"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="supplier_enquiry", email_type="supplier_enquiry",
) )
@@ -831,19 +625,17 @@ async def handle_generate_business_plan(payload: dict) -> None:
export_token = export_row["token"] export_token = export_row["token"]
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,)) user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
if user: if user:
body = ( bp_html = render_email_template(
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_business_plan_heading", language)}</h2>' "emails/business_plan.html",
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">' lang=language,
f'<p>{_t("email_business_plan_body", language)}</p>' download_url=f"{config.BASE_URL}/planner/export/{export_token}",
f'<p style="font-size:14px;color:#334155;">{_t("email_business_plan_includes", language)}</p>' quote_url=f"{config.BASE_URL}/{language}/leads/quote",
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", language))}' preheader=_t("email_business_plan_preheader", language),
f'<p style="font-size:13px;color:#64748B;text-align:center;margin:12px 0 0;">'
f'{_t("email_business_plan_quote_cta", language, quote_url=f"{config.BASE_URL}/{language}/leads/quote")}</p>'
) )
await send_email( await send_email(
to=user["email"], to=user["email"],
subject=_t("email_business_plan_subject", language), 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"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="business_plan", email_type="business_plan",
) )

View File

@@ -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 "<!DOCTYPE html>" 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 "<!DOCTYPE html>" 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