Merge branch 'worktree-sitemap-improvement'

# Conflicts:
#	web/src/padelnomics/admin/routes.py
This commit is contained in:
Deeman
2026-02-23 13:15:21 +01:00
24 changed files with 2192 additions and 169 deletions

View File

@@ -81,6 +81,8 @@ DUCKDB_PATH=local.duckdb SERVING_DUCKDB_PATH=analytics.duckdb \
| Extraction patterns, state tracking, adding new sources | `extract/padelnomics_extract/README.md` |
| 3-layer SQLMesh architecture, materialization strategy | `transform/sqlmesh_padelnomics/README.md` |
| Two-file DuckDB architecture (SQLMesh lock isolation) | `src/padelnomics/export_serving.py` docstring |
| Email hub: delivery tracking, webhook handler, admin UI | `web/src/padelnomics/webhooks.py` docstring |
| User flows (all admin + public routes) | `docs/USER_FLOWS.md` |
## Pipeline data flow
@@ -103,6 +105,7 @@ analytics.duckdb ← serving tables only, web app read-only
| `LANDING_DIR` | `data/landing` | Landing zone root (extraction writes here) |
| `DUCKDB_PATH` | `local.duckdb` | SQLMesh pipeline DB (exclusive write) |
| `SERVING_DUCKDB_PATH` | `analytics.duckdb` | Read-only DB for web app |
| `RESEND_WEBHOOK_SECRET` | `""` | Resend webhook signature secret (skip verification if empty) |
## Coding philosophy

View File

@@ -7,6 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Admin Email Hub** (`/admin/emails`) — full email management dashboard with:
sent log (filterable by type/event/search, HTMX partial updates), email detail
with Resend API enrichment for HTML preview, inbound inbox with unread badges
and inline reply, compose form with branded template wrapping, and Resend
audience management with contact list/remove
- **Email delivery tracking** — `email_log` table records every outgoing email
with resend_id; Resend webhook handler (`/webhooks/resend`) updates delivery
events (delivered, bounced, opened, clicked, complained) in real-time;
`inbound_emails` table stores received messages with full body
- **send_email() returns resend_id** — changed return type from `bool` to
`str | None` (backward-compatible: truthy string works like True); all 9
worker handlers now pass `email_type=` for per-type filtering in the log
- **Playtomic full data extraction** — expanded venue bounding boxes from 4 regions
(ES, UK, DE, FR) to 23 globally (Italy, Portugal, NL, BE, AT, CH, Nordics, Mexico,
Argentina, Middle East, USA); PAGE_SIZE increased from 20 to 100; availability
@@ -38,6 +50,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
translated footer; ~70 new translation keys (EN + DE); all task payloads now
carry `lang` from request context at enqueue time; payloads without `lang`
gracefully default to English
- **Email design & copy upgrade** — redesigned `_email_wrap()`: replaced monogram
header with lowercase wordmark matching website, added 3px blue accent border,
preheader text support (hidden preview in email clients), HR separators between
heading and body; `_email_button()` now full-width block for mobile tap targets;
rewrote copy for all 9 emails with improved subject lines, urgency cues,
quick-start links in welcome email, styled project recap cards in quote
verification, heat badges on lead forward emails, "what happens next" section
in lead matched notifications, and secondary CTAs; ~30 new/updated translation
keys in both EN and DE
### Changed
- **Resend audiences restructured** — replaced dynamic `waitlist-{blueprint}`

View File

@@ -1,7 +1,7 @@
# Padelnomics — Project Tracker
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
> Last updated: 2026-02-22.
> Last updated: 2026-02-23.
---
@@ -100,6 +100,7 @@
- [x] Comprehensive admin: users, tasks, leads, suppliers, CMS templates, scenarios, articles, feedback
- [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
### SEO & Legal
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
@@ -193,7 +194,7 @@ _Move here when you start working on it._
### Bugs / Tech Debt
- [ ] Resend audiences: two segments both using "waitlist-auth" — review audience/segment model and fix duplication
- [ ] Transactional emails not all translated to German — some emails still sent in English regardless of user language
- [ ] Resend inbound emails enabled — plan how to integrate (webhook routing, reply handling, support inbox?)
- [x] ~~Resend inbound emails enabled~~ — integrated: webhook handler + admin inbox with reply (done in email hub)
- [ ] Extraction: Playtomic API only returns ~20 venues per bbox — investigate smaller/targeted bboxes
### Marketing & Content

View File

@@ -188,6 +188,10 @@ Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state).
| Leads | `GET /admin/leads`, `/admin/leads/<id>` | List, filter, view detail, change status, forward to supplier, create |
| Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
| Feedback | `GET /admin/feedback` | View all submitted feedback |
| Email Sent Log | `GET /admin/emails`, `/admin/emails/<id>` | List all outgoing emails (filter by type/event/search), detail with API-enriched HTML preview |
| Email Inbox | `GET /admin/emails/inbox`, `/admin/emails/inbox/<id>` | Inbound emails (unread badge), detail with sandboxed HTML, inline reply |
| Email Compose | `GET /admin/emails/compose` | Send ad-hoc emails with from-address selection + optional branded wrapping |
| Audiences | `GET /admin/emails/audiences`, `/admin/emails/audiences/<id>/contacts` | Resend audiences, contact list, remove contacts |
| Article Templates | `GET /admin/templates` | CRUD + bulk generate articles from template+data |
| Published Scenarios | `GET /admin/scenarios` | CRUD public scenario cards (shown on landing) |
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML |
@@ -211,6 +215,7 @@ Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state).
| `dashboard` | `/dashboard` | No |
| `billing` | `/billing` | No |
| `admin` | `/admin` | No |
| `webhooks` | `/webhooks` | No |
**Language detection for non-prefixed blueprints:** Cookie (`lang`) → `Accept-Language` header → fallback `"en"`

View File

@@ -6,6 +6,7 @@ from datetime import date, datetime, timedelta
from pathlib import Path
import mistune
import resend
from quart import (
Blueprint,
Response,
@@ -19,7 +20,16 @@ from quart import (
)
from ..auth.routes import role_required
from ..core import csrf_protect, execute, fetch_all, fetch_one, slugify
from ..core import (
EMAIL_ADDRESSES,
config,
csrf_protect,
execute,
fetch_all,
fetch_one,
send_email,
slugify,
)
# Blueprint with its own template folder
bp = Blueprint(
@@ -30,6 +40,24 @@ bp = Blueprint(
)
@bp.before_request
async def _inject_admin_sidebar_data():
"""Load unread inbox count for sidebar badge on every admin page."""
from quart import g
try:
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
g.admin_unread_count = row["cnt"] if row else 0
except Exception:
g.admin_unread_count = 0
@bp.context_processor
def _admin_context():
"""Expose admin-specific variables to all admin templates."""
from quart import g
return {"unread_count": getattr(g, "admin_unread_count", 0)}
# =============================================================================
# SQL Queries
# =============================================================================
@@ -825,6 +853,344 @@ async def feedback():
return await render_template("admin/feedback.html", feedback_list=feedback_list)
# =============================================================================
# Email Hub
# =============================================================================
EMAIL_TYPES = [
"ad_hoc", "magic_link", "welcome", "quote_verification", "waitlist",
"lead_forward", "lead_matched", "supplier_enquiry", "business_plan",
"generic", "admin_compose", "admin_reply",
]
EVENT_TYPES = ["sent", "delivered", "opened", "clicked", "bounced", "complained"]
async def get_email_log(
email_type: str = None, last_event: str = None, search: str = None,
page: int = 1, per_page: int = 50,
) -> list[dict]:
"""Get email log with optional filters."""
wheres = ["1=1"]
params: list = []
if email_type:
wheres.append("email_type = ?")
params.append(email_type)
if last_event:
wheres.append("last_event = ?")
params.append(last_event)
if search:
wheres.append("(to_addr LIKE ? OR subject LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
where = " AND ".join(wheres)
offset = (page - 1) * per_page
params.extend([per_page, offset])
return await fetch_all(
f"""SELECT * FROM email_log WHERE {where}
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
tuple(params),
)
async def get_email_stats() -> dict:
"""Aggregate email stats for the list header."""
total = await fetch_one("SELECT COUNT(*) as cnt FROM email_log")
delivered = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'delivered'")
bounced = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'bounced'")
today = datetime.utcnow().date().isoformat()
sent_today = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE created_at >= ?", (today,))
return {
"total": total["cnt"] if total else 0,
"delivered": delivered["cnt"] if delivered else 0,
"bounced": bounced["cnt"] if bounced else 0,
"sent_today": sent_today["cnt"] if sent_today else 0,
}
async def get_unread_count() -> int:
"""Count unread inbound emails."""
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
return row["cnt"] if row else 0
@bp.route("/emails")
@role_required("admin")
async def emails():
"""Sent email log."""
email_type = request.args.get("email_type", "")
last_event = request.args.get("last_event", "")
search = request.args.get("search", "").strip()
page = max(1, int(request.args.get("page", "1") or "1"))
log = await get_email_log(
email_type=email_type or None, last_event=last_event or None,
search=search or None, page=page,
)
stats = await get_email_stats()
unread = await get_unread_count()
return await render_template(
"admin/emails.html",
emails=log,
email_stats=stats,
email_types=EMAIL_TYPES,
event_types=EVENT_TYPES,
current_type=email_type,
current_event=last_event,
current_search=search,
page=page,
unread_count=unread,
)
@bp.route("/emails/results")
@role_required("admin")
async def email_results():
"""HTMX partial for filtered email log."""
email_type = request.args.get("email_type", "")
last_event = request.args.get("last_event", "")
search = request.args.get("search", "").strip()
page = max(1, int(request.args.get("page", "1") or "1"))
log = await get_email_log(
email_type=email_type or None, last_event=last_event or None,
search=search or None, page=page,
)
return await render_template("admin/partials/email_results.html", emails=log)
@bp.route("/emails/<int:email_id>")
@role_required("admin")
async def email_detail(email_id: int):
"""Email detail — enriches with Resend API for HTML body."""
email = await fetch_one("SELECT * FROM email_log WHERE id = ?", (email_id,))
if not email:
await flash("Email not found.", "error")
return redirect(url_for("admin.emails"))
# Try to fetch full email from Resend API (5s timeout)
enriched_html = None
if email["resend_id"] and email["resend_id"] != "dev" and config.RESEND_API_KEY:
resend.api_key = config.RESEND_API_KEY
try:
result = resend.Emails.get(email["resend_id"])
if isinstance(result, dict):
enriched_html = result.get("html", "")
else:
enriched_html = getattr(result, "html", "")
except Exception:
pass # Metadata-only fallback
return await render_template(
"admin/email_detail.html",
email=email,
enriched_html=enriched_html,
)
# --- Inbox ---
@bp.route("/emails/inbox")
@role_required("admin")
async def inbox():
"""Inbound email list."""
page = max(1, int(request.args.get("page", "1") or "1"))
per_page = 50
offset = (page - 1) * per_page
unread = await get_unread_count()
messages = await fetch_all(
"SELECT * FROM inbound_emails ORDER BY received_at DESC LIMIT ? OFFSET ?",
(per_page, offset),
)
return await render_template(
"admin/inbox.html", messages=messages, unread_count=unread, page=page,
)
@bp.route("/emails/inbox/<int:msg_id>")
@role_required("admin")
async def inbox_detail(msg_id: int):
"""Inbound email detail — marks as read."""
msg = await fetch_one("SELECT * FROM inbound_emails WHERE id = ?", (msg_id,))
if not msg:
await flash("Message not found.", "error")
return redirect(url_for("admin.inbox"))
if not msg["is_read"]:
await execute("UPDATE inbound_emails SET is_read = 1 WHERE id = ?", (msg_id,))
return await render_template("admin/inbox_detail.html", msg=msg)
@bp.route("/emails/inbox/<int:msg_id>/reply", methods=["POST"])
@role_required("admin")
@csrf_protect
async def inbox_reply(msg_id: int):
"""Reply to an inbound email."""
msg = await fetch_one("SELECT * FROM inbound_emails WHERE id = ?", (msg_id,))
if not msg:
await flash("Message not found.", "error")
return redirect(url_for("admin.inbox"))
form = await request.form
body = form.get("body", "").strip()
from_addr = form.get("from_addr", "") or EMAIL_ADDRESSES["transactional"]
if not body:
await flash("Reply body is required.", "error")
return redirect(url_for("admin.inbox_detail", msg_id=msg_id))
subject = msg["subject"] or ""
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
result = await send_email(
to=msg["from_addr"],
subject=subject,
html=html,
from_addr=from_addr,
email_type="admin_reply",
)
if result:
await flash("Reply sent.", "success")
else:
await flash("Failed to send reply.", "error")
return redirect(url_for("admin.inbox_detail", msg_id=msg_id))
# --- Compose ---
@bp.route("/emails/compose", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def email_compose():
"""Compose and send an ad-hoc email."""
if request.method == "POST":
form = await request.form
to = form.get("to", "").strip()
subject = form.get("subject", "").strip()
body = form.get("body", "").strip()
from_addr = form.get("from_addr", "") or EMAIL_ADDRESSES["transactional"]
wrap = form.get("wrap", "") == "1"
if not to or not subject or not body:
await flash("To, subject, and body are required.", "error")
return await render_template(
"admin/email_compose.html",
data={"to": to, "subject": subject, "body": body, "from_addr": from_addr},
email_addresses=EMAIL_ADDRESSES,
)
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
if wrap:
from ..worker import _email_wrap
html = _email_wrap(html)
result = await send_email(
to=to, subject=subject, html=html,
from_addr=from_addr, email_type="admin_compose",
)
if result:
await flash(f"Email sent to {to}.", "success")
return redirect(url_for("admin.emails"))
else:
await flash("Failed to send email.", "error")
return await render_template(
"admin/email_compose.html",
data={"to": to, "subject": subject, "body": body, "from_addr": from_addr},
email_addresses=EMAIL_ADDRESSES,
)
return await render_template(
"admin/email_compose.html", data={}, email_addresses=EMAIL_ADDRESSES,
)
# --- Audiences ---
@bp.route("/emails/audiences")
@role_required("admin")
async def audiences():
"""List Resend audiences with local cache + API contact counts."""
audience_list = await fetch_all("SELECT * FROM resend_audiences ORDER BY name")
# Enrich with contact count from API (best-effort)
for a in audience_list:
a["contact_count"] = None
if config.RESEND_API_KEY and a.get("audience_id"):
resend.api_key = config.RESEND_API_KEY
try:
contacts = resend.Contacts.list(a["audience_id"])
if isinstance(contacts, dict):
a["contact_count"] = len(contacts.get("data", []))
elif isinstance(contacts, list):
a["contact_count"] = len(contacts)
else:
data = getattr(contacts, "data", [])
a["contact_count"] = len(data) if data else 0
except Exception:
pass
return await render_template("admin/audiences.html", audiences=audience_list)
@bp.route("/emails/audiences/<audience_id>/contacts")
@role_required("admin")
async def audience_contacts(audience_id: str):
"""List contacts in a Resend audience."""
audience = await fetch_one("SELECT * FROM resend_audiences WHERE audience_id = ?", (audience_id,))
if not audience:
await flash("Audience not found.", "error")
return redirect(url_for("admin.audiences"))
contacts = []
if config.RESEND_API_KEY:
resend.api_key = config.RESEND_API_KEY
try:
result = resend.Contacts.list(audience_id)
if isinstance(result, dict):
contacts = result.get("data", [])
elif isinstance(result, list):
contacts = result
else:
contacts = getattr(result, "data", []) or []
except Exception:
await flash("Failed to fetch contacts from Resend.", "error")
return await render_template(
"admin/audience_contacts.html", audience=audience, contacts=contacts,
)
@bp.route("/emails/audiences/<audience_id>/contacts/remove", methods=["POST"])
@role_required("admin")
@csrf_protect
async def audience_contact_remove(audience_id: str):
"""Remove a contact from a Resend audience."""
form = await request.form
contact_id = form.get("contact_id", "")
if not contact_id:
await flash("No contact specified.", "error")
return redirect(url_for("admin.audience_contacts", audience_id=audience_id))
if config.RESEND_API_KEY:
resend.api_key = config.RESEND_API_KEY
try:
resend.Contacts.remove(audience_id, contact_id)
await flash("Contact removed.", "success")
except Exception as e:
await flash(f"Failed to remove contact: {e}", "error")
return redirect(url_for("admin.audience_contacts", audience_id=audience_id))
# =============================================================================
# Content Templates (read-only — templates live in git as .md.jinja files)
# =============================================================================

View File

@@ -0,0 +1,46 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "audiences" %}
{% block title %}{{ audience.name }} Contacts - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<a href="{{ url_for('admin.audiences') }}" class="text-sm text-slate">&larr; Audiences</a>
<h1 class="text-2xl mt-1">{{ audience.name }}</h1>
<p class="text-sm text-slate">{{ contacts | length }} contacts</p>
</div>
</header>
{% if contacts %}
<div class="card">
<table class="table">
<thead>
<tr>
<th>Email</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in contacts %}
<tr>
<td class="text-sm">{{ c.email if c.email is defined else (c.get('email', '-') if c is mapping else '-') }}</td>
<td class="mono text-sm">{{ (c.created_at if c.created_at is defined else (c.get('created_at', '-') if c is mapping else '-'))[:16] if c else '-' }}</td>
<td style="text-align:right">
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="contact_id" value="{{ c.id if c.id is defined else (c.get('id', '') if c is mapping else '') }}">
<button type="submit" class="btn-outline btn-sm" style="color:#DC2626" onclick="return confirm('Remove this contact?')">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card text-center" style="padding:2rem">
<p class="text-slate">No contacts in this audience.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "audiences" %}
{% block title %}Audiences - Email Hub - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Audiences</h1>
<p class="text-sm text-slate mt-1">Resend audiences and contact counts</p>
</div>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">Back to Dashboard</a>
</header>
{% if audiences %}
<div class="grid-2" style="gap:1rem">
{% for a in audiences %}
<div class="card" style="padding:1.25rem">
<div class="flex justify-between items-center mb-2">
<h2 class="text-lg">{{ a.name }}</h2>
{% if a.contact_count is not none %}
<span class="badge">{{ a.contact_count }} contacts</span>
{% else %}
<span class="badge text-slate">API unavailable</span>
{% endif %}
</div>
<p class="mono text-xs text-slate mb-3">{{ a.audience_id }}</p>
<a href="{{ url_for('admin.audience_contacts', audience_id=a.audience_id) }}" class="btn-outline btn-sm">View Contacts</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="card text-center" style="padding:2rem">
<p class="text-slate">No audiences found. They are created automatically when users sign up.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -86,6 +86,24 @@
Templates
</a>
<div class="admin-sidebar__section">Email</div>
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}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="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>
Sent Log
</a>
<a href="{{ url_for('admin.inbox') }}" class="{% if admin_page == 'inbox' %}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 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"/></svg>
Inbox{% if unread_count %} <span class="badge-danger" style="font-size:10px;padding:1px 6px;margin-left:auto;">{{ unread_count }}</span>{% endif %}
</a>
<a href="{{ url_for('admin.email_compose') }}" class="{% if admin_page == 'compose' %}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="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
</a>
<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>
Audiences
</a>
<div class="admin-sidebar__section">System</div>
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}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="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>

View File

@@ -0,0 +1,52 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "compose" %}
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="mb-6">
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">&larr; Sent Log</a>
<h1 class="text-2xl mt-1">Compose Email</h1>
</header>
<div class="card" style="padding:1.5rem;max-width:640px">
<form method="post" action="{{ url_for('admin.email_compose') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">From</label>
<select name="from_addr" class="form-input">
{% for key, addr in email_addresses.items() %}
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<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>
</div>
<div class="mb-4">
<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>
</div>
<div class="mb-4">
<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>
</div>
<div class="mb-6">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="wrap" value="1" checked>
Wrap in branded email template
</label>
</div>
<div class="flex gap-2">
<button type="submit" class="btn">Send Email</button>
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "emails" %}
{% block title %}Email #{{ email.id }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="mb-6">
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">&larr; Sent Log</a>
<h1 class="text-2xl mt-1">Email #{{ email.id }}</h1>
</header>
<div class="grid-2" style="gap:1.5rem">
<!-- Metadata -->
<div class="card" style="padding:1.5rem">
<h2 class="text-lg mb-4">Details</h2>
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
<dt class="text-slate">To</dt>
<dd>{{ email.to_addr }}</dd>
<dt class="text-slate">From</dt>
<dd>{{ email.from_addr }}</dd>
<dt class="text-slate">Subject</dt>
<dd>{{ email.subject }}</dd>
<dt class="text-slate">Type</dt>
<dd><span class="badge">{{ email.email_type }}</span></dd>
<dt class="text-slate">Status</dt>
<dd>
{% if email.last_event == 'delivered' %}
<span class="badge" style="background:#DCFCE7;color:#166534">delivered</span>
{% elif email.last_event == 'bounced' %}
<span class="badge-danger">bounced</span>
{% elif email.last_event == 'opened' %}
<span class="badge" style="background:#DBEAFE;color:#1E40AF">opened</span>
{% elif email.last_event == 'clicked' %}
<span class="badge" style="background:#E0E7FF;color:#3730A3">clicked</span>
{% elif email.last_event == 'complained' %}
<span class="badge-warning">complained</span>
{% else %}
<span class="badge">{{ email.last_event }}</span>
{% endif %}
</dd>
<dt class="text-slate">Resend ID</dt>
<dd class="mono text-xs">{{ email.resend_id or '-' }}</dd>
<dt class="text-slate">Sent at</dt>
<dd class="mono">{{ email.created_at or '-' }}</dd>
{% if email.delivered_at %}
<dt class="text-slate">Delivered</dt>
<dd class="mono">{{ email.delivered_at }}</dd>
{% endif %}
{% if email.opened_at %}
<dt class="text-slate">Opened</dt>
<dd class="mono">{{ email.opened_at }}</dd>
{% endif %}
{% if email.clicked_at %}
<dt class="text-slate">Clicked</dt>
<dd class="mono">{{ email.clicked_at }}</dd>
{% endif %}
{% if email.bounced_at %}
<dt class="text-slate">Bounced</dt>
<dd class="mono" style="color:#DC2626">{{ email.bounced_at }}</dd>
{% endif %}
</dl>
</div>
<!-- Preview -->
<div class="card" style="padding:1.5rem">
<h2 class="text-lg mb-4">Preview</h2>
{% if enriched_html %}
<iframe srcdoc="{{ enriched_html | e }}" sandbox style="width:100%;min-height:400px;border:1px solid #E2E8F0;border-radius:6px;background:#fff"></iframe>
{% else %}
<p class="text-slate text-sm">HTML preview not available. {% if not email.resend_id or email.resend_id == 'dev' %}Email was sent in dev mode.{% else %}Could not fetch from Resend API.{% endif %}</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "emails" %}
{% block title %}Sent Log - Email Hub - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Sent Log</h1>
<p class="text-sm text-slate mt-1">
{{ email_stats.total }} total
&middot; {{ email_stats.sent_today }} today
&middot; {{ email_stats.delivered }} delivered
{% if email_stats.bounced %}&middot; <span style="color:#DC2626">{{ email_stats.bounced }} bounced</span>{% endif %}
</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.email_compose') }}" class="btn btn-sm">+ Compose</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">Back to Dashboard</a>
</div>
</header>
<!-- Filters -->
<div class="card mb-6" style="padding:1rem 1.25rem;">
<form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.email_results') }}"
hx-target="#email-results"
hx-trigger="change, input delay:300ms from:find input">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="text-xs font-semibold text-slate block mb-1">Type</label>
<select name="email_type" class="form-input" style="min-width:140px">
<option value="">All</option>
{% for t in email_types %}
<option value="{{ t }}" {% if t == current_type %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="text-xs font-semibold text-slate block mb-1">Event</label>
<select name="last_event" class="form-input" style="min-width:120px">
<option value="">All</option>
{% for e in event_types %}
<option value="{{ e }}" {% if e == current_event %}selected{% endif %}>{{ e }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
<input type="text" name="search" value="{{ current_search }}" class="form-input" placeholder="Email or subject..." style="min-width:180px">
</div>
</form>
</div>
<!-- Results -->
<div id="email-results">
{% include "admin/partials/email_results.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "inbox" %}
{% block title %}Inbox - Email Hub - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Inbox</h1>
<p class="text-sm text-slate mt-1">
{{ messages | length }} messages shown
{% if unread_count %}&middot; <strong>{{ unread_count }} unread</strong>{% endif %}
</p>
</div>
<a href="{{ url_for('admin.email_compose') }}" class="btn btn-sm">+ Compose</a>
</header>
{% if messages %}
<div class="card">
<table class="table">
<thead>
<tr>
<th></th>
<th>From</th>
<th>Subject</th>
<th>Received</th>
</tr>
</thead>
<tbody>
{% for m in messages %}
<tr{% if not m.is_read %} style="font-weight:600"{% endif %}>
<td style="width:24px">
{% if not m.is_read %}
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#1D4ED8" title="Unread"></span>
{% endif %}
</td>
<td class="text-sm" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<a href="{{ url_for('admin.inbox_detail', msg_id=m.id) }}">{{ m.from_addr }}</a>
</td>
<td class="text-sm" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<a href="{{ url_for('admin.inbox_detail', msg_id=m.id) }}">{{ m.subject or '(no subject)' }}</a>
</td>
<td class="mono text-sm">{{ m.received_at[:16] if m.received_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card text-center" style="padding:2rem">
<p class="text-slate">No inbound emails yet.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "inbox" %}
{% block title %}Message from {{ msg.from_addr }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="mb-6">
<a href="{{ url_for('admin.inbox') }}" class="text-sm text-slate">&larr; Inbox</a>
<h1 class="text-2xl mt-1">{{ msg.subject or '(no subject)' }}</h1>
</header>
<div class="grid-2" style="gap:1.5rem">
<!-- Metadata + body -->
<div class="card" style="padding:1.5rem">
<dl style="display:grid;grid-template-columns:80px 1fr;gap:6px 12px;font-size:0.8125rem;margin-bottom:1rem">
<dt class="text-slate">From</dt>
<dd>{{ msg.from_addr }}</dd>
<dt class="text-slate">To</dt>
<dd>{{ msg.to_addr }}</dd>
<dt class="text-slate">Received</dt>
<dd class="mono">{{ msg.received_at or '-' }}</dd>
{% if msg.message_id %}
<dt class="text-slate">Msg ID</dt>
<dd class="mono text-xs" style="word-break:break-all">{{ msg.message_id }}</dd>
{% endif %}
</dl>
{% if msg.html_body %}
<h2 class="text-sm font-semibold text-slate mb-2">HTML Body</h2>
<iframe srcdoc="{{ msg.html_body | e }}" sandbox style="width:100%;min-height:300px;border:1px solid #E2E8F0;border-radius:6px;background:#fff"></iframe>
{% elif msg.text_body %}
<h2 class="text-sm font-semibold text-slate mb-2">Text Body</h2>
<pre style="white-space:pre-wrap;font-size:0.8125rem;padding:1rem;background:#F8FAFC;border-radius:6px;border:1px solid #E2E8F0">{{ msg.text_body }}</pre>
{% else %}
<p class="text-slate text-sm">No body content.</p>
{% endif %}
</div>
<!-- Reply -->
<div class="card" style="padding:1.5rem">
<h2 class="text-lg mb-4">Reply</h2>
<form method="post" action="{{ url_for('admin.inbox_reply', msg_id=msg.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">From</label>
<select name="from_addr" class="form-input">
<option value="Padelnomics &lt;hello@notifications.padelnomics.io&gt;">Transactional</option>
<option value="Padelnomics Leads &lt;leads@notifications.padelnomics.io&gt;">Leads</option>
<option value="Padelnomics &lt;coach@notifications.padelnomics.io&gt;">Nurture</option>
</select>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">To</label>
<input type="text" value="{{ msg.from_addr }}" class="form-input" disabled>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
<textarea name="body" rows="8" class="form-input" placeholder="Type your reply..." required></textarea>
</div>
<button type="submit" class="btn">Send Reply</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% if emails %}
<div class="card">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>To</th>
<th>Subject</th>
<th>Type</th>
<th>Status</th>
<th>Sent</th>
</tr>
</thead>
<tbody>
{% for e in emails %}
<tr>
<td><a href="{{ url_for('admin.email_detail', email_id=e.id) }}">#{{ e.id }}</a></td>
<td class="text-sm" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ e.to_addr }}</td>
<td class="text-sm" style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ e.subject }}</td>
<td><span class="badge">{{ e.email_type }}</span></td>
<td>
{% if e.last_event == 'delivered' %}
<span class="badge" style="background:#DCFCE7;color:#166534">delivered</span>
{% elif e.last_event == 'bounced' %}
<span class="badge-danger">bounced</span>
{% elif e.last_event == 'opened' %}
<span class="badge" style="background:#DBEAFE;color:#1E40AF">opened</span>
{% elif e.last_event == 'clicked' %}
<span class="badge" style="background:#E0E7FF;color:#3730A3">clicked</span>
{% elif e.last_event == 'complained' %}
<span class="badge-warning">complained</span>
{% else %}
<span class="badge">{{ e.last_event }}</span>
{% endif %}
</td>
<td class="mono text-sm">{{ e.created_at[:16] if e.created_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card text-center" style="padding:2rem">
<p class="text-slate">No emails match the current filters.</p>
</div>
{% endif %}

View File

@@ -305,6 +305,7 @@ def create_app() -> Quart:
from .planner.routes import bp as planner_bp
from .public.routes import bp as public_bp
from .suppliers.routes import bp as suppliers_bp
from .webhooks import bp as webhooks_bp
# Lang-prefixed blueprints (SEO-relevant, public-facing)
app.register_blueprint(public_bp, url_prefix="/<lang>")
@@ -318,6 +319,7 @@ def create_app() -> Quart:
app.register_blueprint(dashboard_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(webhooks_bp)
# Content catch-all LAST — lives under /<lang> too
app.register_blueprint(content_bp, url_prefix="/<lang>")

View File

@@ -61,6 +61,7 @@ class Config:
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
]
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
RESEND_WEBHOOK_SECRET: str = os.getenv("RESEND_WEBHOOK_SECRET", "")
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
@@ -346,28 +347,47 @@ def is_plausible_phone(phone: str) -> bool:
async def send_email(
to: str, subject: str, html: str, text: str = None, from_addr: str = None
) -> bool:
"""Send email via Resend SDK."""
to: str, subject: str, html: str, text: str = None,
from_addr: str = None, email_type: str = "ad_hoc",
) -> str | None:
"""Send email via Resend SDK. Returns resend_id on success, None on failure.
Truthy string works like True for existing boolean callers; None is falsy.
"""
sender = from_addr or config.EMAIL_FROM
resend_id = None
if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}")
return True
resend_id = "dev"
else:
resend.api_key = config.RESEND_API_KEY
try:
resend.Emails.send(
result = resend.Emails.send(
{
"from": from_addr or config.EMAIL_FROM,
"from": sender,
"to": to,
"subject": subject,
"html": html,
"text": text or html,
}
)
return True
resend_id = result.get("id") if isinstance(result, dict) else getattr(result, "id", None)
except Exception as e:
print(f"[EMAIL] Error sending to {to}: {e}")
return False
return None
# Log to email_log (best-effort, never fail the send)
try:
await execute(
"""INSERT INTO email_log (resend_id, from_addr, to_addr, subject, email_type)
VALUES (?, ?, ?, ?, ?)""",
(resend_id, sender, to, subject, email_type),
)
except Exception as e:
print(f"[EMAIL] Failed to log email: {e}")
return resend_id
# =============================================================================

View File

@@ -1539,44 +1539,60 @@
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Sch\u00e4tzungen und stellen keine Finanzberatung dar. Die tats\u00e4chlichen Ergebnisse k\u00f6nnen je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. \u00a9 Padelnomics \u2014 padelnomics.io",
"email_magic_link_heading": "Bei {app_name} anmelden",
"email_magic_link_body": "Klicke auf den Button unten, um dich anzumelden. Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab.",
"email_magic_link_btn": "Anmelden",
"email_magic_link_body": "Hier ist dein Anmeldelink. Er l\u00e4uft in {expiry_minutes} Minuten ab.",
"email_magic_link_btn": "Anmelden \u2192",
"email_magic_link_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_magic_link_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
"email_magic_link_subject": "Bei {app_name} anmelden",
"email_magic_link_subject": "Dein Anmeldelink f\u00fcr {app_name}",
"email_magic_link_preheader": "Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab",
"email_quote_verify_heading": "Best\u00e4tige deine E-Mail f\u00fcr Anbieter-Angebote",
"email_quote_verify_heading": "Best\u00e4tige deine E-Mail f\u00fcr Angebote",
"email_quote_verify_greeting": "Hallo {first_name},",
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage{project_desc}. Klicke auf den Button unten, um deine E-Mail zu best\u00e4tigen und deine Anfrage zu aktivieren. Dabei wird auch dein {app_name}-Konto erstellt, damit du dein Projekt verfolgen kannst.",
"email_quote_verify_btn": "Best\u00e4tigen & Angebot aktivieren",
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage. Best\u00e4tige deine E-Mail, um deine Anfrage zu aktivieren und dein {app_name}-Konto zu erstellen.",
"email_quote_verify_project_label": "Dein Projekt:",
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt behandelt.",
"email_quote_verify_btn": "Best\u00e4tigen &amp; Aktivieren \u2192",
"email_quote_verify_expires": "Dieser Link l\u00e4uft in 60 Minuten ab.",
"email_quote_verify_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_quote_verify_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
"email_quote_verify_subject": "Best\u00e4tige deine E-Mail f\u00fcr Anbieter-Angebote",
"email_quote_verify_subject": "Best\u00e4tige deine E-Mail \u2014 Anbieter sind bereit f\u00fcr Angebote",
"email_quote_verify_preheader": "Ein Klick, um deine Angebotsanfrage zu aktivieren",
"email_quote_verify_preheader_courts": "Ein Klick, um dein {court_count}-Court-Projekt zu aktivieren",
"email_welcome_heading": "Willkommen bei {app_name}!",
"email_welcome_body": "Danke f\u00fcr deine Anmeldung. Du kannst jetzt mit der Planung deines Padel-Gesch\u00e4fts loslegen.",
"email_welcome_btn": "Zum Dashboard",
"email_welcome_subject": "Willkommen bei {app_name}",
"email_welcome_heading": "Willkommen bei {app_name}",
"email_welcome_greeting": "Hallo {first_name},",
"email_welcome_body": "Du hast jetzt Zugang zum Finanzplaner, Marktdaten und dem Anbieterverzeichnis \u2014 alles, was du f\u00fcr die Planung deines Padel-Gesch\u00e4fts brauchst.",
"email_welcome_quickstart_heading": "Schnellstart:",
"email_welcome_link_planner": "Finanzplaner \u2014 modelliere deine Investition",
"email_welcome_link_markets": "Marktdaten \u2014 erkunde die Padel-Nachfrage nach Stadt",
"email_welcome_link_quotes": "Angebote einholen \u2014 verbinde dich mit verifizierten Anbietern",
"email_welcome_btn": "Jetzt planen \u2192",
"email_welcome_subject": "Du bist dabei \u2014 so f\u00e4ngst du an",
"email_welcome_preheader": "Dein Padel-Planungstoolkit ist bereit",
"email_waitlist_supplier_heading": "Du stehst auf der Anbieter-Warteliste",
"email_waitlist_supplier_body": "Danke f\u00fcr dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen die ultimative Anbieter-Plattform f\u00fcr Padel-Unternehmer.",
"email_waitlist_supplier_perks": "Du erf\u00e4hrst als Erster, wenn wir starten. Wir senden dir fr\u00fchen Zugang, exklusive Launch-Preise und Onboarding-Unterst\u00fctzung.",
"email_waitlist_supplier_body": "Danke f\u00fcr dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen eine Plattform, die dich mit qualifizierten Leads von Padel-Unternehmern verbindet, die aktiv Projekte planen.",
"email_waitlist_supplier_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
"email_waitlist_supplier_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
"email_waitlist_supplier_perk_2": "Exklusive Launch-Preise (gesichert)",
"email_waitlist_supplier_perk_3": "Pers\u00f6nliches Onboarding-Gespr\u00e4ch",
"email_waitlist_supplier_meanwhile": "In der Zwischenzeit erkunde unsere kostenlosen Ressourcen:",
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis \u2014 verifizierte Anbieter durchsuchen",
"email_waitlist_supplier_subject": "Du stehst auf der Liste \u2014 {app_name} {plan_name} startet bald",
"email_waitlist_supplier_subject": "Du bist dabei \u2014 {plan_name} fr\u00fcher Zugang kommt",
"email_waitlist_supplier_preheader": "Exklusive Launch-Preise + bevorzugtes Onboarding",
"email_waitlist_general_heading": "Du stehst auf der Warteliste",
"email_waitlist_general_body": "Danke, dass du dich auf die Warteliste eingetragen hast. Wir bereiten den Start der ultimativen Planungsplattform f\u00fcr Padel-Unternehmer vor.",
"email_waitlist_general_perks_intro": "Du bist unter den Ersten, die Zugang erhalten. Wir senden dir:",
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang zur gesamten Plattform",
"email_waitlist_general_perk_2": "Exklusive Launch-Boni",
"email_waitlist_general_body": "Danke f\u00fcr deine Anmeldung. Wir bauen die Planungsplattform f\u00fcr Padel-Unternehmer \u2014 Finanzmodellierung, Marktdaten und Anbietervernetzung an einem Ort.",
"email_waitlist_general_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
"email_waitlist_general_perk_2": "Exklusive Launch-Preise",
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
"email_waitlist_general_outro": "Wir melden uns bald.",
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 {app_name} startet bald",
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 wir benachrichtigen dich zum Launch",
"email_waitlist_general_preheader": "Fr\u00fcher Zugang + exklusive Launch-Preise",
"email_lead_forward_heading": "Neues Projekt-Lead",
"email_lead_forward_subheading": "Ein neues Padel-Projekt passt zu deinen Leistungen.",
"email_lead_forward_urgency": "Dieses Lead wurde gerade freigeschaltet. Anbieter, die innerhalb von 24 Stunden antworten, gewinnen 3x h\u00e4ufiger das Projekt.",
"email_lead_forward_section_brief": "Projektbeschreibung",
"email_lead_forward_section_contact": "Kontakt",
"email_lead_forward_lbl_facility": "Anlage",
@@ -1591,27 +1607,38 @@
"email_lead_forward_lbl_phone": "Telefon",
"email_lead_forward_lbl_company": "Unternehmen",
"email_lead_forward_lbl_role": "Rolle",
"email_lead_forward_btn": "Im Lead-Feed ansehen",
"email_lead_forward_btn": "Im Lead-Feed ansehen \u2192",
"email_lead_forward_reply_direct": "oder <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">direkt an {contact_email} antworten</a>",
"email_lead_forward_preheader_suffix": "Kontaktdaten enthalten",
"email_lead_matched_heading": "Ein Anbieter pr\u00fcft dein Projekt",
"email_lead_matched_heading": "Ein Anbieter m\u00f6chte dein Projekt besprechen",
"email_lead_matched_greeting": "Hallo {first_name},",
"email_lead_matched_body": "Gute Nachrichten \u2014 ein verifizierter Anbieter wurde mit deinem Padel-Projekt abgeglichen. Er hat deine Projektbeschreibung und wird sich direkt bei dir melden.",
"email_lead_matched_body": "Gute Nachrichten \u2014 ein verifizierter Anbieter wurde mit deinem Padel-Projekt abgeglichen. Er hat deine Projektbeschreibung und Kontaktdaten.",
"email_lead_matched_context": "Du hast eine Angebotsanfrage f\u00fcr eine {facility_type}-Anlage mit {court_count} Pl\u00e4tzen in {country} eingereicht.",
"email_lead_matched_btn": "Zum Dashboard",
"email_lead_matched_next_heading": "Was passiert als N\u00e4chstes",
"email_lead_matched_next_body": "Der Anbieter hat deine Projektbeschreibung und Kontaktdaten erhalten. Die meisten Anbieter melden sich innerhalb von 24\u201348 Stunden per E-Mail oder Telefon.",
"email_lead_matched_tip": "Tipp: Schnelles Reagieren auf Anbieter-Kontaktaufnahmen erh\u00f6ht deine Chance auf wettbewerbsf\u00e4hige Angebote.",
"email_lead_matched_btn": "Zum Dashboard \u2192",
"email_lead_matched_note": "Du erh\u00e4ltst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter deine Projektdetails freischaltet.",
"email_lead_matched_subject": "Ein Anbieter pr\u00fcft dein Padel-Projekt",
"email_lead_matched_subject": "{first_name}, ein Anbieter m\u00f6chte dein Projekt besprechen",
"email_lead_matched_preheader": "Der Anbieter wird sich direkt bei dir melden \u2014 das erwartet dich",
"email_enquiry_heading": "Neue Anfrage \u00fcber {app_name}",
"email_enquiry_body": "Du hast eine neue Verzeichnisanfrage f\u00fcr <strong>{supplier_name}</strong>.",
"email_enquiry_heading": "Neue Anfrage von {contact_name}",
"email_enquiry_body": "Du hast eine neue Anfrage \u00fcber deinen <strong>{supplier_name}</strong>-Verzeichniseintrag.",
"email_enquiry_lbl_from": "Von",
"email_enquiry_lbl_message": "Nachricht",
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\">{contact_email}</a>.",
"email_enquiry_subject": "Neue Anfrage \u00fcber {app_name}: {contact_name}",
"email_enquiry_respond_fast": "Antworte innerhalb von 24 Stunden f\u00fcr den besten Eindruck.",
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a>.",
"email_enquiry_subject": "Neue Anfrage von {contact_name} \u00fcber deinen Verzeichniseintrag",
"email_enquiry_preheader": "Antworte, um mit diesem potenziellen Kunden in Kontakt zu treten",
"email_business_plan_heading": "Dein Businessplan ist fertig",
"email_business_plan_body": "Dein Padel-Businessplan wurde als PDF erstellt und steht zum Download bereit.",
"email_business_plan_btn": "PDF herunterladen",
"email_business_plan_subject": "Dein Padel-Businessplan ist fertig",
"email_business_plan_includes": "Dein Plan enth\u00e4lt Investitions\u00fcbersicht, Umsatzprognosen und Break-Even-Analyse.",
"email_business_plan_btn": "PDF herunterladen \u2192",
"email_business_plan_quote_cta": "Bereit f\u00fcr den n\u00e4chsten Schritt? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Angebote von Anbietern einholen \u2192</a>",
"email_business_plan_subject": "Dein Businessplan-PDF steht zum Download bereit",
"email_business_plan_preheader": "Professioneller Padel-Finanzplan \u2014 jetzt herunterladen",
"email_footer_tagline": "Die Planungsplattform f\u00fcr Padel-Unternehmer",
"email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast."

View File

@@ -1539,44 +1539,60 @@
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io",
"email_magic_link_heading": "Sign in to {app_name}",
"email_magic_link_body": "Click the button below to sign in. This link expires in {expiry_minutes} minutes.",
"email_magic_link_btn": "Sign In",
"email_magic_link_body": "Here's your sign-in link. It expires in {expiry_minutes} minutes.",
"email_magic_link_btn": "Sign In \u2192",
"email_magic_link_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_magic_link_ignore": "If you didn't request this, you can safely ignore this email.",
"email_magic_link_subject": "Sign in to {app_name}",
"email_magic_link_subject": "Your sign-in link for {app_name}",
"email_magic_link_preheader": "This link expires in {expiry_minutes} minutes",
"email_quote_verify_heading": "Verify your email to get supplier quotes",
"email_quote_verify_heading": "Verify your email to get quotes",
"email_quote_verify_greeting": "Hi {first_name},",
"email_quote_verify_body": "Thanks for requesting quotes{project_desc}. Click the button below to verify your email and activate your quote request. This will also create your {app_name} account so you can track your project.",
"email_quote_verify_btn": "Verify & Activate Quote",
"email_quote_verify_body": "Thanks for requesting quotes. Verify your email to activate your quote request and create your {app_name} account.",
"email_quote_verify_project_label": "Your project:",
"email_quote_verify_urgency": "Verified requests get prioritized by our supplier network.",
"email_quote_verify_btn": "Verify &amp; Activate \u2192",
"email_quote_verify_expires": "This link expires in 60 minutes.",
"email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.",
"email_quote_verify_subject": "Verify your email to get supplier quotes",
"email_quote_verify_subject": "Verify your email \u2014 suppliers are ready to quote",
"email_quote_verify_preheader": "One click to activate your quote request",
"email_quote_verify_preheader_courts": "One click to activate your {court_count}-court project",
"email_welcome_heading": "Welcome to {app_name}!",
"email_welcome_body": "Thanks for signing up. You're all set to start planning your padel business.",
"email_welcome_btn": "Go to Dashboard",
"email_welcome_subject": "Welcome to {app_name}",
"email_welcome_heading": "Welcome to {app_name}",
"email_welcome_greeting": "Hi {first_name},",
"email_welcome_body": "You now have access to the financial planner, market data, and supplier directory \u2014 everything you need to plan your padel business.",
"email_welcome_quickstart_heading": "Quick start:",
"email_welcome_link_planner": "Financial Planner \u2014 model your investment",
"email_welcome_link_markets": "Market Data \u2014 explore padel demand by city",
"email_welcome_link_quotes": "Get Quotes \u2014 connect with verified suppliers",
"email_welcome_btn": "Start Planning \u2192",
"email_welcome_subject": "You're in \u2014 here's how to start planning",
"email_welcome_preheader": "Your padel business planning toolkit is ready",
"email_waitlist_supplier_heading": "You're on the Supplier Waitlist",
"email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building the ultimate supplier platform for padel entrepreneurs.",
"email_waitlist_supplier_perks": "You'll be among the first to know when we launch. We'll send you early access, exclusive launch pricing, and onboarding support.",
"email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building a platform to connect you with qualified leads from padel entrepreneurs actively planning projects.",
"email_waitlist_supplier_perks_intro": "As an early waitlist member, you'll get:",
"email_waitlist_supplier_perk_1": "Early access before public launch",
"email_waitlist_supplier_perk_2": "Exclusive launch pricing (locked in)",
"email_waitlist_supplier_perk_3": "Dedicated onboarding call",
"email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:",
"email_waitlist_supplier_link_planner": "Financial Planning Tool \u2014 model your padel facility",
"email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers",
"email_waitlist_supplier_subject": "You're on the list \u2014 {app_name} {plan_name} is launching soon",
"email_waitlist_supplier_subject": "You're in \u2014 {plan_name} early access is coming",
"email_waitlist_supplier_preheader": "Exclusive launch pricing + priority onboarding",
"email_waitlist_general_heading": "You're on the Waitlist",
"email_waitlist_general_body": "Thanks for joining the waitlist. We're preparing to launch the ultimate planning platform for padel entrepreneurs.",
"email_waitlist_general_perks_intro": "You'll be among the first to get access when we open. We'll send you:",
"email_waitlist_general_perk_1": "Early access to the full platform",
"email_waitlist_general_perk_2": "Exclusive launch bonuses",
"email_waitlist_general_body": "Thanks for joining. We're building the planning platform for padel entrepreneurs \u2014 financial modelling, market data, and supplier connections in one place.",
"email_waitlist_general_perks_intro": "As an early waitlist member, you'll get:",
"email_waitlist_general_perk_1": "Early access before public launch",
"email_waitlist_general_perk_2": "Exclusive launch pricing",
"email_waitlist_general_perk_3": "Priority onboarding and support",
"email_waitlist_general_outro": "We'll be in touch soon.",
"email_waitlist_general_subject": "You're on the list \u2014 {app_name} is launching soon",
"email_waitlist_general_subject": "You're on the list \u2014 we'll notify you at launch",
"email_waitlist_general_preheader": "Early access + exclusive launch pricing",
"email_lead_forward_heading": "New Project Lead",
"email_lead_forward_subheading": "A new padel project matches your services.",
"email_lead_forward_urgency": "This lead was just unlocked. Suppliers who respond within 24 hours are 3x more likely to win the project.",
"email_lead_forward_section_brief": "Project Brief",
"email_lead_forward_section_contact": "Contact",
"email_lead_forward_lbl_facility": "Facility",
@@ -1591,27 +1607,38 @@
"email_lead_forward_lbl_phone": "Phone",
"email_lead_forward_lbl_company": "Company",
"email_lead_forward_lbl_role": "Role",
"email_lead_forward_btn": "View in Lead Feed",
"email_lead_forward_btn": "View in Lead Feed \u2192",
"email_lead_forward_reply_direct": "or <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">reply directly to {contact_email}</a>",
"email_lead_forward_preheader_suffix": "contact details inside",
"email_lead_matched_heading": "A supplier is reviewing your project",
"email_lead_matched_heading": "A supplier wants to discuss your project",
"email_lead_matched_greeting": "Hi {first_name},",
"email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and will reach out to you directly.",
"email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and contact details.",
"email_lead_matched_context": "You submitted a quote request for a {facility_type} facility with {court_count} courts in {country}.",
"email_lead_matched_btn": "View Your Dashboard",
"email_lead_matched_next_heading": "What happens next",
"email_lead_matched_next_body": "The supplier has received your project brief and contact details. Most suppliers respond within 24\u201348 hours via email or phone.",
"email_lead_matched_tip": "Tip: Responding quickly to supplier outreach increases your chance of getting competitive quotes.",
"email_lead_matched_btn": "View Your Dashboard \u2192",
"email_lead_matched_note": "You'll receive this notification each time a new supplier unlocks your project details.",
"email_lead_matched_subject": "A supplier is reviewing your padel project",
"email_lead_matched_subject": "{first_name}, a supplier wants to discuss your project",
"email_lead_matched_preheader": "They'll reach out to you directly \u2014 here's what to expect",
"email_enquiry_heading": "New enquiry via {app_name}",
"email_enquiry_body": "You have a new directory enquiry for <strong>{supplier_name}</strong>.",
"email_enquiry_heading": "New enquiry from {contact_name}",
"email_enquiry_body": "You have a new enquiry via your <strong>{supplier_name}</strong> directory listing.",
"email_enquiry_lbl_from": "From",
"email_enquiry_lbl_message": "Message",
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\">{contact_email}</a> to respond.",
"email_enquiry_subject": "New enquiry via {app_name}: {contact_name}",
"email_enquiry_respond_fast": "Respond within 24 hours for the best impression.",
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a> to connect.",
"email_enquiry_subject": "New enquiry from {contact_name} via your directory listing",
"email_enquiry_preheader": "Reply to connect with this potential client",
"email_business_plan_heading": "Your Business Plan is Ready",
"email_business_plan_heading": "Your business plan is ready",
"email_business_plan_body": "Your padel business plan PDF has been generated and is ready for download.",
"email_business_plan_btn": "Download PDF",
"email_business_plan_subject": "Your Padel Business Plan PDF is Ready",
"email_business_plan_includes": "Your plan includes investment breakdown, revenue projections, and break-even analysis.",
"email_business_plan_btn": "Download PDF \u2192",
"email_business_plan_quote_cta": "Ready for the next step? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Get quotes from suppliers \u2192</a>",
"email_business_plan_subject": "Your business plan PDF is ready to download",
"email_business_plan_preheader": "Professional padel facility financial plan \u2014 download now",
"email_footer_tagline": "The padel business planning platform",
"email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request."

View File

@@ -0,0 +1,50 @@
"""Add email_log and inbound_emails tables for the admin email hub.
email_log tracks every outgoing email (resend_id, delivery events).
inbound_emails stores messages received via Resend webhook (full body stored
locally since inbound payloads can't be re-fetched).
"""
def up(conn):
conn.execute("""
CREATE TABLE IF NOT EXISTS email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resend_id TEXT,
from_addr TEXT NOT NULL,
to_addr TEXT NOT NULL,
subject TEXT NOT NULL,
email_type TEXT NOT NULL DEFAULT 'ad_hoc',
last_event TEXT NOT NULL DEFAULT 'sent',
delivered_at TEXT,
opened_at TEXT,
clicked_at TEXT,
bounced_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_log_resend ON email_log(resend_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_log_to ON email_log(to_addr)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_log_type ON email_log(email_type)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_log_created ON email_log(created_at)")
conn.execute("""
CREATE TABLE IF NOT EXISTS inbound_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resend_id TEXT NOT NULL UNIQUE,
message_id TEXT,
in_reply_to TEXT,
from_addr TEXT NOT NULL,
to_addr TEXT NOT NULL,
subject TEXT,
text_body TEXT,
html_body TEXT,
is_read INTEGER NOT NULL DEFAULT 0,
received_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_inbound_resend ON inbound_emails(resend_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_inbound_from ON inbound_emails(from_addr)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_inbound_read ON inbound_emails(is_read)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_inbound_received ON inbound_emails(received_at)")

View File

@@ -0,0 +1,107 @@
"""
Resend webhook handler — receives delivery events and inbound emails.
NOT behind @role_required: Resend posts here unauthenticated.
Verification uses RESEND_WEBHOOK_SECRET via the Resend SDK.
"""
from datetime import datetime
import resend
from quart import Blueprint, jsonify, request
from .core import config, execute
bp = Blueprint("webhooks", __name__, url_prefix="/webhooks")
# Maps Resend event types to (column_to_update, timestamp_column) pairs.
_EVENT_UPDATES: dict[str, tuple[str, str | None]] = {
"email.delivered": ("delivered", "delivered_at"),
"email.bounced": ("bounced", "bounced_at"),
"email.opened": ("opened", "opened_at"),
"email.clicked": ("clicked", "clicked_at"),
"email.complained": ("complained", None),
}
@bp.route("/resend", methods=["POST"])
async def resend_webhook():
"""Handle Resend webhook events (delivery tracking + inbound email)."""
body = await request.get_data()
# Verify signature when secret is configured
if config.RESEND_WEBHOOK_SECRET:
svix_id = request.headers.get("svix-id", "")
svix_timestamp = request.headers.get("svix-timestamp", "")
svix_signature = request.headers.get("svix-signature", "")
try:
wh = resend.Webhooks(config.RESEND_WEBHOOK_SECRET)
wh.verify(body, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
})
except Exception:
return jsonify({"error": "invalid signature"}), 401
payload = await request.get_json()
if not payload:
return jsonify({"error": "empty payload"}), 400
event_type = payload.get("type", "")
data = payload.get("data", {})
if event_type in _EVENT_UPDATES:
_handle_delivery_event(event_type, data)
elif event_type == "email.received":
await _handle_inbound(data)
return jsonify({"ok": True})
async def _handle_delivery_event(event_type: str, data: dict) -> None:
"""Update email_log with delivery event (idempotent)."""
email_id = data.get("email_id", "")
if not email_id:
return
last_event, ts_col = _EVENT_UPDATES[event_type]
now = datetime.utcnow().isoformat()
if ts_col:
await execute(
f"UPDATE email_log SET last_event = ?, {ts_col} = ? WHERE resend_id = ?",
(last_event, now, email_id),
)
else:
await execute(
"UPDATE email_log SET last_event = ? WHERE resend_id = ?",
(last_event, email_id),
)
async def _handle_inbound(data: dict) -> None:
"""Store an inbound email (INSERT OR IGNORE on resend_id)."""
resend_id = data.get("email_id", "")
if not resend_id:
return
now = datetime.utcnow().isoformat()
await execute(
"""INSERT OR IGNORE INTO inbound_emails
(resend_id, message_id, in_reply_to, from_addr, to_addr,
subject, text_body, html_body, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
resend_id,
data.get("message_id", ""),
data.get("in_reply_to", ""),
data.get("from", ""),
data.get("to", [""])[0] if isinstance(data.get("to"), list) else data.get("to", ""),
data.get("subject", ""),
data.get("text", ""),
data.get("html", ""),
now,
),
)

View File

@@ -24,11 +24,23 @@ def _t(key: str, lang: str = "en", **kwargs) -> str:
return raw.format(**kwargs) if kwargs else raw
def _email_wrap(body: str, lang: str = "en") -> str:
"""Wrap email body in a branded layout with inline CSS."""
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 = datetime.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}">
@@ -38,23 +50,19 @@ def _email_wrap(body: str, lang: str = "en") -> str:
<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;">
<!-- Logo header -->
<tr><td style="background-color:#0F172A;padding:28px 36px 24px;">
<table cellpadding="0" cellspacing="0">
<tr>
<td style="vertical-align:middle;">
<!-- Padel racket monogram -->
<span style="display:inline-block;width:32px;height:32px;background-color:#1D4ED8;border-radius:6px;text-align:center;line-height:32px;font-size:17px;font-weight:800;color:#fff;font-family:Helvetica,Arial,sans-serif;margin-right:10px;vertical-align:middle;">P</span>
</td>
<td style="vertical-align:middle;">
<span style="color:#FFFFFF;font-size:18px;font-weight:700;letter-spacing:-0.03em;vertical-align:middle;">{config.APP_NAME}</span>
</td>
</tr>
</table>
<!-- 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 -->
@@ -68,7 +76,7 @@ def _email_wrap(body: str, lang: str = "en") -> str:
<!-- 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;">{config.APP_NAME}</a>
<a href="{config.BASE_URL}" style="color:#64748B;text-decoration:none;font-weight:500;">padelnomics.io</a>
&nbsp;&middot;&nbsp;
{tagline}
</p>
@@ -85,12 +93,16 @@ def _email_wrap(body: str, lang: str = "en") -> str:
def _email_button(url: str, label: str) -> str:
"""Render a branded CTA button for email."""
"""Render a branded CTA button for email.
Uses display:block for full-width tap target on mobile.
"""
return (
f'<table cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">'
f'<tr><td style="background-color:#1D4ED8;border-radius:7px;text-align:center;">'
f'<a href="{url}" style="display:inline-block;padding:13px 30px;'
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">'
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>"
)
@@ -185,6 +197,7 @@ async def handle_send_email(payload: dict) -> None:
html=payload["html"],
text=payload.get("text"),
from_addr=payload.get("from_addr"),
email_type="generic",
)
@@ -200,9 +213,11 @@ async def handle_send_magic_link(payload: dict) -> None:
print(f" {link}")
print(f"{'=' * 60}\n")
expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=config.MAGIC_LINK_EXPIRY_MINUTES)}</p>'
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=expiry_minutes)}</p>'
f'{_email_button(link, _t("email_magic_link_btn", lang))}'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_fallback", lang)}</p>'
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
@@ -212,8 +227,9 @@ async def handle_send_magic_link(payload: dict) -> None:
await send_email(
to=payload["email"],
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
html=_email_wrap(body, lang),
html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)),
from_addr=EMAIL_ADDRESSES["transactional"],
email_type="magic_link",
)
@@ -235,21 +251,36 @@ async def handle_send_quote_verification(payload: dict) -> None:
first_name = (
payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
)
project_desc = ""
parts = []
if payload.get("court_count"):
parts.append(f"{payload['court_count']}-court")
if payload.get("facility_type"):
parts.append(payload["facility_type"])
if payload.get("country"):
parts.append(f"in {payload['country']}")
if parts:
project_desc = f" for your {' '.join(parts)} project"
court_count = payload.get("court_count", "")
facility_type = payload.get("facility_type", "")
country = payload.get("country", "")
# Project recap card
project_card = ""
recap_parts = []
if court_count:
recap_parts.append(f"{court_count} courts")
if facility_type:
recap_parts.append(facility_type)
if 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)
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_quote_verify_heading", lang)}</h2>'
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_quote_verify_heading", lang)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p>{_t("email_quote_verify_greeting", lang, first_name=first_name)}</p>'
f'<p>{_t("email_quote_verify_body", lang, project_desc=project_desc, app_name=config.APP_NAME)}</p>'
f'<p>{_t("email_quote_verify_body", lang, app_name=config.APP_NAME)}</p>'
f'{project_card}'
f'<p style="font-size:13px;color:#334155;">{_t("email_quote_verify_urgency", lang)}</p>'
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>'
@@ -260,8 +291,9 @@ async def handle_send_quote_verification(payload: dict) -> None:
await send_email(
to=payload["email"],
subject=_t("email_quote_verify_subject", lang),
html=_email_wrap(body, lang),
html=_email_wrap(body, lang, preheader=preheader),
from_addr=EMAIL_ADDRESSES["transactional"],
email_type="quote_verification",
)
@@ -269,17 +301,32 @@ async def handle_send_quote_verification(payload: dict) -> None:
async def handle_send_welcome(payload: dict) -> None:
"""Send welcome email to new user."""
lang = payload.get("lang", "en")
name_parts = (payload.get("name") or "").split()
first_name = name_parts[0] if name_parts else "there"
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>'
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p>{_t("email_welcome_greeting", lang, first_name=first_name)}</p>'
f'<p>{_t("email_welcome_body", lang)}</p>'
f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_welcome_btn", 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(
to=payload["email"],
subject=_t("email_welcome_subject", lang, app_name=config.APP_NAME),
html=_email_wrap(body, lang),
subject=_t("email_welcome_subject", lang),
html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)),
from_addr=EMAIL_ADDRESSES["transactional"],
email_type="welcome",
)
@@ -292,21 +339,30 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
if intent.startswith("supplier_"):
plan_name = intent.replace("supplier_", "").title()
subject = _t("email_waitlist_supplier_subject", lang, app_name=config.APP_NAME, plan_name=plan_name)
subject = _t("email_waitlist_supplier_subject", lang, plan_name=plan_name)
preheader = _t("email_waitlist_supplier_preheader", lang)
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_waitlist_supplier_heading", lang)}</h2>'
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_waitlist_supplier_heading", lang)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p>{_t("email_waitlist_supplier_body", lang, plan_name=plan_name)}</p>'
f'<p>{_t("email_waitlist_supplier_perks", lang)}</p>'
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">{_t("email_waitlist_supplier_link_planner", lang)}</a></li>'
f'<li><a href="{config.BASE_URL}/directory">{_t("email_waitlist_supplier_link_directory", lang)}</a></li>'
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:
subject = _t("email_waitlist_general_subject", lang, app_name=config.APP_NAME)
subject = _t("email_waitlist_general_subject", lang)
preheader = _t("email_waitlist_general_preheader", lang)
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_waitlist_general_heading", lang)}</h2>'
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_waitlist_general_heading", lang)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
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;">'
@@ -320,8 +376,9 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
await send_email(
to=email,
subject=subject,
html=_email_wrap(body, lang),
html=_email_wrap(body, lang, preheader=preheader),
from_addr=EMAIL_ADDRESSES["transactional"],
email_type="waitlist",
)
@@ -354,19 +411,31 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
country = lead["country"] or "Unknown"
courts = lead["court_count"] or "?"
budget = lead["budget_estimate"] or "?"
facility_type = lead["facility_type"] or "padel"
timeline = lead["timeline"] or ""
contact_email = lead["contact_email"] or ""
subject = f"[{heat}] New padel project in {country} {courts} courts, {budget}"
subject = f"[{heat}] New padel project in {country} \u2014 {courts} courts, \u20ac{budget}"
t = lambda key: _t(key, lang) # noqa: E731
# 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
brief_rows = [
(t("email_lead_forward_lbl_facility"), f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
(t("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
(t("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"),
(t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: \u20ac{budget}"),
(t("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
(t("email_lead_forward_lbl_services"), lead["services_needed"] or "-"),
(t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
(tl("email_lead_forward_lbl_facility"), f"{facility_type} ({lead['build_context'] or '-'})"),
(tl("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
(tl("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"),
(tl("email_lead_forward_lbl_timeline"), f"{timeline or '-'} | Budget: \u20ac{budget}"),
(tl("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
(tl("email_lead_forward_lbl_services"), lead["services_needed"] or "-"),
(tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
]
brief_html = ""
@@ -376,29 +445,41 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
)
contact_rows = [
(t("email_lead_forward_lbl_name"), lead["contact_name"] or "-"),
(t("email_lead_forward_lbl_email"), lead["contact_email"] or "-"),
(t("email_lead_forward_lbl_phone"), lead["contact_phone"] or "-"),
(t("email_lead_forward_lbl_company"), lead["contact_company"] or "-"),
(t("email_lead_forward_lbl_role"), lead["stakeholder_type"] or "-"),
]
contact_name = lead["contact_name"] or "-"
contact_phone = lead["contact_phone"] or "-"
contact_html = ""
for label, value in contact_rows:
contact_html += (
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{label}</td>'
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
# 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"]
if timeline:
preheader_parts.append(f"{timeline} timeline")
preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang))
body = (
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{t("email_lead_forward_heading")}</h2>'
f'<p style="font-size:13px;color:#64748B;margin:0 0 16px">{t("email_lead_forward_subheading")}</p>'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{t("email_lead_forward_section_brief")}</h3>'
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">{t("email_lead_forward_section_contact")}</h3>'
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", t("email_lead_forward_btn"))}'
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
@@ -410,8 +491,9 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
await send_email(
to=to_email,
subject=subject,
html=_email_wrap(body, lang),
html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)),
from_addr=EMAIL_ADDRESSES["leads"],
email_type="lead_forward",
)
# Update email_sent_at on lead_forward
@@ -434,19 +516,27 @@ async def handle_send_lead_matched_notification(payload: dict) -> None:
first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there"
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_lead_matched_heading", lang)}</h2>'
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_lead_matched_heading", lang)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p>{_t("email_lead_matched_greeting", lang, first_name=first_name)}</p>'
f'<p>{_t("email_lead_matched_body", lang)}</p>'
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>'
# What happens next
f'<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{_t("email_lead_matched_next_heading", lang)}</p>'
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(
to=lead["contact_email"],
subject=_t("email_lead_matched_subject", lang),
html=_email_wrap(body, lang),
subject=_t("email_lead_matched_subject", lang, first_name=first_name),
html=_email_wrap(body, lang, preheader=_t("email_lead_matched_preheader", lang)),
from_addr=EMAIL_ADDRESSES["leads"],
email_type="lead_matched",
)
@@ -464,8 +554,9 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
message = payload.get("message", "")
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">'
f'{_t("email_enquiry_heading", lang, app_name=config.APP_NAME)}</h2>'
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">'
f'{_t("email_enquiry_heading", lang, contact_name=contact_name)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p>{_t("email_enquiry_body", lang, supplier_name=supplier_name)}</p>'
f'<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">'
f'<tr><td style="padding:6px 0;color:#64748B;width:120px">{_t("email_enquiry_lbl_from", lang)}</td>'
@@ -473,14 +564,16 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
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(
to=supplier_email,
subject=_t("email_enquiry_subject", lang, app_name=config.APP_NAME, contact_name=contact_name),
html=_email_wrap(body, lang),
subject=_t("email_enquiry_subject", lang, contact_name=contact_name),
html=_email_wrap(body, lang, preheader=_t("email_enquiry_preheader", lang)),
from_addr=EMAIL_ADDRESSES["transactional"],
email_type="supplier_enquiry",
)
@@ -542,15 +635,20 @@ async def handle_generate_business_plan(payload: dict) -> None:
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
if user:
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_business_plan_heading", language)}</h2>'
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_business_plan_heading", language)}</h2>'
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
f'<p>{_t("email_business_plan_body", language)}</p>'
f'<p style="font-size:14px;color:#334155;">{_t("email_business_plan_includes", language)}</p>'
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", 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(
to=user["email"],
subject=_t("email_business_plan_subject", language),
html=_email_wrap(body, language),
html=_email_wrap(body, language, preheader=_t("email_business_plan_preheader", language)),
from_addr=EMAIL_ADDRESSES["transactional"],
email_type="business_plan",
)
print(f"[WORKER] Generated business plan PDF: export_id={export_id}")

842
web/tests/test_emails.py Normal file
View File

@@ -0,0 +1,842 @@
"""
Tests for all transactional email worker handlers.
Each handler is tested by mocking send_email and calling the handler
directly with a payload. Assertions cover: recipient, subject keywords,
HTML content (design elements, i18n keys resolved), and from_addr.
The TestResendLive class at the bottom sends real emails via Resend's
@resend.dev test addresses. Skipped unless RESEND_API_KEY is set.
"""
import asyncio
import os
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics.worker import (
handle_send_lead_forward_email,
handle_send_lead_matched_notification,
handle_send_magic_link,
handle_send_quote_verification,
handle_send_supplier_enquiry_email,
handle_send_waitlist_confirmation,
handle_send_welcome,
)
from padelnomics import core
# ── Helpers ──────────────────────────────────────────────────────
def _call_kwargs(mock_send: AsyncMock) -> dict:
"""Extract kwargs from first call to mock send_email."""
mock_send.assert_called_once()
return mock_send.call_args.kwargs
def _assert_common_design(html: str, lang: str = "en"):
"""Verify shared email wrapper design elements are present."""
assert "padelnomics" in html # wordmark
assert "Bricolage Grotesque" in html # brand font
assert "#1D4ED8" in html # blue accent / button
assert "padelnomics.io" in html # footer link
assert f'lang="{lang}"' in html # html lang attribute
# ── Magic Link ───────────────────────────────────────────────────
class TestMagicLink:
@pytest.mark.asyncio
async def test_sends_to_correct_recipient(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send)
assert kw["to"] == "user@example.com"
@pytest.mark.asyncio
async def test_subject_contains_app_name(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send)
assert core.config.APP_NAME.lower() in kw["subject"].lower()
@pytest.mark.asyncio
async def test_html_contains_verify_link(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
kw = _call_kwargs(mock_send)
assert "/auth/verify?token=abc123" in kw["html"]
@pytest.mark.asyncio
async def test_html_contains_fallback_link_text(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
html = _call_kwargs(mock_send)["html"]
assert "word-break:break-all" in html # fallback URL block
@pytest.mark.asyncio
async def test_uses_transactional_from_addr(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio
async def test_preheader_mentions_expiry(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
html = _call_kwargs(mock_send)["html"]
# preheader is hidden span; should mention minutes
assert "display:none" in html # preheader present
@pytest.mark.asyncio
async def test_design_elements_present(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio
async def test_respects_lang_parameter(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"})
html = _call_kwargs(mock_send)["html"]
_assert_common_design(html, lang="de")
# ── Welcome ──────────────────────────────────────────────────────
class TestWelcome:
@pytest.mark.asyncio
async def test_sends_to_correct_recipient(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
assert _call_kwargs(mock_send)["to"] == "new@example.com"
@pytest.mark.asyncio
async def test_subject_not_empty(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
assert len(_call_kwargs(mock_send)["subject"]) > 5
@pytest.mark.asyncio
async def test_html_contains_quickstart_links(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
html = _call_kwargs(mock_send)["html"]
assert "/planner" in html
assert "/markets" in html
assert "/leads/quote" in html
@pytest.mark.asyncio
async def test_uses_first_name_when_provided(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"})
html = _call_kwargs(mock_send)["html"]
assert "Alice" in html
@pytest.mark.asyncio
async def test_fallback_greeting_when_no_name(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
html = _call_kwargs(mock_send)["html"]
# Should use "there" as fallback first_name
assert "there" in html.lower()
@pytest.mark.asyncio
async def test_uses_transactional_from_addr(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio
async def test_design_elements_present(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com"})
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio
async def test_german_welcome(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_welcome({"email": "new@example.com", "lang": "de"})
html = _call_kwargs(mock_send)["html"]
_assert_common_design(html, lang="de")
# ── Quote Verification ───────────────────────────────────────────
class TestQuoteVerification:
_BASE_PAYLOAD = {
"email": "lead@example.com",
"token": "verify_tok",
"lead_token": "lead_tok",
"contact_name": "Bob Builder",
"court_count": 6,
"facility_type": "Indoor",
"country": "Germany",
}
@pytest.mark.asyncio
async def test_sends_to_contact_email(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send)["to"] == "lead@example.com"
@pytest.mark.asyncio
async def test_html_contains_verify_link(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
assert "token=verify_tok" in html
assert "lead=lead_tok" in html
@pytest.mark.asyncio
async def test_html_contains_project_recap(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
assert "6 courts" in html
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio
async def test_uses_first_name_from_contact(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
assert "Bob" in html
@pytest.mark.asyncio
async def test_handles_minimal_payload(self):
"""No court_count/facility_type/country — should still send."""
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification({
"email": "lead@example.com",
"token": "tok",
"lead_token": "ltok",
})
mock_send.assert_called_once()
@pytest.mark.asyncio
async def test_uses_transactional_from_addr(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio
async def test_design_elements_present(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_quote_verification(self._BASE_PAYLOAD)
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Lead Forward (the money email) ──────────────────────────────
class TestLeadForward:
@pytest.mark.asyncio
async def test_sends_to_supplier_email(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send)["to"] == "supplier@test.com"
@pytest.mark.asyncio
async def test_subject_contains_heat_and_country(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
subject = _call_kwargs(mock_send)["subject"]
assert "[HOT]" in subject
assert "Germany" in subject
assert "4 courts" in subject
@pytest.mark.asyncio
async def test_html_contains_heat_badge(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
assert "#DC2626" in html # HOT badge color
assert "HOT" in html
@pytest.mark.asyncio
async def test_html_contains_project_brief(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio
async def test_html_contains_contact_info(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
assert "lead@buyer.com" in html
assert "mailto:lead@buyer.com" in html
assert "John Doe" in html
@pytest.mark.asyncio
async def test_html_contains_urgency_callout(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
# Urgency callout has yellow background
assert "#FEF3C7" in html
@pytest.mark.asyncio
async def test_html_contains_direct_reply_cta(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
html = _call_kwargs(mock_send)["html"]
# Direct reply link text should mention the contact email
assert "lead@buyer.com" in html
@pytest.mark.asyncio
async def test_uses_leads_from_addr(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
@pytest.mark.asyncio
async def test_updates_email_sent_at(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db, create_forward=True)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock):
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
async with db.execute(
"SELECT email_sent_at FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
(lead_id, supplier_id),
) as cursor:
row = await cursor.fetchone()
assert row is not None
assert row["email_sent_at"] is not None
@pytest.mark.asyncio
async def test_skips_when_no_supplier_email(self, db):
"""No email on supplier record — handler exits without sending."""
lead_id, supplier_id = await _seed_lead_and_supplier(db, supplier_email="")
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_lead_not_found(self, db):
"""Non-existent lead_id — handler exits without sending."""
_, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_design_elements_present(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Lead Matched Notification ────────────────────────────────────
class TestLeadMatched:
@pytest.mark.asyncio
async def test_sends_to_lead_contact_email(self, db):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send)["to"] == "lead@buyer.com"
@pytest.mark.asyncio
async def test_subject_contains_first_name(self, db):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert "John" in _call_kwargs(mock_send)["subject"]
@pytest.mark.asyncio
async def test_html_contains_what_happens_next(self, db):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
html = _call_kwargs(mock_send)["html"]
# "What happens next" section and tip callout (blue bg)
assert "#F0F9FF" in html # tip callout background
@pytest.mark.asyncio
async def test_html_contains_project_context(self, db):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
html = _call_kwargs(mock_send)["html"]
assert "Indoor" in html
assert "Germany" in html
@pytest.mark.asyncio
async def test_uses_leads_from_addr(self, db):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
@pytest.mark.asyncio
async def test_skips_when_lead_not_found(self, db):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": 99999})
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_no_contact_email(self, db):
lead_id = await _seed_lead(db, contact_email="")
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_design_elements_present(self, db):
lead_id = await _seed_lead(db)
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_lead_matched_notification({"lead_id": lead_id})
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Supplier Enquiry ─────────────────────────────────────────────
class TestSupplierEnquiry:
_BASE_PAYLOAD = {
"supplier_email": "supplier@corp.com",
"supplier_name": "PadelBuild GmbH",
"contact_name": "Alice Smith",
"contact_email": "alice@buyer.com",
"message": "I need 4 courts, can you quote?",
}
@pytest.mark.asyncio
async def test_sends_to_supplier_email(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send)["to"] == "supplier@corp.com"
@pytest.mark.asyncio
async def test_subject_contains_contact_name(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert "Alice Smith" in _call_kwargs(mock_send)["subject"]
@pytest.mark.asyncio
async def test_html_contains_message(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
assert "4 courts" in html
assert "alice@buyer.com" in html
@pytest.mark.asyncio
async def test_html_contains_respond_fast_nudge(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
html = _call_kwargs(mock_send)["html"]
# The respond-fast nudge line should be present
assert "24" in html # "24 hours" reference
@pytest.mark.asyncio
async def test_skips_when_no_supplier_email(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""})
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_uses_transactional_from_addr(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
@pytest.mark.asyncio
async def test_design_elements_present(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
_assert_common_design(_call_kwargs(mock_send)["html"])
# ── Waitlist (supplement existing test_waitlist.py) ──────────────
class TestWaitlistEmails:
"""Verify design & content for waitlist confirmation emails."""
@pytest.mark.asyncio
async def test_general_waitlist_has_preheader(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
html = _call_kwargs(mock_send)["html"]
assert "display:none" in html # preheader span
@pytest.mark.asyncio
async def test_supplier_waitlist_mentions_plan(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"})
kw = _call_kwargs(mock_send)
assert "growth" in kw["subject"].lower()
assert "supplier" in kw["html"].lower()
@pytest.mark.asyncio
async def test_general_waitlist_design_elements(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
_assert_common_design(_call_kwargs(mock_send)["html"])
@pytest.mark.asyncio
async def test_supplier_waitlist_perks_listed(self):
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"})
html = _call_kwargs(mock_send)["html"]
# Should have <li> perks
assert html.count("<li>") >= 3
# ── DB seed helpers ──────────────────────────────────────────────
async def _seed_lead(
db,
contact_email: str = "lead@buyer.com",
contact_name: str = "John Doe",
) -> int:
"""Insert a lead_request row, return its id."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO lead_requests (
lead_type, contact_name, contact_email, contact_phone,
contact_company, stakeholder_type, facility_type, court_count,
country, location, build_context, glass_type, lighting_type,
timeline, budget_estimate, location_status, financing_status,
services_needed, additional_info, heat_score,
status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
"quote", contact_name, contact_email, "+49123456",
"Padel Corp", "investor", "Indoor", 4,
"Germany", "Berlin", "new_build", "tempered", "LED",
"6-12 months", 200000, "secured", "seeking",
"construction, lighting", "Need turnkey solution", "hot",
"verified", now,
),
) as cursor:
lead_id = cursor.lastrowid
await db.commit()
return lead_id
async def _seed_supplier(
db,
contact_email: str = "supplier@test.com",
) -> int:
"""Insert a supplier row, return its id."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO suppliers (
name, slug, country_code, region, category,
contact_email, tier, claimed_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
"Test Supplier", "test-supplier", "DE", "Europe", "construction",
contact_email, "growth", None, now,
),
) as cursor:
supplier_id = cursor.lastrowid
await db.commit()
return supplier_id
async def _seed_lead_and_supplier(
db,
supplier_email: str = "supplier@test.com",
create_forward: bool = False,
) -> tuple[int, int]:
"""Insert a lead + supplier pair, optionally with a lead_forwards row."""
lead_id = await _seed_lead(db)
supplier_id = await _seed_supplier(db, contact_email=supplier_email)
if create_forward:
now = datetime.now(UTC).isoformat()
await db.execute(
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at)
VALUES (?, ?, ?, ?)""",
(lead_id, supplier_id, 1, now),
)
await db.commit()
return lead_id, supplier_id
# ── Live Resend integration tests ────────────────────────────────
#
# These send real emails to Resend's @resend.dev test addresses,
# then retrieve the sent email via resend.Emails.get() to verify the
# rendered HTML contains expected design elements and content.
#
# Skipped unless RESEND_API_KEY is set in the environment.
# Run with: RESEND_API_KEY=re_xxx uv run pytest web/tests/test_emails.py -k resend_live -v
_has_resend_key = bool(os.environ.get("RESEND_API_KEY"))
_skip_no_key = pytest.mark.skipif(not _has_resend_key, reason="RESEND_API_KEY not set")
def _send_and_capture():
"""Patch resend.Emails.send to capture the returned email ID.
Returns (wrapper, captured) where captured is a dict that will
contain {"id": ...} after send is called. The real send still
executes — this just grabs the return value that core.send_email
discards.
"""
import resend as _resend
_original_send = _resend.Emails.send
captured = {}
def _wrapper(params):
result = _original_send(params)
captured["id"] = result.id if hasattr(result, "id") else result.get("id")
return result
return _wrapper, captured
def _retrieve_html(captured: dict) -> str | None:
"""Retrieve the sent email from Resend and return its HTML.
Returns None if:
- The send failed (rate limit, network error) — captured has no id
- The API key lacks read permission (sending_access only)
"""
import resend as _resend
email_id = captured.get("id")
if not email_id:
return None # send failed (e.g. rate limit) — can't retrieve
try:
email = _resend.Emails.get(email_id)
except Exception:
# sending_access key — can't retrieve, that's OK
return None
return email.html if hasattr(email, "html") else email.get("html", "")
@_skip_no_key
class TestResendLive:
"""Send real emails via Resend to @resend.dev test addresses.
Uses ``delivered@resend.dev`` which simulates successful delivery.
After sending, retrieves the email via resend.Emails.get() and
asserts on the rendered HTML (design elements, content, structure).
Each test calls the handler WITHOUT mocking send_email, so the full
path is exercised: handler → _email_wrap → send_email → Resend API.
"""
@pytest.fixture(autouse=True)
async def _set_resend_key(self):
"""Ensure config picks up the real API key from env.
Adds a 0.6s pause between tests to respect Resend's 2 req/sec
rate limit.
"""
import resend as _resend
original = core.config.RESEND_API_KEY
core.config.RESEND_API_KEY = os.environ["RESEND_API_KEY"]
_resend.api_key = os.environ["RESEND_API_KEY"]
yield
core.config.RESEND_API_KEY = original
await asyncio.sleep(0.6)
def _check_html(self, captured: dict, assertions: list[tuple[str, str]]):
"""Retrieve sent email and run assertions on HTML.
If the API key lacks read permission (sending_access only),
the retrieve is skipped — the test still passes because the
send itself succeeded (no exception from handler).
assertions: list of (needle, description) tuples.
"""
html = _retrieve_html(captured)
if html is None:
return # sending_access key — can't retrieve, send was enough
for needle, desc in assertions:
assert needle in html, f"Expected {desc!r} ({needle!r}) in rendered HTML"
@pytest.mark.asyncio
async def test_magic_link_delivered(self):
wrapper, captured = _send_and_capture()
with patch("resend.Emails.send", side_effect=wrapper):
await handle_send_magic_link({
"email": "delivered@resend.dev",
"token": "test_token_magic",
"lang": "en",
})
self._check_html(captured, [
("padelnomics", "wordmark"),
("Bricolage Grotesque", "brand font"),
("/auth/verify?token=test_token_magic", "verify link"),
("padelnomics.io", "footer link"),
])
@pytest.mark.asyncio
async def test_welcome_delivered(self):
wrapper, captured = _send_and_capture()
with patch("resend.Emails.send", side_effect=wrapper):
await handle_send_welcome({
"email": "delivered@resend.dev",
"name": "Test User",
"lang": "en",
})
self._check_html(captured, [
("Test", "first name"),
("/planner", "planner link"),
("/markets", "markets link"),
("/leads/quote", "quotes link"),
])
@pytest.mark.asyncio
async def test_quote_verification_delivered(self):
wrapper, captured = _send_and_capture()
with patch("resend.Emails.send", side_effect=wrapper):
await handle_send_quote_verification({
"email": "delivered@resend.dev",
"token": "test_verify_tok",
"lead_token": "test_lead_tok",
"contact_name": "Test Buyer",
"court_count": 4,
"facility_type": "Indoor",
"country": "Germany",
"lang": "en",
})
self._check_html(captured, [
("4 courts", "court count"),
("Indoor", "facility type"),
("Germany", "country"),
("token=test_verify_tok", "verify token"),
])
@pytest.mark.asyncio
async def test_waitlist_general_delivered(self):
wrapper, captured = _send_and_capture()
with patch("resend.Emails.send", side_effect=wrapper):
await handle_send_waitlist_confirmation({
"email": "delivered@resend.dev",
"intent": "signup",
"lang": "en",
})
self._check_html(captured, [
("padelnomics", "wordmark"),
("<li>", "perk bullets"),
])
@pytest.mark.asyncio
async def test_waitlist_supplier_delivered(self):
wrapper, captured = _send_and_capture()
with patch("resend.Emails.send", side_effect=wrapper):
await handle_send_waitlist_confirmation({
"email": "delivered@resend.dev",
"intent": "supplier_growth",
"lang": "en",
})
self._check_html(captured, [
("Growth", "plan name"),
])
@pytest.mark.asyncio
async def test_lead_forward_delivered(self, db):
lead_id, supplier_id = await _seed_lead_and_supplier(
db, supplier_email="delivered@resend.dev",
)
wrapper, captured = _send_and_capture()
with patch("resend.Emails.send", side_effect=wrapper):
await handle_send_lead_forward_email({
"lead_id": lead_id,
"supplier_id": supplier_id,
"lang": "en",
})
self._check_html(captured, [
("#DC2626", "HOT badge color"),
("lead@buyer.com", "contact email"),
("mailto:lead@buyer.com", "mailto link"),
("#FEF3C7", "urgency callout bg"),
])
@pytest.mark.asyncio
async def test_lead_matched_delivered(self, db):
lead_id = await _seed_lead(db, contact_email="delivered@resend.dev")
wrapper, captured = _send_and_capture()
with patch("resend.Emails.send", side_effect=wrapper):
await handle_send_lead_matched_notification({
"lead_id": lead_id,
"lang": "en",
})
self._check_html(captured, [
("#F0F9FF", "tip callout bg"),
("Indoor", "facility type"),
("Germany", "country"),
])
@pytest.mark.asyncio
async def test_supplier_enquiry_delivered(self):
wrapper, captured = _send_and_capture()
with patch("resend.Emails.send", side_effect=wrapper):
await handle_send_supplier_enquiry_email({
"supplier_email": "delivered@resend.dev",
"supplier_name": "PadelBuild GmbH",
"contact_name": "Alice Smith",
"contact_email": "alice@buyer.example",
"message": "I need 4 courts, can you quote?",
"lang": "en",
})
self._check_html(captured, [
("Alice Smith", "contact name"),
("alice@buyer.example", "contact email"),
("4 courts", "message content"),
])
@pytest.mark.asyncio
async def test_bounce_handled_gracefully(self):
"""Sending to bounced@resend.dev should not raise — send_email returns str|None."""
result = await core.send_email(
to="bounced@resend.dev",
subject="Bounce test",
html="<p>This should bounce.</p>",
from_addr=core.EMAIL_ADDRESSES["transactional"],
)
# Resend may return success (delivery fails async) or error;
# either way the handler must not crash.
# send_email now returns resend_id (str) on success, None on failure.
assert result is None or isinstance(result, str)

View File

@@ -48,6 +48,8 @@ _IDENTICAL_VALUE_ALLOWLIST = {
# "Budget", "Name", "Phase", "Investor" — same in both languages
"sd_leads_budget", "sd_card_budget", "sd_unlocked_label_budget",
"sd_unlocked_label_name", "sd_unlocked_label_phase", "sd_stakeholder_investor",
# Email lead forward labels — "Phase" and "Name" are identical in DE
"email_lead_forward_lbl_phase", "email_lead_forward_lbl_name",
# Listing form labels that are English brand terms / same in DE
"sd_lst_logo", "sd_lst_website",
# Boost option name — "Logo" is the same in DE

View File

@@ -199,7 +199,7 @@ class TestWorkerTask:
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args.kwargs["to"] == "entrepreneur@example.com"
assert "launching soon" in call_args.kwargs["subject"].lower()
assert "notify you at launch" in call_args.kwargs["subject"].lower()
assert "waitlist" in call_args.kwargs["html"].lower()
@pytest.mark.asyncio