feat: admin email hub — sent log, inbox, compose, audiences, delivery tracking

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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-23 12:15:34 +01:00
parent cb1f00baf0
commit 454b362c88
20 changed files with 1049 additions and 21 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` | | Extraction patterns, state tracking, adding new sources | `extract/padelnomics_extract/README.md` |
| 3-layer SQLMesh architecture, materialization strategy | `transform/sqlmesh_padelnomics/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 | | 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 ## 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) | | `LANDING_DIR` | `data/landing` | Landing zone root (extraction writes here) |
| `DUCKDB_PATH` | `local.duckdb` | SQLMesh pipeline DB (exclusive write) | | `DUCKDB_PATH` | `local.duckdb` | SQLMesh pipeline DB (exclusive write) |
| `SERVING_DUCKDB_PATH` | `analytics.duckdb` | Read-only DB for web app | | `SERVING_DUCKDB_PATH` | `analytics.duckdb` | Read-only DB for web app |
| `RESEND_WEBHOOK_SECRET` | `""` | Resend webhook signature secret (skip verification if empty) |
## Coding philosophy ## Coding philosophy

View File

@@ -7,6 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added ### 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 - **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, (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 Argentina, Middle East, USA); PAGE_SIZE increased from 20 to 100; availability

View File

@@ -1,7 +1,7 @@
# Padelnomics — Project Tracker # Padelnomics — Project Tracker
> Move tasks across columns as you work. Add new tasks at the top of the relevant column. > 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] Comprehensive admin: users, tasks, leads, suppliers, CMS templates, scenarios, articles, feedback
- [x] Task queue management (list, retry, delete) - [x] Task queue management (list, retry, delete)
- [x] Lead funnel stats on admin dashboard - [x] Lead funnel stats on admin dashboard
- [x] Email hub (`/admin/emails`) — sent log, inbox, compose, audiences, delivery event tracking via Resend webhooks
### SEO & Legal ### SEO & Legal
- [x] Sitemap (both language variants, `<lastmod>` on all entries) - [x] Sitemap (both language variants, `<lastmod>` on all entries)
@@ -193,7 +194,7 @@ _Move here when you start working on it._
### Bugs / Tech Debt ### Bugs / Tech Debt
- [ ] Resend audiences: two segments both using "waitlist-auth" — review audience/segment model and fix duplication - [ ] 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 - [ ] 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 - [ ] Extraction: Playtomic API only returns ~20 venues per bbox — investigate smaller/targeted bboxes
### Marketing & Content ### 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 | | 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 | | Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
| Feedback | `GET /admin/feedback` | View all submitted feedback | | 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 | | Article Templates | `GET /admin/templates` | CRUD + bulk generate articles from template+data |
| Published Scenarios | `GET /admin/scenarios` | CRUD public scenario cards (shown on landing) | | Published Scenarios | `GET /admin/scenarios` | CRUD public scenario cards (shown on landing) |
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML | | 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 | | `dashboard` | `/dashboard` | No |
| `billing` | `/billing` | No | | `billing` | `/billing` | No |
| `admin` | `/admin` | No | | `admin` | `/admin` | No |
| `webhooks` | `/webhooks` | No |
**Language detection for non-prefixed blueprints:** Cookie (`lang`) → `Accept-Language` header → fallback `"en"` **Language detection for non-prefixed blueprints:** Cookie (`lang`) → `Accept-Language` header → fallback `"en"`

View File

@@ -8,6 +8,7 @@ from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
import mistune import mistune
import resend
from quart import ( from quart import (
Blueprint, Blueprint,
Response, Response,
@@ -21,7 +22,16 @@ from quart import (
) )
from ..auth.routes import role_required 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 # Blueprint with its own template folder
bp = Blueprint( 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 # SQL Queries
# ============================================================================= # =============================================================================
@@ -827,6 +855,344 @@ async def feedback():
return await render_template("admin/feedback.html", feedback_list=feedback_list) 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))
# ============================================================================= # =============================================================================
# Article Template Management # Article Template Management
# ============================================================================= # =============================================================================

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 Templates
</a> </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> <div class="admin-sidebar__section">System</div>
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}"> <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> <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 .planner.routes import bp as planner_bp
from .public.routes import bp as public_bp from .public.routes import bp as public_bp
from .suppliers.routes import bp as suppliers_bp from .suppliers.routes import bp as suppliers_bp
from .webhooks import bp as webhooks_bp
# Lang-prefixed blueprints (SEO-relevant, public-facing) # Lang-prefixed blueprints (SEO-relevant, public-facing)
app.register_blueprint(public_bp, url_prefix="/<lang>") app.register_blueprint(public_bp, url_prefix="/<lang>")
@@ -318,6 +319,7 @@ def create_app() -> Quart:
app.register_blueprint(dashboard_bp) app.register_blueprint(dashboard_bp)
app.register_blueprint(billing_bp) app.register_blueprint(billing_bp)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(webhooks_bp)
# Content catch-all LAST — lives under /<lang> too # Content catch-all LAST — lives under /<lang> too
app.register_blueprint(content_bp, url_prefix="/<lang>") 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() e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
] ]
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "") 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" WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) 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( async def send_email(
to: str, subject: str, html: str, text: str = None, from_addr: str = None to: str, subject: str, html: str, text: str = None,
) -> bool: from_addr: str = None, email_type: str = "ad_hoc",
"""Send email via Resend SDK.""" ) -> 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: if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}") 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: try:
resend.Emails.send( await execute(
{ """INSERT INTO email_log (resend_id, from_addr, to_addr, subject, email_type)
"from": from_addr or config.EMAIL_FROM, VALUES (?, ?, ?, ?, ?)""",
"to": to, (resend_id, sender, to, subject, email_type),
"subject": subject,
"html": html,
"text": text or html,
}
) )
return True
except Exception as e: except Exception as e:
print(f"[EMAIL] Error sending to {to}: {e}") print(f"[EMAIL] Failed to log email: {e}")
return False
return resend_id
# ============================================================================= # =============================================================================

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

@@ -197,6 +197,7 @@ async def handle_send_email(payload: dict) -> None:
html=payload["html"], html=payload["html"],
text=payload.get("text"), text=payload.get("text"),
from_addr=payload.get("from_addr"), 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), subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)), html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)),
from_addr=EMAIL_ADDRESSES["transactional"], 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), subject=_t("email_quote_verify_subject", lang),
html=_email_wrap(body, lang, preheader=preheader), html=_email_wrap(body, lang, preheader=preheader),
from_addr=EMAIL_ADDRESSES["transactional"], 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), subject=_t("email_welcome_subject", lang),
html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)), html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)),
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="welcome",
) )
@@ -374,6 +378,7 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
subject=subject, subject=subject,
html=_email_wrap(body, lang, preheader=preheader), html=_email_wrap(body, lang, preheader=preheader),
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="waitlist",
) )
@@ -488,6 +493,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
subject=subject, subject=subject,
html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)), html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)),
from_addr=EMAIL_ADDRESSES["leads"], from_addr=EMAIL_ADDRESSES["leads"],
email_type="lead_forward",
) )
# Update email_sent_at on 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), subject=_t("email_lead_matched_subject", lang, first_name=first_name),
html=_email_wrap(body, lang, preheader=_t("email_lead_matched_preheader", lang)), html=_email_wrap(body, lang, preheader=_t("email_lead_matched_preheader", lang)),
from_addr=EMAIL_ADDRESSES["leads"], 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), subject=_t("email_enquiry_subject", lang, contact_name=contact_name),
html=_email_wrap(body, lang, preheader=_t("email_enquiry_preheader", lang)), html=_email_wrap(body, lang, preheader=_t("email_enquiry_preheader", lang)),
from_addr=EMAIL_ADDRESSES["transactional"], 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), subject=_t("email_business_plan_subject", language),
html=_email_wrap(body, language, preheader=_t("email_business_plan_preheader", language)), html=_email_wrap(body, language, preheader=_t("email_business_plan_preheader", language)),
from_addr=EMAIL_ADDRESSES["transactional"], from_addr=EMAIL_ADDRESSES["transactional"],
email_type="business_plan",
) )
print(f"[WORKER] Generated business plan PDF: export_id={export_id}") print(f"[WORKER] Generated business plan PDF: export_id={export_id}")

View File

@@ -829,7 +829,7 @@ class TestResendLive:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bounce_handled_gracefully(self): 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( result = await core.send_email(
to="bounced@resend.dev", to="bounced@resend.dev",
subject="Bounce test", subject="Bounce test",
@@ -838,4 +838,5 @@ class TestResendLive:
) )
# Resend may return success (delivery fails async) or error; # Resend may return success (delivery fails async) or error;
# either way the handler must not crash. # 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)