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

@@ -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
# =============================================================================

View File

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

View File

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

View File

@@ -86,6 +86,24 @@
Templates
</a>
<div class="admin-sidebar__section">Email</div>
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
Sent Log
</a>
<a href="{{ url_for('admin.inbox') }}" class="{% if admin_page == 'inbox' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"/></svg>
Inbox{% if unread_count %} <span class="badge-danger" style="font-size:10px;padding:1px 6px;margin-left:auto;">{{ unread_count }}</span>{% endif %}
</a>
<a href="{{ url_for('admin.email_compose') }}" class="{% if admin_page == 'compose' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
Compose
</a>
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
Audiences
</a>
<div class="admin-sidebar__section">System</div>
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ class Config:
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
]
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
RESEND_WEBHOOK_SECRET: str = os.getenv("RESEND_WEBHOOK_SECRET", "")
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
@@ -346,28 +347,47 @@ def is_plausible_phone(phone: str) -> bool:
async def send_email(
to: str, subject: str, html: str, text: str = None, from_addr: str = None
) -> bool:
"""Send email via Resend SDK."""
to: str, subject: str, html: str, text: str = None,
from_addr: str = None, email_type: str = "ad_hoc",
) -> str | None:
"""Send email via Resend SDK. Returns resend_id on success, None on failure.
Truthy string works like True for existing boolean callers; None is falsy.
"""
sender = from_addr or config.EMAIL_FROM
resend_id = None
if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}")
return True
resend_id = "dev"
else:
resend.api_key = config.RESEND_API_KEY
try:
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
# =============================================================================

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"],
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}")

View File

@@ -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)