From 454b362c88811cee05acad33976b8a203d640eec Mon Sep 17 00:00:00 2001 From: Deeman Date: Mon, 23 Feb 2026 12:15:34 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20email=20hub=20=E2=80=94=20sent?= =?UTF-8?q?=20log,=20inbox,=20compose,=20audiences,=20delivery=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full email management at /admin/emails with: - email_log table tracking all outgoing emails with resend_id + delivery events - inbound_emails table for Resend webhook-received messages - Resend webhook handler (/webhooks/resend) updating delivery status in real-time - send_email() returns resend_id (str|None) instead of bool; all 9 worker handlers pass email_type= for per-type filtering - Admin UI: sent log with HTMX filters, email detail with API-enriched HTML preview, inbox with unread badges + reply, compose with branded wrapping, audience management with contact list/remove - Sidebar Email section with unread badge via blueprint context processor Co-Authored-By: Claude Opus 4.6 --- .claude/CLAUDE.md | 3 + CHANGELOG.md | 12 + PROJECT.md | 5 +- docs/USER_FLOWS.md | 5 + web/src/padelnomics/admin/routes.py | 368 +++++++++++++++++- .../templates/admin/audience_contacts.html | 46 +++ .../admin/templates/admin/audiences.html | 36 ++ .../admin/templates/admin/base_admin.html | 18 + .../admin/templates/admin/email_compose.html | 52 +++ .../admin/templates/admin/email_detail.html | 73 ++++ .../admin/templates/admin/emails.html | 61 +++ .../admin/templates/admin/inbox.html | 53 +++ .../admin/templates/admin/inbox_detail.html | 67 ++++ .../admin/partials/email_results.html | 46 +++ web/src/padelnomics/app.py | 2 + web/src/padelnomics/core.py | 52 ++- .../migrations/versions/0018_add_email_hub.py | 50 +++ web/src/padelnomics/webhooks.py | 107 +++++ web/src/padelnomics/worker.py | 9 + web/tests/test_emails.py | 5 +- 20 files changed, 1049 insertions(+), 21 deletions(-) create mode 100644 web/src/padelnomics/admin/templates/admin/audience_contacts.html create mode 100644 web/src/padelnomics/admin/templates/admin/audiences.html create mode 100644 web/src/padelnomics/admin/templates/admin/email_compose.html create mode 100644 web/src/padelnomics/admin/templates/admin/email_detail.html create mode 100644 web/src/padelnomics/admin/templates/admin/emails.html create mode 100644 web/src/padelnomics/admin/templates/admin/inbox.html create mode 100644 web/src/padelnomics/admin/templates/admin/inbox_detail.html create mode 100644 web/src/padelnomics/admin/templates/admin/partials/email_results.html create mode 100644 web/src/padelnomics/migrations/versions/0018_add_email_hub.py create mode 100644 web/src/padelnomics/webhooks.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d22d026..a135b10 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bf693d0..1b98034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PROJECT.md b/PROJECT.md index 93adf10..e3c1993 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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, `` 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 diff --git a/docs/USER_FLOWS.md b/docs/USER_FLOWS.md index 7199e04..3f30b3b 100644 --- a/docs/USER_FLOWS.md +++ b/docs/USER_FLOWS.md @@ -188,6 +188,10 @@ Same as Flow 2 but arrives at `//leads/quote` directly (no planner state). | Leads | `GET /admin/leads`, `/admin/leads/` | List, filter, view detail, change status, forward to supplier, create | | Suppliers | `GET /admin/suppliers`, `/admin/suppliers/` | List, view, adjust credits, change tier, create | | Feedback | `GET /admin/feedback` | View all submitted feedback | +| Email Sent Log | `GET /admin/emails`, `/admin/emails/` | List all outgoing emails (filter by type/event/search), detail with API-enriched HTML preview | +| Email Inbox | `GET /admin/emails/inbox`, `/admin/emails/inbox/` | 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//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 `//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"` diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index fed6144..af6ae75 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -8,6 +8,7 @@ from datetime import date, datetime, timedelta from pathlib import Path import mistune +import resend from quart import ( Blueprint, Response, @@ -21,7 +22,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( @@ -32,6 +42,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 # ============================================================================= @@ -827,6 +855,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/") +@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/") +@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//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"

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

" + 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"

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

" + 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//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//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)) + + # ============================================================================= # Article Template Management # ============================================================================= diff --git a/web/src/padelnomics/admin/templates/admin/audience_contacts.html b/web/src/padelnomics/admin/templates/admin/audience_contacts.html new file mode 100644 index 0000000..10ecebe --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/audience_contacts.html @@ -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 %} +
+
+ ← Audiences +

{{ audience.name }}

+

{{ contacts | length }} contacts

+
+
+ + {% if contacts %} +
+ + + + + + + + + + {% for c in contacts %} + + + + + + {% endfor %} + +
EmailCreated
{{ c.email if c.email is defined else (c.get('email', '-') if c is mapping else '-') }}{{ (c.created_at if c.created_at is defined else (c.get('created_at', '-') if c is mapping else '-'))[:16] if c else '-' }} +
+ + + +
+
+
+ {% else %} +
+

No contacts in this audience.

+
+ {% endif %} +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/audiences.html b/web/src/padelnomics/admin/templates/admin/audiences.html new file mode 100644 index 0000000..bb6fb6c --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/audiences.html @@ -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 %} +
+
+

Audiences

+

Resend audiences and contact counts

+
+ Back to Dashboard +
+ + {% if audiences %} +
+ {% for a in audiences %} +
+
+

{{ a.name }}

+ {% if a.contact_count is not none %} + {{ a.contact_count }} contacts + {% else %} + API unavailable + {% endif %} +
+

{{ a.audience_id }}

+ View Contacts +
+ {% endfor %} +
+ {% else %} +
+

No audiences found. They are created automatically when users sign up.

+
+ {% endif %} +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index d826e97..4f19690 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -86,6 +86,24 @@ Templates +
Email
+ + + Sent Log + + + + Inbox{% if unread_count %} {{ unread_count }}{% endif %} + + + + Compose + + + + Audiences + +
System
diff --git a/web/src/padelnomics/admin/templates/admin/email_compose.html b/web/src/padelnomics/admin/templates/admin/email_compose.html new file mode 100644 index 0000000..dfd5a7a --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/email_compose.html @@ -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 %} +
+ ← Sent Log +

Compose Email

+
+ +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/email_detail.html b/web/src/padelnomics/admin/templates/admin/email_detail.html new file mode 100644 index 0000000..597851a --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/email_detail.html @@ -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 %} +
+ ← Sent Log +

Email #{{ email.id }}

+
+ +
+ +
+

Details

+
+
To
+
{{ email.to_addr }}
+
From
+
{{ email.from_addr }}
+
Subject
+
{{ email.subject }}
+
Type
+
{{ email.email_type }}
+
Status
+
+ {% if email.last_event == 'delivered' %} + delivered + {% elif email.last_event == 'bounced' %} + bounced + {% elif email.last_event == 'opened' %} + opened + {% elif email.last_event == 'clicked' %} + clicked + {% elif email.last_event == 'complained' %} + complained + {% else %} + {{ email.last_event }} + {% endif %} +
+
Resend ID
+
{{ email.resend_id or '-' }}
+
Sent at
+
{{ email.created_at or '-' }}
+ {% if email.delivered_at %} +
Delivered
+
{{ email.delivered_at }}
+ {% endif %} + {% if email.opened_at %} +
Opened
+
{{ email.opened_at }}
+ {% endif %} + {% if email.clicked_at %} +
Clicked
+
{{ email.clicked_at }}
+ {% endif %} + {% if email.bounced_at %} +
Bounced
+
{{ email.bounced_at }}
+ {% endif %} +
+
+ + +
+

Preview

+ {% if enriched_html %} + + {% else %} +

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 %}

+ {% endif %} +
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/emails.html b/web/src/padelnomics/admin/templates/admin/emails.html new file mode 100644 index 0000000..8a40294 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/emails.html @@ -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 %} +
+
+

Sent Log

+

+ {{ email_stats.total }} total + · {{ email_stats.sent_today }} today + · {{ email_stats.delivered }} delivered + {% if email_stats.bounced %}· {{ email_stats.bounced }} bounced{% endif %} +

+
+ +
+ + +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ {% include "admin/partials/email_results.html" %} +
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/inbox.html b/web/src/padelnomics/admin/templates/admin/inbox.html new file mode 100644 index 0000000..b5c0864 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/inbox.html @@ -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 %} +
+
+

Inbox

+

+ {{ messages | length }} messages shown + {% if unread_count %}· {{ unread_count }} unread{% endif %} +

+
+ + Compose +
+ + {% if messages %} +
+ + + + + + + + + + + {% for m in messages %} + + + + + + + {% endfor %} + +
FromSubjectReceived
+ {% if not m.is_read %} + + {% endif %} + + {{ m.from_addr }} + + {{ m.subject or '(no subject)' }} + {{ m.received_at[:16] if m.received_at else '-' }}
+
+ {% else %} +
+

No inbound emails yet.

+
+ {% endif %} +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/inbox_detail.html b/web/src/padelnomics/admin/templates/admin/inbox_detail.html new file mode 100644 index 0000000..2f77684 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/inbox_detail.html @@ -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 %} +
+ ← Inbox +

{{ msg.subject or '(no subject)' }}

+
+ +
+ +
+
+
From
+
{{ msg.from_addr }}
+
To
+
{{ msg.to_addr }}
+
Received
+
{{ msg.received_at or '-' }}
+ {% if msg.message_id %} +
Msg ID
+
{{ msg.message_id }}
+ {% endif %} +
+ + {% if msg.html_body %} +

HTML Body

+ + {% elif msg.text_body %} +

Text Body

+
{{ msg.text_body }}
+ {% else %} +

No body content.

+ {% endif %} +
+ + +
+

Reply

+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/partials/email_results.html b/web/src/padelnomics/admin/templates/admin/partials/email_results.html new file mode 100644 index 0000000..2945ff8 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/partials/email_results.html @@ -0,0 +1,46 @@ +{% if emails %} +
+ + + + + + + + + + + + + {% for e in emails %} + + + + + + + + + {% endfor %} + +
IDToSubjectTypeStatusSent
#{{ e.id }}{{ e.to_addr }}{{ e.subject }}{{ e.email_type }} + {% if e.last_event == 'delivered' %} + delivered + {% elif e.last_event == 'bounced' %} + bounced + {% elif e.last_event == 'opened' %} + opened + {% elif e.last_event == 'clicked' %} + clicked + {% elif e.last_event == 'complained' %} + complained + {% else %} + {{ e.last_event }} + {% endif %} + {{ e.created_at[:16] if e.created_at else '-' }}
+
+{% else %} +
+

No emails match the current filters.

+
+{% endif %} diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index 30aad72..e9f76f7 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -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="/") @@ -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 / too app.register_blueprint(content_bp, url_prefix="/") diff --git a/web/src/padelnomics/core.py b/web/src/padelnomics/core.py index bd925f2..a85e368 100644 --- a/web/src/padelnomics/core.py +++ b/web/src/padelnomics/core.py @@ -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: + result = resend.Emails.send( + { + "from": sender, + "to": to, + "subject": subject, + "html": html, + "text": text or html, + } + ) + 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 None - resend.api_key = config.RESEND_API_KEY + # Log to email_log (best-effort, never fail the send) try: - resend.Emails.send( - { - "from": from_addr or config.EMAIL_FROM, - "to": to, - "subject": subject, - "html": html, - "text": text or html, - } + await execute( + """INSERT INTO email_log (resend_id, from_addr, to_addr, subject, email_type) + VALUES (?, ?, ?, ?, ?)""", + (resend_id, sender, to, subject, email_type), ) - return True except Exception as e: - print(f"[EMAIL] Error sending to {to}: {e}") - return False + print(f"[EMAIL] Failed to log email: {e}") + + return resend_id # ============================================================================= diff --git a/web/src/padelnomics/migrations/versions/0018_add_email_hub.py b/web/src/padelnomics/migrations/versions/0018_add_email_hub.py new file mode 100644 index 0000000..8b0dc66 --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0018_add_email_hub.py @@ -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)") diff --git a/web/src/padelnomics/webhooks.py b/web/src/padelnomics/webhooks.py new file mode 100644 index 0000000..6d3821c --- /dev/null +++ b/web/src/padelnomics/webhooks.py @@ -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, + ), + ) diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py index 315253d..d639ee4 100644 --- a/web/src/padelnomics/worker.py +++ b/web/src/padelnomics/worker.py @@ -197,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", ) @@ -228,6 +229,7 @@ async def handle_send_magic_link(payload: dict) -> None: subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME), html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)), from_addr=EMAIL_ADDRESSES["transactional"], + email_type="magic_link", ) @@ -291,6 +293,7 @@ async def handle_send_quote_verification(payload: dict) -> None: subject=_t("email_quote_verify_subject", lang), html=_email_wrap(body, lang, preheader=preheader), from_addr=EMAIL_ADDRESSES["transactional"], + email_type="quote_verification", ) @@ -323,6 +326,7 @@ async def handle_send_welcome(payload: dict) -> None: 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", ) @@ -374,6 +378,7 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None: subject=subject, html=_email_wrap(body, lang, preheader=preheader), from_addr=EMAIL_ADDRESSES["transactional"], + email_type="waitlist", ) @@ -488,6 +493,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None: subject=subject, 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 @@ -530,6 +536,7 @@ async def handle_send_lead_matched_notification(payload: dict) -> None: 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", ) @@ -566,6 +573,7 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None: 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", ) @@ -640,6 +648,7 @@ async def handle_generate_business_plan(payload: dict) -> None: subject=_t("email_business_plan_subject", 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}") diff --git a/web/tests/test_emails.py b/web/tests/test_emails.py index 77a8cc0..eaac104 100644 --- a/web/tests/test_emails.py +++ b/web/tests/test_emails.py @@ -829,7 +829,7 @@ class TestResendLive: @pytest.mark.asyncio async def test_bounce_handled_gracefully(self): - """Sending to bounced@resend.dev should not raise — send_email returns False.""" + """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", @@ -838,4 +838,5 @@ class TestResendLive: ) # Resend may return success (delivery fails async) or error; # either way the handler must not crash. - assert isinstance(result, bool) + # send_email now returns resend_id (str) on success, None on failure. + assert result is None or isinstance(result, str)