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:
@@ -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
|
||||
|
||||
12
CHANGELOG.md
12
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Padelnomics — Project Tracker
|
||||
|
||||
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
||||
> Last updated: 2026-02-22.
|
||||
> Last updated: 2026-02-23.
|
||||
|
||||
---
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
- [x] Comprehensive admin: users, tasks, leads, suppliers, CMS templates, scenarios, articles, feedback
|
||||
- [x] Task queue management (list, retry, delete)
|
||||
- [x] Lead funnel stats on admin dashboard
|
||||
- [x] Email hub (`/admin/emails`) — sent log, inbox, compose, audiences, delivery event tracking via Resend webhooks
|
||||
|
||||
### SEO & Legal
|
||||
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
||||
@@ -193,7 +194,7 @@ _Move here when you start working on it._
|
||||
### Bugs / Tech Debt
|
||||
- [ ] Resend audiences: two segments both using "waitlist-auth" — review audience/segment model and fix duplication
|
||||
- [ ] Transactional emails not all translated to German — some emails still sent in English regardless of user language
|
||||
- [ ] Resend inbound emails enabled — plan how to integrate (webhook routing, reply handling, support inbox?)
|
||||
- [x] ~~Resend inbound emails enabled~~ — integrated: webhook handler + admin inbox with reply (done in email hub)
|
||||
- [ ] Extraction: Playtomic API only returns ~20 venues per bbox — investigate smaller/targeted bboxes
|
||||
|
||||
### Marketing & Content
|
||||
|
||||
@@ -188,6 +188,10 @@ Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state).
|
||||
| Leads | `GET /admin/leads`, `/admin/leads/<id>` | List, filter, view detail, change status, forward to supplier, create |
|
||||
| Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
|
||||
| Feedback | `GET /admin/feedback` | View all submitted feedback |
|
||||
| Email Sent Log | `GET /admin/emails`, `/admin/emails/<id>` | List all outgoing emails (filter by type/event/search), detail with API-enriched HTML preview |
|
||||
| Email Inbox | `GET /admin/emails/inbox`, `/admin/emails/inbox/<id>` | Inbound emails (unread badge), detail with sandboxed HTML, inline reply |
|
||||
| Email Compose | `GET /admin/emails/compose` | Send ad-hoc emails with from-address selection + optional branded wrapping |
|
||||
| Audiences | `GET /admin/emails/audiences`, `/admin/emails/audiences/<id>/contacts` | Resend audiences, contact list, remove contacts |
|
||||
| Article Templates | `GET /admin/templates` | CRUD + bulk generate articles from template+data |
|
||||
| Published Scenarios | `GET /admin/scenarios` | CRUD public scenario cards (shown on landing) |
|
||||
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML |
|
||||
@@ -211,6 +215,7 @@ Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state).
|
||||
| `dashboard` | `/dashboard` | No |
|
||||
| `billing` | `/billing` | No |
|
||||
| `admin` | `/admin` | No |
|
||||
| `webhooks` | `/webhooks` | No |
|
||||
|
||||
**Language detection for non-prefixed blueprints:** Cookie (`lang`) → `Accept-Language` header → fallback `"en"`
|
||||
|
||||
|
||||
@@ -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/<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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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">← 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 %}
|
||||
36
web/src/padelnomics/admin/templates/admin/audiences.html
Normal file
36
web/src/padelnomics/admin/templates/admin/audiences.html
Normal 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 %}
|
||||
@@ -86,6 +86,24 @@
|
||||
Templates
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Email</div>
|
||||
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
Sent Log
|
||||
</a>
|
||||
<a href="{{ url_for('admin.inbox') }}" class="{% if admin_page == 'inbox' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"/></svg>
|
||||
Inbox{% if unread_count %} <span class="badge-danger" style="font-size:10px;padding:1px 6px;margin-left:auto;">{{ unread_count }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('admin.email_compose') }}" class="{% if admin_page == 'compose' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||
Compose
|
||||
</a>
|
||||
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
|
||||
Audiences
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">System</div>
|
||||
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
|
||||
|
||||
52
web/src/padelnomics/admin/templates/admin/email_compose.html
Normal file
52
web/src/padelnomics/admin/templates/admin/email_compose.html
Normal 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">← 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 <br>)" 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 %}
|
||||
73
web/src/padelnomics/admin/templates/admin/email_detail.html
Normal file
73
web/src/padelnomics/admin/templates/admin/email_detail.html
Normal 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">← 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 %}
|
||||
61
web/src/padelnomics/admin/templates/admin/emails.html
Normal file
61
web/src/padelnomics/admin/templates/admin/emails.html
Normal 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
|
||||
· {{ email_stats.sent_today }} today
|
||||
· {{ email_stats.delivered }} delivered
|
||||
{% if email_stats.bounced %}· <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 %}
|
||||
53
web/src/padelnomics/admin/templates/admin/inbox.html
Normal file
53
web/src/padelnomics/admin/templates/admin/inbox.html
Normal 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 %}· <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 %}
|
||||
67
web/src/padelnomics/admin/templates/admin/inbox_detail.html
Normal file
67
web/src/padelnomics/admin/templates/admin/inbox_detail.html
Normal 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">← 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 <hello@notifications.padelnomics.io>">Transactional</option>
|
||||
<option value="Padelnomics Leads <leads@notifications.padelnomics.io>">Leads</option>
|
||||
<option value="Padelnomics <coach@notifications.padelnomics.io>">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 %}
|
||||
@@ -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 %}
|
||||
@@ -305,6 +305,7 @@ def create_app() -> Quart:
|
||||
from .planner.routes import bp as planner_bp
|
||||
from .public.routes import bp as public_bp
|
||||
from .suppliers.routes import bp as suppliers_bp
|
||||
from .webhooks import bp as webhooks_bp
|
||||
|
||||
# Lang-prefixed blueprints (SEO-relevant, public-facing)
|
||||
app.register_blueprint(public_bp, url_prefix="/<lang>")
|
||||
@@ -318,6 +319,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(billing_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(webhooks_bp)
|
||||
|
||||
# Content catch-all LAST — lives under /<lang> too
|
||||
app.register_blueprint(content_bp, url_prefix="/<lang>")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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)")
|
||||
107
web/src/padelnomics/webhooks.py
Normal file
107
web/src/padelnomics/webhooks.py
Normal 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,
|
||||
),
|
||||
)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user