Merge branch 'worktree-sitemap-improvement'
# Conflicts: # web/src/padelnomics/admin/routes.py
This commit is contained in:
@@ -6,6 +6,7 @@ from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import mistune
|
||||
import resend
|
||||
from quart import (
|
||||
Blueprint,
|
||||
Response,
|
||||
@@ -19,7 +20,16 @@ from quart import (
|
||||
)
|
||||
|
||||
from ..auth.routes import role_required
|
||||
from ..core import csrf_protect, execute, fetch_all, fetch_one, slugify
|
||||
from ..core import (
|
||||
EMAIL_ADDRESSES,
|
||||
config,
|
||||
csrf_protect,
|
||||
execute,
|
||||
fetch_all,
|
||||
fetch_one,
|
||||
send_email,
|
||||
slugify,
|
||||
)
|
||||
|
||||
# Blueprint with its own template folder
|
||||
bp = Blueprint(
|
||||
@@ -30,6 +40,24 @@ bp = Blueprint(
|
||||
)
|
||||
|
||||
|
||||
@bp.before_request
|
||||
async def _inject_admin_sidebar_data():
|
||||
"""Load unread inbox count for sidebar badge on every admin page."""
|
||||
from quart import g
|
||||
try:
|
||||
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
|
||||
g.admin_unread_count = row["cnt"] if row else 0
|
||||
except Exception:
|
||||
g.admin_unread_count = 0
|
||||
|
||||
|
||||
@bp.context_processor
|
||||
def _admin_context():
|
||||
"""Expose admin-specific variables to all admin templates."""
|
||||
from quart import g
|
||||
return {"unread_count": getattr(g, "admin_unread_count", 0)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SQL Queries
|
||||
# =============================================================================
|
||||
@@ -825,6 +853,344 @@ async def feedback():
|
||||
return await render_template("admin/feedback.html", feedback_list=feedback_list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Email Hub
|
||||
# =============================================================================
|
||||
|
||||
EMAIL_TYPES = [
|
||||
"ad_hoc", "magic_link", "welcome", "quote_verification", "waitlist",
|
||||
"lead_forward", "lead_matched", "supplier_enquiry", "business_plan",
|
||||
"generic", "admin_compose", "admin_reply",
|
||||
]
|
||||
|
||||
EVENT_TYPES = ["sent", "delivered", "opened", "clicked", "bounced", "complained"]
|
||||
|
||||
|
||||
async def get_email_log(
|
||||
email_type: str = None, last_event: str = None, search: str = None,
|
||||
page: int = 1, per_page: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Get email log with optional filters."""
|
||||
wheres = ["1=1"]
|
||||
params: list = []
|
||||
|
||||
if email_type:
|
||||
wheres.append("email_type = ?")
|
||||
params.append(email_type)
|
||||
if last_event:
|
||||
wheres.append("last_event = ?")
|
||||
params.append(last_event)
|
||||
if search:
|
||||
wheres.append("(to_addr LIKE ? OR subject LIKE ?)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
where = " AND ".join(wheres)
|
||||
offset = (page - 1) * per_page
|
||||
params.extend([per_page, offset])
|
||||
|
||||
return await fetch_all(
|
||||
f"""SELECT * FROM email_log WHERE {where}
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
|
||||
async def get_email_stats() -> dict:
|
||||
"""Aggregate email stats for the list header."""
|
||||
total = await fetch_one("SELECT COUNT(*) as cnt FROM email_log")
|
||||
delivered = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'delivered'")
|
||||
bounced = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'bounced'")
|
||||
today = datetime.utcnow().date().isoformat()
|
||||
sent_today = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE created_at >= ?", (today,))
|
||||
return {
|
||||
"total": total["cnt"] if total else 0,
|
||||
"delivered": delivered["cnt"] if delivered else 0,
|
||||
"bounced": bounced["cnt"] if bounced else 0,
|
||||
"sent_today": sent_today["cnt"] if sent_today else 0,
|
||||
}
|
||||
|
||||
|
||||
async def get_unread_count() -> int:
|
||||
"""Count unread inbound emails."""
|
||||
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
@bp.route("/emails")
|
||||
@role_required("admin")
|
||||
async def emails():
|
||||
"""Sent email log."""
|
||||
email_type = request.args.get("email_type", "")
|
||||
last_event = request.args.get("last_event", "")
|
||||
search = request.args.get("search", "").strip()
|
||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||
|
||||
log = await get_email_log(
|
||||
email_type=email_type or None, last_event=last_event or None,
|
||||
search=search or None, page=page,
|
||||
)
|
||||
stats = await get_email_stats()
|
||||
unread = await get_unread_count()
|
||||
|
||||
return await render_template(
|
||||
"admin/emails.html",
|
||||
emails=log,
|
||||
email_stats=stats,
|
||||
email_types=EMAIL_TYPES,
|
||||
event_types=EVENT_TYPES,
|
||||
current_type=email_type,
|
||||
current_event=last_event,
|
||||
current_search=search,
|
||||
page=page,
|
||||
unread_count=unread,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/emails/results")
|
||||
@role_required("admin")
|
||||
async def email_results():
|
||||
"""HTMX partial for filtered email log."""
|
||||
email_type = request.args.get("email_type", "")
|
||||
last_event = request.args.get("last_event", "")
|
||||
search = request.args.get("search", "").strip()
|
||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||
|
||||
log = await get_email_log(
|
||||
email_type=email_type or None, last_event=last_event or None,
|
||||
search=search or None, page=page,
|
||||
)
|
||||
return await render_template("admin/partials/email_results.html", emails=log)
|
||||
|
||||
|
||||
@bp.route("/emails/<int:email_id>")
|
||||
@role_required("admin")
|
||||
async def email_detail(email_id: int):
|
||||
"""Email detail — enriches with Resend API for HTML body."""
|
||||
email = await fetch_one("SELECT * FROM email_log WHERE id = ?", (email_id,))
|
||||
if not email:
|
||||
await flash("Email not found.", "error")
|
||||
return redirect(url_for("admin.emails"))
|
||||
|
||||
# Try to fetch full email from Resend API (5s timeout)
|
||||
enriched_html = None
|
||||
if email["resend_id"] and email["resend_id"] != "dev" and config.RESEND_API_KEY:
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
try:
|
||||
result = resend.Emails.get(email["resend_id"])
|
||||
if isinstance(result, dict):
|
||||
enriched_html = result.get("html", "")
|
||||
else:
|
||||
enriched_html = getattr(result, "html", "")
|
||||
except Exception:
|
||||
pass # Metadata-only fallback
|
||||
|
||||
return await render_template(
|
||||
"admin/email_detail.html",
|
||||
email=email,
|
||||
enriched_html=enriched_html,
|
||||
)
|
||||
|
||||
|
||||
# --- Inbox ---
|
||||
|
||||
@bp.route("/emails/inbox")
|
||||
@role_required("admin")
|
||||
async def inbox():
|
||||
"""Inbound email list."""
|
||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||
per_page = 50
|
||||
offset = (page - 1) * per_page
|
||||
unread = await get_unread_count()
|
||||
|
||||
messages = await fetch_all(
|
||||
"SELECT * FROM inbound_emails ORDER BY received_at DESC LIMIT ? OFFSET ?",
|
||||
(per_page, offset),
|
||||
)
|
||||
return await render_template(
|
||||
"admin/inbox.html", messages=messages, unread_count=unread, page=page,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/emails/inbox/<int:msg_id>")
|
||||
@role_required("admin")
|
||||
async def inbox_detail(msg_id: int):
|
||||
"""Inbound email detail — marks as read."""
|
||||
msg = await fetch_one("SELECT * FROM inbound_emails WHERE id = ?", (msg_id,))
|
||||
if not msg:
|
||||
await flash("Message not found.", "error")
|
||||
return redirect(url_for("admin.inbox"))
|
||||
|
||||
if not msg["is_read"]:
|
||||
await execute("UPDATE inbound_emails SET is_read = 1 WHERE id = ?", (msg_id,))
|
||||
|
||||
return await render_template("admin/inbox_detail.html", msg=msg)
|
||||
|
||||
|
||||
@bp.route("/emails/inbox/<int:msg_id>/reply", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def inbox_reply(msg_id: int):
|
||||
"""Reply to an inbound email."""
|
||||
msg = await fetch_one("SELECT * FROM inbound_emails WHERE id = ?", (msg_id,))
|
||||
if not msg:
|
||||
await flash("Message not found.", "error")
|
||||
return redirect(url_for("admin.inbox"))
|
||||
|
||||
form = await request.form
|
||||
body = form.get("body", "").strip()
|
||||
from_addr = form.get("from_addr", "") or EMAIL_ADDRESSES["transactional"]
|
||||
|
||||
if not body:
|
||||
await flash("Reply body is required.", "error")
|
||||
return redirect(url_for("admin.inbox_detail", msg_id=msg_id))
|
||||
|
||||
subject = msg["subject"] or ""
|
||||
if not subject.lower().startswith("re:"):
|
||||
subject = f"Re: {subject}"
|
||||
|
||||
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
|
||||
result = await send_email(
|
||||
to=msg["from_addr"],
|
||||
subject=subject,
|
||||
html=html,
|
||||
from_addr=from_addr,
|
||||
email_type="admin_reply",
|
||||
)
|
||||
if result:
|
||||
await flash("Reply sent.", "success")
|
||||
else:
|
||||
await flash("Failed to send reply.", "error")
|
||||
|
||||
return redirect(url_for("admin.inbox_detail", msg_id=msg_id))
|
||||
|
||||
|
||||
# --- Compose ---
|
||||
|
||||
@bp.route("/emails/compose", methods=["GET", "POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def email_compose():
|
||||
"""Compose and send an ad-hoc email."""
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
to = form.get("to", "").strip()
|
||||
subject = form.get("subject", "").strip()
|
||||
body = form.get("body", "").strip()
|
||||
from_addr = form.get("from_addr", "") or EMAIL_ADDRESSES["transactional"]
|
||||
wrap = form.get("wrap", "") == "1"
|
||||
|
||||
if not to or not subject or not body:
|
||||
await flash("To, subject, and body are required.", "error")
|
||||
return await render_template(
|
||||
"admin/email_compose.html",
|
||||
data={"to": to, "subject": subject, "body": body, "from_addr": from_addr},
|
||||
email_addresses=EMAIL_ADDRESSES,
|
||||
)
|
||||
|
||||
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
|
||||
if wrap:
|
||||
from ..worker import _email_wrap
|
||||
html = _email_wrap(html)
|
||||
|
||||
result = await send_email(
|
||||
to=to, subject=subject, html=html,
|
||||
from_addr=from_addr, email_type="admin_compose",
|
||||
)
|
||||
if result:
|
||||
await flash(f"Email sent to {to}.", "success")
|
||||
return redirect(url_for("admin.emails"))
|
||||
else:
|
||||
await flash("Failed to send email.", "error")
|
||||
return await render_template(
|
||||
"admin/email_compose.html",
|
||||
data={"to": to, "subject": subject, "body": body, "from_addr": from_addr},
|
||||
email_addresses=EMAIL_ADDRESSES,
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"admin/email_compose.html", data={}, email_addresses=EMAIL_ADDRESSES,
|
||||
)
|
||||
|
||||
|
||||
# --- Audiences ---
|
||||
|
||||
@bp.route("/emails/audiences")
|
||||
@role_required("admin")
|
||||
async def audiences():
|
||||
"""List Resend audiences with local cache + API contact counts."""
|
||||
audience_list = await fetch_all("SELECT * FROM resend_audiences ORDER BY name")
|
||||
|
||||
# Enrich with contact count from API (best-effort)
|
||||
for a in audience_list:
|
||||
a["contact_count"] = None
|
||||
if config.RESEND_API_KEY and a.get("audience_id"):
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
try:
|
||||
contacts = resend.Contacts.list(a["audience_id"])
|
||||
if isinstance(contacts, dict):
|
||||
a["contact_count"] = len(contacts.get("data", []))
|
||||
elif isinstance(contacts, list):
|
||||
a["contact_count"] = len(contacts)
|
||||
else:
|
||||
data = getattr(contacts, "data", [])
|
||||
a["contact_count"] = len(data) if data else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await render_template("admin/audiences.html", audiences=audience_list)
|
||||
|
||||
|
||||
@bp.route("/emails/audiences/<audience_id>/contacts")
|
||||
@role_required("admin")
|
||||
async def audience_contacts(audience_id: str):
|
||||
"""List contacts in a Resend audience."""
|
||||
audience = await fetch_one("SELECT * FROM resend_audiences WHERE audience_id = ?", (audience_id,))
|
||||
if not audience:
|
||||
await flash("Audience not found.", "error")
|
||||
return redirect(url_for("admin.audiences"))
|
||||
|
||||
contacts = []
|
||||
if config.RESEND_API_KEY:
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
try:
|
||||
result = resend.Contacts.list(audience_id)
|
||||
if isinstance(result, dict):
|
||||
contacts = result.get("data", [])
|
||||
elif isinstance(result, list):
|
||||
contacts = result
|
||||
else:
|
||||
contacts = getattr(result, "data", []) or []
|
||||
except Exception:
|
||||
await flash("Failed to fetch contacts from Resend.", "error")
|
||||
|
||||
return await render_template(
|
||||
"admin/audience_contacts.html", audience=audience, contacts=contacts,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/emails/audiences/<audience_id>/contacts/remove", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def audience_contact_remove(audience_id: str):
|
||||
"""Remove a contact from a Resend audience."""
|
||||
form = await request.form
|
||||
contact_id = form.get("contact_id", "")
|
||||
|
||||
if not contact_id:
|
||||
await flash("No contact specified.", "error")
|
||||
return redirect(url_for("admin.audience_contacts", audience_id=audience_id))
|
||||
|
||||
if config.RESEND_API_KEY:
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
try:
|
||||
resend.Contacts.remove(audience_id, contact_id)
|
||||
await flash("Contact removed.", "success")
|
||||
except Exception as e:
|
||||
await flash(f"Failed to remove contact: {e}", "error")
|
||||
|
||||
return redirect(url_for("admin.audience_contacts", audience_id=audience_id))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Content Templates (read-only — templates live in git as .md.jinja files)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -1539,44 +1539,60 @@
|
||||
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Sch\u00e4tzungen und stellen keine Finanzberatung dar. Die tats\u00e4chlichen Ergebnisse k\u00f6nnen je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. \u00a9 Padelnomics \u2014 padelnomics.io",
|
||||
|
||||
"email_magic_link_heading": "Bei {app_name} anmelden",
|
||||
"email_magic_link_body": "Klicke auf den Button unten, um dich anzumelden. Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab.",
|
||||
"email_magic_link_btn": "Anmelden",
|
||||
"email_magic_link_body": "Hier ist dein Anmeldelink. Er l\u00e4uft in {expiry_minutes} Minuten ab.",
|
||||
"email_magic_link_btn": "Anmelden \u2192",
|
||||
"email_magic_link_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
|
||||
"email_magic_link_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
|
||||
"email_magic_link_subject": "Bei {app_name} anmelden",
|
||||
"email_magic_link_subject": "Dein Anmeldelink f\u00fcr {app_name}",
|
||||
"email_magic_link_preheader": "Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab",
|
||||
|
||||
"email_quote_verify_heading": "Best\u00e4tige deine E-Mail f\u00fcr Anbieter-Angebote",
|
||||
"email_quote_verify_heading": "Best\u00e4tige deine E-Mail f\u00fcr Angebote",
|
||||
"email_quote_verify_greeting": "Hallo {first_name},",
|
||||
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage{project_desc}. Klicke auf den Button unten, um deine E-Mail zu best\u00e4tigen und deine Anfrage zu aktivieren. Dabei wird auch dein {app_name}-Konto erstellt, damit du dein Projekt verfolgen kannst.",
|
||||
"email_quote_verify_btn": "Best\u00e4tigen & Angebot aktivieren",
|
||||
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage. Best\u00e4tige deine E-Mail, um deine Anfrage zu aktivieren und dein {app_name}-Konto zu erstellen.",
|
||||
"email_quote_verify_project_label": "Dein Projekt:",
|
||||
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt behandelt.",
|
||||
"email_quote_verify_btn": "Best\u00e4tigen & Aktivieren \u2192",
|
||||
"email_quote_verify_expires": "Dieser Link l\u00e4uft in 60 Minuten ab.",
|
||||
"email_quote_verify_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
|
||||
"email_quote_verify_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
|
||||
"email_quote_verify_subject": "Best\u00e4tige deine E-Mail f\u00fcr Anbieter-Angebote",
|
||||
"email_quote_verify_subject": "Best\u00e4tige deine E-Mail \u2014 Anbieter sind bereit f\u00fcr Angebote",
|
||||
"email_quote_verify_preheader": "Ein Klick, um deine Angebotsanfrage zu aktivieren",
|
||||
"email_quote_verify_preheader_courts": "Ein Klick, um dein {court_count}-Court-Projekt zu aktivieren",
|
||||
|
||||
"email_welcome_heading": "Willkommen bei {app_name}!",
|
||||
"email_welcome_body": "Danke f\u00fcr deine Anmeldung. Du kannst jetzt mit der Planung deines Padel-Gesch\u00e4fts loslegen.",
|
||||
"email_welcome_btn": "Zum Dashboard",
|
||||
"email_welcome_subject": "Willkommen bei {app_name}",
|
||||
"email_welcome_heading": "Willkommen bei {app_name}",
|
||||
"email_welcome_greeting": "Hallo {first_name},",
|
||||
"email_welcome_body": "Du hast jetzt Zugang zum Finanzplaner, Marktdaten und dem Anbieterverzeichnis \u2014 alles, was du f\u00fcr die Planung deines Padel-Gesch\u00e4fts brauchst.",
|
||||
"email_welcome_quickstart_heading": "Schnellstart:",
|
||||
"email_welcome_link_planner": "Finanzplaner \u2014 modelliere deine Investition",
|
||||
"email_welcome_link_markets": "Marktdaten \u2014 erkunde die Padel-Nachfrage nach Stadt",
|
||||
"email_welcome_link_quotes": "Angebote einholen \u2014 verbinde dich mit verifizierten Anbietern",
|
||||
"email_welcome_btn": "Jetzt planen \u2192",
|
||||
"email_welcome_subject": "Du bist dabei \u2014 so f\u00e4ngst du an",
|
||||
"email_welcome_preheader": "Dein Padel-Planungstoolkit ist bereit",
|
||||
|
||||
"email_waitlist_supplier_heading": "Du stehst auf der Anbieter-Warteliste",
|
||||
"email_waitlist_supplier_body": "Danke f\u00fcr dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen die ultimative Anbieter-Plattform f\u00fcr Padel-Unternehmer.",
|
||||
"email_waitlist_supplier_perks": "Du erf\u00e4hrst als Erster, wenn wir starten. Wir senden dir fr\u00fchen Zugang, exklusive Launch-Preise und Onboarding-Unterst\u00fctzung.",
|
||||
"email_waitlist_supplier_body": "Danke f\u00fcr dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen eine Plattform, die dich mit qualifizierten Leads von Padel-Unternehmern verbindet, die aktiv Projekte planen.",
|
||||
"email_waitlist_supplier_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
|
||||
"email_waitlist_supplier_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
|
||||
"email_waitlist_supplier_perk_2": "Exklusive Launch-Preise (gesichert)",
|
||||
"email_waitlist_supplier_perk_3": "Pers\u00f6nliches Onboarding-Gespr\u00e4ch",
|
||||
"email_waitlist_supplier_meanwhile": "In der Zwischenzeit erkunde unsere kostenlosen Ressourcen:",
|
||||
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
|
||||
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis \u2014 verifizierte Anbieter durchsuchen",
|
||||
"email_waitlist_supplier_subject": "Du stehst auf der Liste \u2014 {app_name} {plan_name} startet bald",
|
||||
"email_waitlist_supplier_subject": "Du bist dabei \u2014 {plan_name} fr\u00fcher Zugang kommt",
|
||||
"email_waitlist_supplier_preheader": "Exklusive Launch-Preise + bevorzugtes Onboarding",
|
||||
"email_waitlist_general_heading": "Du stehst auf der Warteliste",
|
||||
"email_waitlist_general_body": "Danke, dass du dich auf die Warteliste eingetragen hast. Wir bereiten den Start der ultimativen Planungsplattform f\u00fcr Padel-Unternehmer vor.",
|
||||
"email_waitlist_general_perks_intro": "Du bist unter den Ersten, die Zugang erhalten. Wir senden dir:",
|
||||
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang zur gesamten Plattform",
|
||||
"email_waitlist_general_perk_2": "Exklusive Launch-Boni",
|
||||
"email_waitlist_general_body": "Danke f\u00fcr deine Anmeldung. Wir bauen die Planungsplattform f\u00fcr Padel-Unternehmer \u2014 Finanzmodellierung, Marktdaten und Anbietervernetzung an einem Ort.",
|
||||
"email_waitlist_general_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
|
||||
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
|
||||
"email_waitlist_general_perk_2": "Exklusive Launch-Preise",
|
||||
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
|
||||
"email_waitlist_general_outro": "Wir melden uns bald.",
|
||||
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 {app_name} startet bald",
|
||||
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 wir benachrichtigen dich zum Launch",
|
||||
"email_waitlist_general_preheader": "Fr\u00fcher Zugang + exklusive Launch-Preise",
|
||||
|
||||
"email_lead_forward_heading": "Neues Projekt-Lead",
|
||||
"email_lead_forward_subheading": "Ein neues Padel-Projekt passt zu deinen Leistungen.",
|
||||
"email_lead_forward_urgency": "Dieses Lead wurde gerade freigeschaltet. Anbieter, die innerhalb von 24 Stunden antworten, gewinnen 3x h\u00e4ufiger das Projekt.",
|
||||
"email_lead_forward_section_brief": "Projektbeschreibung",
|
||||
"email_lead_forward_section_contact": "Kontakt",
|
||||
"email_lead_forward_lbl_facility": "Anlage",
|
||||
@@ -1591,27 +1607,38 @@
|
||||
"email_lead_forward_lbl_phone": "Telefon",
|
||||
"email_lead_forward_lbl_company": "Unternehmen",
|
||||
"email_lead_forward_lbl_role": "Rolle",
|
||||
"email_lead_forward_btn": "Im Lead-Feed ansehen",
|
||||
"email_lead_forward_btn": "Im Lead-Feed ansehen \u2192",
|
||||
"email_lead_forward_reply_direct": "oder <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">direkt an {contact_email} antworten</a>",
|
||||
"email_lead_forward_preheader_suffix": "Kontaktdaten enthalten",
|
||||
|
||||
"email_lead_matched_heading": "Ein Anbieter pr\u00fcft dein Projekt",
|
||||
"email_lead_matched_heading": "Ein Anbieter m\u00f6chte dein Projekt besprechen",
|
||||
"email_lead_matched_greeting": "Hallo {first_name},",
|
||||
"email_lead_matched_body": "Gute Nachrichten \u2014 ein verifizierter Anbieter wurde mit deinem Padel-Projekt abgeglichen. Er hat deine Projektbeschreibung und wird sich direkt bei dir melden.",
|
||||
"email_lead_matched_body": "Gute Nachrichten \u2014 ein verifizierter Anbieter wurde mit deinem Padel-Projekt abgeglichen. Er hat deine Projektbeschreibung und Kontaktdaten.",
|
||||
"email_lead_matched_context": "Du hast eine Angebotsanfrage f\u00fcr eine {facility_type}-Anlage mit {court_count} Pl\u00e4tzen in {country} eingereicht.",
|
||||
"email_lead_matched_btn": "Zum Dashboard",
|
||||
"email_lead_matched_next_heading": "Was passiert als N\u00e4chstes",
|
||||
"email_lead_matched_next_body": "Der Anbieter hat deine Projektbeschreibung und Kontaktdaten erhalten. Die meisten Anbieter melden sich innerhalb von 24\u201348 Stunden per E-Mail oder Telefon.",
|
||||
"email_lead_matched_tip": "Tipp: Schnelles Reagieren auf Anbieter-Kontaktaufnahmen erh\u00f6ht deine Chance auf wettbewerbsf\u00e4hige Angebote.",
|
||||
"email_lead_matched_btn": "Zum Dashboard \u2192",
|
||||
"email_lead_matched_note": "Du erh\u00e4ltst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter deine Projektdetails freischaltet.",
|
||||
"email_lead_matched_subject": "Ein Anbieter pr\u00fcft dein Padel-Projekt",
|
||||
"email_lead_matched_subject": "{first_name}, ein Anbieter m\u00f6chte dein Projekt besprechen",
|
||||
"email_lead_matched_preheader": "Der Anbieter wird sich direkt bei dir melden \u2014 das erwartet dich",
|
||||
|
||||
"email_enquiry_heading": "Neue Anfrage \u00fcber {app_name}",
|
||||
"email_enquiry_body": "Du hast eine neue Verzeichnisanfrage f\u00fcr <strong>{supplier_name}</strong>.",
|
||||
"email_enquiry_heading": "Neue Anfrage von {contact_name}",
|
||||
"email_enquiry_body": "Du hast eine neue Anfrage \u00fcber deinen <strong>{supplier_name}</strong>-Verzeichniseintrag.",
|
||||
"email_enquiry_lbl_from": "Von",
|
||||
"email_enquiry_lbl_message": "Nachricht",
|
||||
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\">{contact_email}</a>.",
|
||||
"email_enquiry_subject": "Neue Anfrage \u00fcber {app_name}: {contact_name}",
|
||||
"email_enquiry_respond_fast": "Antworte innerhalb von 24 Stunden f\u00fcr den besten Eindruck.",
|
||||
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a>.",
|
||||
"email_enquiry_subject": "Neue Anfrage von {contact_name} \u00fcber deinen Verzeichniseintrag",
|
||||
"email_enquiry_preheader": "Antworte, um mit diesem potenziellen Kunden in Kontakt zu treten",
|
||||
|
||||
"email_business_plan_heading": "Dein Businessplan ist fertig",
|
||||
"email_business_plan_body": "Dein Padel-Businessplan wurde als PDF erstellt und steht zum Download bereit.",
|
||||
"email_business_plan_btn": "PDF herunterladen",
|
||||
"email_business_plan_subject": "Dein Padel-Businessplan ist fertig",
|
||||
"email_business_plan_includes": "Dein Plan enth\u00e4lt Investitions\u00fcbersicht, Umsatzprognosen und Break-Even-Analyse.",
|
||||
"email_business_plan_btn": "PDF herunterladen \u2192",
|
||||
"email_business_plan_quote_cta": "Bereit f\u00fcr den n\u00e4chsten Schritt? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Angebote von Anbietern einholen \u2192</a>",
|
||||
"email_business_plan_subject": "Dein Businessplan-PDF steht zum Download bereit",
|
||||
"email_business_plan_preheader": "Professioneller Padel-Finanzplan \u2014 jetzt herunterladen",
|
||||
|
||||
"email_footer_tagline": "Die Planungsplattform f\u00fcr Padel-Unternehmer",
|
||||
"email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast."
|
||||
|
||||
@@ -1539,44 +1539,60 @@
|
||||
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io",
|
||||
|
||||
"email_magic_link_heading": "Sign in to {app_name}",
|
||||
"email_magic_link_body": "Click the button below to sign in. This link expires in {expiry_minutes} minutes.",
|
||||
"email_magic_link_btn": "Sign In",
|
||||
"email_magic_link_body": "Here's your sign-in link. It expires in {expiry_minutes} minutes.",
|
||||
"email_magic_link_btn": "Sign In \u2192",
|
||||
"email_magic_link_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
|
||||
"email_magic_link_ignore": "If you didn't request this, you can safely ignore this email.",
|
||||
"email_magic_link_subject": "Sign in to {app_name}",
|
||||
"email_magic_link_subject": "Your sign-in link for {app_name}",
|
||||
"email_magic_link_preheader": "This link expires in {expiry_minutes} minutes",
|
||||
|
||||
"email_quote_verify_heading": "Verify your email to get supplier quotes",
|
||||
"email_quote_verify_heading": "Verify your email to get quotes",
|
||||
"email_quote_verify_greeting": "Hi {first_name},",
|
||||
"email_quote_verify_body": "Thanks for requesting quotes{project_desc}. Click the button below to verify your email and activate your quote request. This will also create your {app_name} account so you can track your project.",
|
||||
"email_quote_verify_btn": "Verify & Activate Quote",
|
||||
"email_quote_verify_body": "Thanks for requesting quotes. Verify your email to activate your quote request and create your {app_name} account.",
|
||||
"email_quote_verify_project_label": "Your project:",
|
||||
"email_quote_verify_urgency": "Verified requests get prioritized by our supplier network.",
|
||||
"email_quote_verify_btn": "Verify & Activate \u2192",
|
||||
"email_quote_verify_expires": "This link expires in 60 minutes.",
|
||||
"email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
|
||||
"email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.",
|
||||
"email_quote_verify_subject": "Verify your email to get supplier quotes",
|
||||
"email_quote_verify_subject": "Verify your email \u2014 suppliers are ready to quote",
|
||||
"email_quote_verify_preheader": "One click to activate your quote request",
|
||||
"email_quote_verify_preheader_courts": "One click to activate your {court_count}-court project",
|
||||
|
||||
"email_welcome_heading": "Welcome to {app_name}!",
|
||||
"email_welcome_body": "Thanks for signing up. You're all set to start planning your padel business.",
|
||||
"email_welcome_btn": "Go to Dashboard",
|
||||
"email_welcome_subject": "Welcome to {app_name}",
|
||||
"email_welcome_heading": "Welcome to {app_name}",
|
||||
"email_welcome_greeting": "Hi {first_name},",
|
||||
"email_welcome_body": "You now have access to the financial planner, market data, and supplier directory \u2014 everything you need to plan your padel business.",
|
||||
"email_welcome_quickstart_heading": "Quick start:",
|
||||
"email_welcome_link_planner": "Financial Planner \u2014 model your investment",
|
||||
"email_welcome_link_markets": "Market Data \u2014 explore padel demand by city",
|
||||
"email_welcome_link_quotes": "Get Quotes \u2014 connect with verified suppliers",
|
||||
"email_welcome_btn": "Start Planning \u2192",
|
||||
"email_welcome_subject": "You're in \u2014 here's how to start planning",
|
||||
"email_welcome_preheader": "Your padel business planning toolkit is ready",
|
||||
|
||||
"email_waitlist_supplier_heading": "You're on the Supplier Waitlist",
|
||||
"email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building the ultimate supplier platform for padel entrepreneurs.",
|
||||
"email_waitlist_supplier_perks": "You'll be among the first to know when we launch. We'll send you early access, exclusive launch pricing, and onboarding support.",
|
||||
"email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building a platform to connect you with qualified leads from padel entrepreneurs actively planning projects.",
|
||||
"email_waitlist_supplier_perks_intro": "As an early waitlist member, you'll get:",
|
||||
"email_waitlist_supplier_perk_1": "Early access before public launch",
|
||||
"email_waitlist_supplier_perk_2": "Exclusive launch pricing (locked in)",
|
||||
"email_waitlist_supplier_perk_3": "Dedicated onboarding call",
|
||||
"email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:",
|
||||
"email_waitlist_supplier_link_planner": "Financial Planning Tool \u2014 model your padel facility",
|
||||
"email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers",
|
||||
"email_waitlist_supplier_subject": "You're on the list \u2014 {app_name} {plan_name} is launching soon",
|
||||
"email_waitlist_supplier_subject": "You're in \u2014 {plan_name} early access is coming",
|
||||
"email_waitlist_supplier_preheader": "Exclusive launch pricing + priority onboarding",
|
||||
"email_waitlist_general_heading": "You're on the Waitlist",
|
||||
"email_waitlist_general_body": "Thanks for joining the waitlist. We're preparing to launch the ultimate planning platform for padel entrepreneurs.",
|
||||
"email_waitlist_general_perks_intro": "You'll be among the first to get access when we open. We'll send you:",
|
||||
"email_waitlist_general_perk_1": "Early access to the full platform",
|
||||
"email_waitlist_general_perk_2": "Exclusive launch bonuses",
|
||||
"email_waitlist_general_body": "Thanks for joining. We're building the planning platform for padel entrepreneurs \u2014 financial modelling, market data, and supplier connections in one place.",
|
||||
"email_waitlist_general_perks_intro": "As an early waitlist member, you'll get:",
|
||||
"email_waitlist_general_perk_1": "Early access before public launch",
|
||||
"email_waitlist_general_perk_2": "Exclusive launch pricing",
|
||||
"email_waitlist_general_perk_3": "Priority onboarding and support",
|
||||
"email_waitlist_general_outro": "We'll be in touch soon.",
|
||||
"email_waitlist_general_subject": "You're on the list \u2014 {app_name} is launching soon",
|
||||
"email_waitlist_general_subject": "You're on the list \u2014 we'll notify you at launch",
|
||||
"email_waitlist_general_preheader": "Early access + exclusive launch pricing",
|
||||
|
||||
"email_lead_forward_heading": "New Project Lead",
|
||||
"email_lead_forward_subheading": "A new padel project matches your services.",
|
||||
"email_lead_forward_urgency": "This lead was just unlocked. Suppliers who respond within 24 hours are 3x more likely to win the project.",
|
||||
"email_lead_forward_section_brief": "Project Brief",
|
||||
"email_lead_forward_section_contact": "Contact",
|
||||
"email_lead_forward_lbl_facility": "Facility",
|
||||
@@ -1591,27 +1607,38 @@
|
||||
"email_lead_forward_lbl_phone": "Phone",
|
||||
"email_lead_forward_lbl_company": "Company",
|
||||
"email_lead_forward_lbl_role": "Role",
|
||||
"email_lead_forward_btn": "View in Lead Feed",
|
||||
"email_lead_forward_btn": "View in Lead Feed \u2192",
|
||||
"email_lead_forward_reply_direct": "or <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">reply directly to {contact_email}</a>",
|
||||
"email_lead_forward_preheader_suffix": "contact details inside",
|
||||
|
||||
"email_lead_matched_heading": "A supplier is reviewing your project",
|
||||
"email_lead_matched_heading": "A supplier wants to discuss your project",
|
||||
"email_lead_matched_greeting": "Hi {first_name},",
|
||||
"email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and will reach out to you directly.",
|
||||
"email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and contact details.",
|
||||
"email_lead_matched_context": "You submitted a quote request for a {facility_type} facility with {court_count} courts in {country}.",
|
||||
"email_lead_matched_btn": "View Your Dashboard",
|
||||
"email_lead_matched_next_heading": "What happens next",
|
||||
"email_lead_matched_next_body": "The supplier has received your project brief and contact details. Most suppliers respond within 24\u201348 hours via email or phone.",
|
||||
"email_lead_matched_tip": "Tip: Responding quickly to supplier outreach increases your chance of getting competitive quotes.",
|
||||
"email_lead_matched_btn": "View Your Dashboard \u2192",
|
||||
"email_lead_matched_note": "You'll receive this notification each time a new supplier unlocks your project details.",
|
||||
"email_lead_matched_subject": "A supplier is reviewing your padel project",
|
||||
"email_lead_matched_subject": "{first_name}, a supplier wants to discuss your project",
|
||||
"email_lead_matched_preheader": "They'll reach out to you directly \u2014 here's what to expect",
|
||||
|
||||
"email_enquiry_heading": "New enquiry via {app_name}",
|
||||
"email_enquiry_body": "You have a new directory enquiry for <strong>{supplier_name}</strong>.",
|
||||
"email_enquiry_heading": "New enquiry from {contact_name}",
|
||||
"email_enquiry_body": "You have a new enquiry via your <strong>{supplier_name}</strong> directory listing.",
|
||||
"email_enquiry_lbl_from": "From",
|
||||
"email_enquiry_lbl_message": "Message",
|
||||
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\">{contact_email}</a> to respond.",
|
||||
"email_enquiry_subject": "New enquiry via {app_name}: {contact_name}",
|
||||
"email_enquiry_respond_fast": "Respond within 24 hours for the best impression.",
|
||||
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a> to connect.",
|
||||
"email_enquiry_subject": "New enquiry from {contact_name} via your directory listing",
|
||||
"email_enquiry_preheader": "Reply to connect with this potential client",
|
||||
|
||||
"email_business_plan_heading": "Your Business Plan is Ready",
|
||||
"email_business_plan_heading": "Your business plan is ready",
|
||||
"email_business_plan_body": "Your padel business plan PDF has been generated and is ready for download.",
|
||||
"email_business_plan_btn": "Download PDF",
|
||||
"email_business_plan_subject": "Your Padel Business Plan PDF is Ready",
|
||||
"email_business_plan_includes": "Your plan includes investment breakdown, revenue projections, and break-even analysis.",
|
||||
"email_business_plan_btn": "Download PDF \u2192",
|
||||
"email_business_plan_quote_cta": "Ready for the next step? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Get quotes from suppliers \u2192</a>",
|
||||
"email_business_plan_subject": "Your business plan PDF is ready to download",
|
||||
"email_business_plan_preheader": "Professional padel facility financial plan \u2014 download now",
|
||||
|
||||
"email_footer_tagline": "The padel business planning platform",
|
||||
"email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request."
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -24,11 +24,23 @@ def _t(key: str, lang: str = "en", **kwargs) -> str:
|
||||
return raw.format(**kwargs) if kwargs else raw
|
||||
|
||||
|
||||
def _email_wrap(body: str, lang: str = "en") -> str:
|
||||
"""Wrap email body in a branded layout with inline CSS."""
|
||||
def _email_wrap(body: str, lang: str = "en", preheader: str = "") -> str:
|
||||
"""Wrap email body in a branded layout with inline CSS.
|
||||
|
||||
preheader: hidden preview text shown in email client list views.
|
||||
"""
|
||||
year = datetime.utcnow().year
|
||||
tagline = _t("email_footer_tagline", lang)
|
||||
copyright_text = _t("email_footer_copyright", lang, year=year, app_name=config.APP_NAME)
|
||||
# Hidden preheader trick: visible text + invisible padding to prevent
|
||||
# email clients from pulling body text into the preview.
|
||||
preheader_html = ""
|
||||
if preheader:
|
||||
preheader_html = (
|
||||
f'<span style="display:none;font-size:1px;color:#F1F5F9;line-height:1px;'
|
||||
f'max-height:0;max-width:0;opacity:0;overflow:hidden;">'
|
||||
f'{preheader}{"͏ ‌ " * 30}</span>'
|
||||
)
|
||||
return f"""\
|
||||
<!DOCTYPE html>
|
||||
<html lang="{lang}">
|
||||
@@ -38,23 +50,19 @@ def _email_wrap(body: str, lang: str = "en") -> str:
|
||||
<title>{config.APP_NAME}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#F1F5F9;font-family:Helvetica,Arial,sans-serif;">
|
||||
{preheader_html}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F1F5F9;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
<table width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background-color:#FFFFFF;border-radius:10px;border:1px solid #E2E8F0;overflow:hidden;">
|
||||
|
||||
<!-- Logo header -->
|
||||
<tr><td style="background-color:#0F172A;padding:28px 36px 24px;">
|
||||
<table cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="vertical-align:middle;">
|
||||
<!-- Padel racket monogram -->
|
||||
<span style="display:inline-block;width:32px;height:32px;background-color:#1D4ED8;border-radius:6px;text-align:center;line-height:32px;font-size:17px;font-weight:800;color:#fff;font-family:Helvetica,Arial,sans-serif;margin-right:10px;vertical-align:middle;">P</span>
|
||||
</td>
|
||||
<td style="vertical-align:middle;">
|
||||
<span style="color:#FFFFFF;font-size:18px;font-weight:700;letter-spacing:-0.03em;vertical-align:middle;">{config.APP_NAME}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Blue accent border -->
|
||||
<tr><td style="height:3px;background-color:#1D4ED8;font-size:0;line-height:0;"> </td></tr>
|
||||
|
||||
<!-- Wordmark header -->
|
||||
<tr><td style="background-color:#0F172A;padding:24px 36px;">
|
||||
<a href="{config.BASE_URL}" style="text-decoration:none;">
|
||||
<span style="color:#FFFFFF;font-size:18px;font-weight:800;letter-spacing:-0.02em;font-family:'Bricolage Grotesque',Georgia,'Times New Roman',serif;">padelnomics</span>
|
||||
</a>
|
||||
</td></tr>
|
||||
|
||||
<!-- Body -->
|
||||
@@ -68,7 +76,7 @@ def _email_wrap(body: str, lang: str = "en") -> str:
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
|
||||
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
|
||||
<a href="{config.BASE_URL}" style="color:#64748B;text-decoration:none;font-weight:500;">{config.APP_NAME}</a>
|
||||
<a href="{config.BASE_URL}" style="color:#64748B;text-decoration:none;font-weight:500;">padelnomics.io</a>
|
||||
·
|
||||
{tagline}
|
||||
</p>
|
||||
@@ -85,12 +93,16 @@ def _email_wrap(body: str, lang: str = "en") -> str:
|
||||
|
||||
|
||||
def _email_button(url: str, label: str) -> str:
|
||||
"""Render a branded CTA button for email."""
|
||||
"""Render a branded CTA button for email.
|
||||
|
||||
Uses display:block for full-width tap target on mobile.
|
||||
"""
|
||||
return (
|
||||
f'<table cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">'
|
||||
f'<tr><td style="background-color:#1D4ED8;border-radius:7px;text-align:center;">'
|
||||
f'<a href="{url}" style="display:inline-block;padding:13px 30px;'
|
||||
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">'
|
||||
f'<table cellpadding="0" cellspacing="0" width="100%" style="margin:28px 0 8px;">'
|
||||
f'<tr><td style="background-color:#1D4ED8;border-radius:8px;text-align:center;">'
|
||||
f'<a href="{url}" style="display:block;padding:14px 32px;'
|
||||
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;'
|
||||
f'letter-spacing:-0.01em;">'
|
||||
f"{label}</a></td></tr></table>"
|
||||
)
|
||||
|
||||
@@ -185,6 +197,7 @@ async def handle_send_email(payload: dict) -> None:
|
||||
html=payload["html"],
|
||||
text=payload.get("text"),
|
||||
from_addr=payload.get("from_addr"),
|
||||
email_type="generic",
|
||||
)
|
||||
|
||||
|
||||
@@ -200,9 +213,11 @@ async def handle_send_magic_link(payload: dict) -> None:
|
||||
print(f" {link}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=config.MAGIC_LINK_EXPIRY_MINUTES)}</p>'
|
||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
||||
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=expiry_minutes)}</p>'
|
||||
f'{_email_button(link, _t("email_magic_link_btn", lang))}'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_fallback", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
|
||||
@@ -212,8 +227,9 @@ async def handle_send_magic_link(payload: dict) -> None:
|
||||
await send_email(
|
||||
to=payload["email"],
|
||||
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
|
||||
html=_email_wrap(body, lang),
|
||||
html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
email_type="magic_link",
|
||||
)
|
||||
|
||||
|
||||
@@ -235,21 +251,36 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
||||
first_name = (
|
||||
payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
|
||||
)
|
||||
project_desc = ""
|
||||
parts = []
|
||||
if payload.get("court_count"):
|
||||
parts.append(f"{payload['court_count']}-court")
|
||||
if payload.get("facility_type"):
|
||||
parts.append(payload["facility_type"])
|
||||
if payload.get("country"):
|
||||
parts.append(f"in {payload['country']}")
|
||||
if parts:
|
||||
project_desc = f" for your {' '.join(parts)} project"
|
||||
court_count = payload.get("court_count", "")
|
||||
facility_type = payload.get("facility_type", "")
|
||||
country = payload.get("country", "")
|
||||
|
||||
# Project recap card
|
||||
project_card = ""
|
||||
recap_parts = []
|
||||
if court_count:
|
||||
recap_parts.append(f"{court_count} courts")
|
||||
if facility_type:
|
||||
recap_parts.append(facility_type)
|
||||
if country:
|
||||
recap_parts.append(country)
|
||||
if recap_parts:
|
||||
project_card = (
|
||||
f'<table cellpadding="0" cellspacing="0" width="100%" style="margin:16px 0;border:1px solid #E2E8F0;border-radius:8px;overflow:hidden;">'
|
||||
f'<tr><td style="padding:14px 18px;background-color:#F8FAFC;font-size:13px;color:#64748B;">'
|
||||
f'<strong style="color:#0F172A;">{_t("email_quote_verify_project_label", lang)}</strong> {" · ".join(recap_parts)}'
|
||||
f'</td></tr></table>'
|
||||
)
|
||||
|
||||
preheader = _t("email_quote_verify_preheader_courts", lang, court_count=court_count) if court_count else _t("email_quote_verify_preheader", lang)
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_quote_verify_heading", lang)}</h2>'
|
||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_quote_verify_heading", lang)}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
||||
f'<p>{_t("email_quote_verify_greeting", lang, first_name=first_name)}</p>'
|
||||
f'<p>{_t("email_quote_verify_body", lang, project_desc=project_desc, app_name=config.APP_NAME)}</p>'
|
||||
f'<p>{_t("email_quote_verify_body", lang, app_name=config.APP_NAME)}</p>'
|
||||
f'{project_card}'
|
||||
f'<p style="font-size:13px;color:#334155;">{_t("email_quote_verify_urgency", lang)}</p>'
|
||||
f'{_email_button(link, _t("email_quote_verify_btn", lang))}'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_expires", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_fallback", lang)}</p>'
|
||||
@@ -260,8 +291,9 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
||||
await send_email(
|
||||
to=payload["email"],
|
||||
subject=_t("email_quote_verify_subject", lang),
|
||||
html=_email_wrap(body, lang),
|
||||
html=_email_wrap(body, lang, preheader=preheader),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
email_type="quote_verification",
|
||||
)
|
||||
|
||||
|
||||
@@ -269,17 +301,32 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
||||
async def handle_send_welcome(payload: dict) -> None:
|
||||
"""Send welcome email to new user."""
|
||||
lang = payload.get("lang", "en")
|
||||
name_parts = (payload.get("name") or "").split()
|
||||
first_name = name_parts[0] if name_parts else "there"
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
||||
f'<p>{_t("email_welcome_greeting", lang, first_name=first_name)}</p>'
|
||||
f'<p>{_t("email_welcome_body", lang)}</p>'
|
||||
f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_welcome_btn", lang))}'
|
||||
f'<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{_t("email_welcome_quickstart_heading", lang)}</p>'
|
||||
f'<table cellpadding="0" cellspacing="0" style="margin:0 0 20px;font-size:14px;">'
|
||||
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>'
|
||||
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/planner" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_planner", lang)}</a></td></tr>'
|
||||
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>'
|
||||
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/{lang}/markets" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_markets", lang)}</a></td></tr>'
|
||||
f'<tr><td style="padding:4px 10px 4px 0;color:#1D4ED8;">●</td>'
|
||||
f'<td style="padding:4px 0;"><a href="{config.BASE_URL}/{lang}/leads/quote" style="color:#1D4ED8;text-decoration:none;font-weight:500;">{_t("email_welcome_link_quotes", lang)}</a></td></tr>'
|
||||
f'</table>'
|
||||
f'{_email_button(f"{config.BASE_URL}/planner", _t("email_welcome_btn", lang))}'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=payload["email"],
|
||||
subject=_t("email_welcome_subject", lang, app_name=config.APP_NAME),
|
||||
html=_email_wrap(body, lang),
|
||||
subject=_t("email_welcome_subject", lang),
|
||||
html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
email_type="welcome",
|
||||
)
|
||||
|
||||
|
||||
@@ -292,21 +339,30 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
|
||||
|
||||
if intent.startswith("supplier_"):
|
||||
plan_name = intent.replace("supplier_", "").title()
|
||||
subject = _t("email_waitlist_supplier_subject", lang, app_name=config.APP_NAME, plan_name=plan_name)
|
||||
subject = _t("email_waitlist_supplier_subject", lang, plan_name=plan_name)
|
||||
preheader = _t("email_waitlist_supplier_preheader", lang)
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_waitlist_supplier_heading", lang)}</h2>'
|
||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_waitlist_supplier_heading", lang)}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
||||
f'<p>{_t("email_waitlist_supplier_body", lang, plan_name=plan_name)}</p>'
|
||||
f'<p>{_t("email_waitlist_supplier_perks", lang)}</p>'
|
||||
f'<p>{_t("email_waitlist_supplier_perks_intro", lang)}</p>'
|
||||
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
|
||||
f'<li>{_t("email_waitlist_supplier_perk_1", lang)}</li>'
|
||||
f'<li>{_t("email_waitlist_supplier_perk_2", lang)}</li>'
|
||||
f'<li>{_t("email_waitlist_supplier_perk_3", lang)}</li>'
|
||||
f'</ul>'
|
||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_waitlist_supplier_meanwhile", lang)}</p>'
|
||||
f'<ul style="font-size:13px;color:#64748B;">'
|
||||
f'<li><a href="{config.BASE_URL}/planner">{_t("email_waitlist_supplier_link_planner", lang)}</a></li>'
|
||||
f'<li><a href="{config.BASE_URL}/directory">{_t("email_waitlist_supplier_link_directory", lang)}</a></li>'
|
||||
f'<li><a href="{config.BASE_URL}/planner" style="color:#1D4ED8;">{_t("email_waitlist_supplier_link_planner", lang)}</a></li>'
|
||||
f'<li><a href="{config.BASE_URL}/directory" style="color:#1D4ED8;">{_t("email_waitlist_supplier_link_directory", lang)}</a></li>'
|
||||
f'</ul>'
|
||||
)
|
||||
else:
|
||||
subject = _t("email_waitlist_general_subject", lang, app_name=config.APP_NAME)
|
||||
subject = _t("email_waitlist_general_subject", lang)
|
||||
preheader = _t("email_waitlist_general_preheader", lang)
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_waitlist_general_heading", lang)}</h2>'
|
||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_waitlist_general_heading", lang)}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
||||
f'<p>{_t("email_waitlist_general_body", lang)}</p>'
|
||||
f'<p>{_t("email_waitlist_general_perks_intro", lang)}</p>'
|
||||
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
|
||||
@@ -320,8 +376,9 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
|
||||
await send_email(
|
||||
to=email,
|
||||
subject=subject,
|
||||
html=_email_wrap(body, lang),
|
||||
html=_email_wrap(body, lang, preheader=preheader),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
email_type="waitlist",
|
||||
)
|
||||
|
||||
|
||||
@@ -354,19 +411,31 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
country = lead["country"] or "Unknown"
|
||||
courts = lead["court_count"] or "?"
|
||||
budget = lead["budget_estimate"] or "?"
|
||||
facility_type = lead["facility_type"] or "padel"
|
||||
timeline = lead["timeline"] or ""
|
||||
contact_email = lead["contact_email"] or ""
|
||||
|
||||
subject = f"[{heat}] New padel project in {country} — {courts} courts, €{budget}"
|
||||
subject = f"[{heat}] New padel project in {country} \u2014 {courts} courts, \u20ac{budget}"
|
||||
|
||||
t = lambda key: _t(key, lang) # noqa: E731
|
||||
# Heat badge color
|
||||
heat_colors = {"HOT": "#DC2626", "WARM": "#EA580C", "COOL": "#2563EB"}
|
||||
heat_bg = heat_colors.get(heat, "#2563EB")
|
||||
heat_badge = (
|
||||
f'<span style="display:inline-block;padding:2px 8px;border-radius:4px;'
|
||||
f'background-color:{heat_bg};color:#FFFFFF;font-size:11px;font-weight:700;'
|
||||
f'letter-spacing:0.04em;vertical-align:middle;margin-left:8px;">{heat}</span>'
|
||||
)
|
||||
|
||||
tl = lambda key: _t(key, lang) # noqa: E731
|
||||
|
||||
brief_rows = [
|
||||
(t("email_lead_forward_lbl_facility"), f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
|
||||
(t("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
|
||||
(t("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"),
|
||||
(t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: \u20ac{budget}"),
|
||||
(t("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
|
||||
(t("email_lead_forward_lbl_services"), lead["services_needed"] or "-"),
|
||||
(t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
|
||||
(tl("email_lead_forward_lbl_facility"), f"{facility_type} ({lead['build_context'] or '-'})"),
|
||||
(tl("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
|
||||
(tl("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"),
|
||||
(tl("email_lead_forward_lbl_timeline"), f"{timeline or '-'} | Budget: \u20ac{budget}"),
|
||||
(tl("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
|
||||
(tl("email_lead_forward_lbl_services"), lead["services_needed"] or "-"),
|
||||
(tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
|
||||
]
|
||||
|
||||
brief_html = ""
|
||||
@@ -376,29 +445,41 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
|
||||
)
|
||||
|
||||
contact_rows = [
|
||||
(t("email_lead_forward_lbl_name"), lead["contact_name"] or "-"),
|
||||
(t("email_lead_forward_lbl_email"), lead["contact_email"] or "-"),
|
||||
(t("email_lead_forward_lbl_phone"), lead["contact_phone"] or "-"),
|
||||
(t("email_lead_forward_lbl_company"), lead["contact_company"] or "-"),
|
||||
(t("email_lead_forward_lbl_role"), lead["stakeholder_type"] or "-"),
|
||||
]
|
||||
contact_name = lead["contact_name"] or "-"
|
||||
contact_phone = lead["contact_phone"] or "-"
|
||||
|
||||
contact_html = ""
|
||||
for label, value in contact_rows:
|
||||
contact_html += (
|
||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{label}</td>'
|
||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
|
||||
)
|
||||
# Contact section with prominent email
|
||||
contact_html = (
|
||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_name")}</td>'
|
||||
f'<td style="padding:4px 0;font-size:14px;color:#0F172A;font-weight:600">{contact_name}</td></tr>'
|
||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_email")}</td>'
|
||||
f'<td style="padding:4px 0;font-size:14px;"><a href="mailto:{contact_email}" style="color:#1D4ED8;font-weight:600;text-decoration:none;">{contact_email}</a></td></tr>'
|
||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_phone")}</td>'
|
||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{contact_phone}</td></tr>'
|
||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_company")}</td>'
|
||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{lead["contact_company"] or "-"}</td></tr>'
|
||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_role")}</td>'
|
||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{lead["stakeholder_type"] or "-"}</td></tr>'
|
||||
)
|
||||
|
||||
preheader_parts = [f"{facility_type} project"]
|
||||
if timeline:
|
||||
preheader_parts.append(f"{timeline} timeline")
|
||||
preheader_parts.append(_t("email_lead_forward_preheader_suffix", lang))
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{t("email_lead_forward_heading")}</h2>'
|
||||
f'<p style="font-size:13px;color:#64748B;margin:0 0 16px">{t("email_lead_forward_subheading")}</p>'
|
||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{t("email_lead_forward_section_brief")}</h3>'
|
||||
f'<p style="font-size:13px;color:#334155;margin:0 0 16px;padding:10px 14px;'
|
||||
f'background-color:#FEF3C7;border-radius:6px;border-left:3px solid #F59E0B;">'
|
||||
f'{_t("email_lead_forward_urgency", lang)}</p>'
|
||||
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{tl("email_lead_forward_heading")} {heat_badge}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:8px 0 16px;">'
|
||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{tl("email_lead_forward_section_brief")}</h3>'
|
||||
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{brief_html}</table>'
|
||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{t("email_lead_forward_section_contact")}</h3>'
|
||||
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{tl("email_lead_forward_section_contact")}</h3>'
|
||||
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{contact_html}</table>'
|
||||
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", t("email_lead_forward_btn"))}'
|
||||
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", tl("email_lead_forward_btn"))}'
|
||||
f'<p style="font-size:13px;color:#64748B;text-align:center;margin:8px 0 0;">'
|
||||
f'{_t("email_lead_forward_reply_direct", lang, contact_email=contact_email)}</p>'
|
||||
)
|
||||
|
||||
# Send to supplier contact email or general contact
|
||||
@@ -410,8 +491,9 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
await send_email(
|
||||
to=to_email,
|
||||
subject=subject,
|
||||
html=_email_wrap(body, lang),
|
||||
html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)),
|
||||
from_addr=EMAIL_ADDRESSES["leads"],
|
||||
email_type="lead_forward",
|
||||
)
|
||||
|
||||
# Update email_sent_at on lead_forward
|
||||
@@ -434,19 +516,27 @@ async def handle_send_lead_matched_notification(payload: dict) -> None:
|
||||
first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there"
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_lead_matched_heading", lang)}</h2>'
|
||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_lead_matched_heading", lang)}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
||||
f'<p>{_t("email_lead_matched_greeting", lang, first_name=first_name)}</p>'
|
||||
f'<p>{_t("email_lead_matched_body", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_lead_matched_context", lang, facility_type=lead["facility_type"] or "padel", court_count=lead["court_count"] or "?", country=lead["country"] or "your area")}</p>'
|
||||
# What happens next
|
||||
f'<p style="font-size:14px;font-weight:600;color:#0F172A;margin:20px 0 8px;">{_t("email_lead_matched_next_heading", lang)}</p>'
|
||||
f'<p style="font-size:14px;color:#334155;">{_t("email_lead_matched_next_body", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#64748B;padding:10px 14px;background-color:#F0F9FF;'
|
||||
f'border-radius:6px;border-left:3px solid #1D4ED8;">'
|
||||
f'{_t("email_lead_matched_tip", lang)}</p>'
|
||||
f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_lead_matched_btn", lang))}'
|
||||
f'<p style="font-size:12px;color:#94A3B8;">{_t("email_lead_matched_note", lang)}</p>'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=lead["contact_email"],
|
||||
subject=_t("email_lead_matched_subject", lang),
|
||||
html=_email_wrap(body, lang),
|
||||
subject=_t("email_lead_matched_subject", lang, first_name=first_name),
|
||||
html=_email_wrap(body, lang, preheader=_t("email_lead_matched_preheader", lang)),
|
||||
from_addr=EMAIL_ADDRESSES["leads"],
|
||||
email_type="lead_matched",
|
||||
)
|
||||
|
||||
|
||||
@@ -464,8 +554,9 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
|
||||
message = payload.get("message", "")
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">'
|
||||
f'{_t("email_enquiry_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">'
|
||||
f'{_t("email_enquiry_heading", lang, contact_name=contact_name)}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
||||
f'<p>{_t("email_enquiry_body", lang, supplier_name=supplier_name)}</p>'
|
||||
f'<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">'
|
||||
f'<tr><td style="padding:6px 0;color:#64748B;width:120px">{_t("email_enquiry_lbl_from", lang)}</td>'
|
||||
@@ -473,14 +564,16 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
|
||||
f'<tr><td style="padding:6px 0;color:#64748B;vertical-align:top">{_t("email_enquiry_lbl_message", lang)}</td>'
|
||||
f'<td style="padding:6px 0;white-space:pre-wrap">{message}</td></tr>'
|
||||
f'</table>'
|
||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_respond_fast", lang)}</p>'
|
||||
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_reply", lang, contact_email=contact_email)}</p>'
|
||||
)
|
||||
|
||||
await send_email(
|
||||
to=supplier_email,
|
||||
subject=_t("email_enquiry_subject", lang, app_name=config.APP_NAME, contact_name=contact_name),
|
||||
html=_email_wrap(body, lang),
|
||||
subject=_t("email_enquiry_subject", lang, contact_name=contact_name),
|
||||
html=_email_wrap(body, lang, preheader=_t("email_enquiry_preheader", lang)),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
email_type="supplier_enquiry",
|
||||
)
|
||||
|
||||
|
||||
@@ -542,15 +635,20 @@ async def handle_generate_business_plan(payload: dict) -> None:
|
||||
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||
if user:
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_business_plan_heading", language)}</h2>'
|
||||
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_t("email_business_plan_heading", language)}</h2>'
|
||||
f'<hr style="border:none;border-top:1px solid #E2E8F0;margin:0 0 16px;">'
|
||||
f'<p>{_t("email_business_plan_body", language)}</p>'
|
||||
f'<p style="font-size:14px;color:#334155;">{_t("email_business_plan_includes", language)}</p>'
|
||||
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", language))}'
|
||||
f'<p style="font-size:13px;color:#64748B;text-align:center;margin:12px 0 0;">'
|
||||
f'{_t("email_business_plan_quote_cta", language, quote_url=f"{config.BASE_URL}/{language}/leads/quote")}</p>'
|
||||
)
|
||||
await send_email(
|
||||
to=user["email"],
|
||||
subject=_t("email_business_plan_subject", language),
|
||||
html=_email_wrap(body, language),
|
||||
html=_email_wrap(body, language, preheader=_t("email_business_plan_preheader", language)),
|
||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||
email_type="business_plan",
|
||||
)
|
||||
|
||||
print(f"[WORKER] Generated business plan PDF: export_id={export_id}")
|
||||
|
||||
Reference in New Issue
Block a user