Merge branch 'worktree-sitemap-improvement'
# Conflicts: # web/src/padelnomics/admin/routes.py
This commit is contained in:
@@ -81,6 +81,8 @@ DUCKDB_PATH=local.duckdb SERVING_DUCKDB_PATH=analytics.duckdb \
|
|||||||
| Extraction patterns, state tracking, adding new sources | `extract/padelnomics_extract/README.md` |
|
| Extraction patterns, state tracking, adding new sources | `extract/padelnomics_extract/README.md` |
|
||||||
| 3-layer SQLMesh architecture, materialization strategy | `transform/sqlmesh_padelnomics/README.md` |
|
| 3-layer SQLMesh architecture, materialization strategy | `transform/sqlmesh_padelnomics/README.md` |
|
||||||
| Two-file DuckDB architecture (SQLMesh lock isolation) | `src/padelnomics/export_serving.py` docstring |
|
| Two-file DuckDB architecture (SQLMesh lock isolation) | `src/padelnomics/export_serving.py` docstring |
|
||||||
|
| Email hub: delivery tracking, webhook handler, admin UI | `web/src/padelnomics/webhooks.py` docstring |
|
||||||
|
| User flows (all admin + public routes) | `docs/USER_FLOWS.md` |
|
||||||
|
|
||||||
## Pipeline data flow
|
## Pipeline data flow
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ analytics.duckdb ← serving tables only, web app read-only
|
|||||||
| `LANDING_DIR` | `data/landing` | Landing zone root (extraction writes here) |
|
| `LANDING_DIR` | `data/landing` | Landing zone root (extraction writes here) |
|
||||||
| `DUCKDB_PATH` | `local.duckdb` | SQLMesh pipeline DB (exclusive write) |
|
| `DUCKDB_PATH` | `local.duckdb` | SQLMesh pipeline DB (exclusive write) |
|
||||||
| `SERVING_DUCKDB_PATH` | `analytics.duckdb` | Read-only DB for web app |
|
| `SERVING_DUCKDB_PATH` | `analytics.duckdb` | Read-only DB for web app |
|
||||||
|
| `RESEND_WEBHOOK_SECRET` | `""` | Resend webhook signature secret (skip verification if empty) |
|
||||||
|
|
||||||
|
|
||||||
## Coding philosophy
|
## Coding philosophy
|
||||||
|
|||||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -7,6 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Admin Email Hub** (`/admin/emails`) — full email management dashboard with:
|
||||||
|
sent log (filterable by type/event/search, HTMX partial updates), email detail
|
||||||
|
with Resend API enrichment for HTML preview, inbound inbox with unread badges
|
||||||
|
and inline reply, compose form with branded template wrapping, and Resend
|
||||||
|
audience management with contact list/remove
|
||||||
|
- **Email delivery tracking** — `email_log` table records every outgoing email
|
||||||
|
with resend_id; Resend webhook handler (`/webhooks/resend`) updates delivery
|
||||||
|
events (delivered, bounced, opened, clicked, complained) in real-time;
|
||||||
|
`inbound_emails` table stores received messages with full body
|
||||||
|
- **send_email() returns resend_id** — changed return type from `bool` to
|
||||||
|
`str | None` (backward-compatible: truthy string works like True); all 9
|
||||||
|
worker handlers now pass `email_type=` for per-type filtering in the log
|
||||||
- **Playtomic full data extraction** — expanded venue bounding boxes from 4 regions
|
- **Playtomic full data extraction** — expanded venue bounding boxes from 4 regions
|
||||||
(ES, UK, DE, FR) to 23 globally (Italy, Portugal, NL, BE, AT, CH, Nordics, Mexico,
|
(ES, UK, DE, FR) to 23 globally (Italy, Portugal, NL, BE, AT, CH, Nordics, Mexico,
|
||||||
Argentina, Middle East, USA); PAGE_SIZE increased from 20 to 100; availability
|
Argentina, Middle East, USA); PAGE_SIZE increased from 20 to 100; availability
|
||||||
@@ -38,6 +50,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
translated footer; ~70 new translation keys (EN + DE); all task payloads now
|
translated footer; ~70 new translation keys (EN + DE); all task payloads now
|
||||||
carry `lang` from request context at enqueue time; payloads without `lang`
|
carry `lang` from request context at enqueue time; payloads without `lang`
|
||||||
gracefully default to English
|
gracefully default to English
|
||||||
|
- **Email design & copy upgrade** — redesigned `_email_wrap()`: replaced monogram
|
||||||
|
header with lowercase wordmark matching website, added 3px blue accent border,
|
||||||
|
preheader text support (hidden preview in email clients), HR separators between
|
||||||
|
heading and body; `_email_button()` now full-width block for mobile tap targets;
|
||||||
|
rewrote copy for all 9 emails with improved subject lines, urgency cues,
|
||||||
|
quick-start links in welcome email, styled project recap cards in quote
|
||||||
|
verification, heat badges on lead forward emails, "what happens next" section
|
||||||
|
in lead matched notifications, and secondary CTAs; ~30 new/updated translation
|
||||||
|
keys in both EN and DE
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Resend audiences restructured** — replaced dynamic `waitlist-{blueprint}`
|
- **Resend audiences restructured** — replaced dynamic `waitlist-{blueprint}`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Padelnomics — Project Tracker
|
# Padelnomics — Project Tracker
|
||||||
|
|
||||||
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
||||||
> Last updated: 2026-02-22.
|
> Last updated: 2026-02-23.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
- [x] Comprehensive admin: users, tasks, leads, suppliers, CMS templates, scenarios, articles, feedback
|
- [x] Comprehensive admin: users, tasks, leads, suppliers, CMS templates, scenarios, articles, feedback
|
||||||
- [x] Task queue management (list, retry, delete)
|
- [x] Task queue management (list, retry, delete)
|
||||||
- [x] Lead funnel stats on admin dashboard
|
- [x] Lead funnel stats on admin dashboard
|
||||||
|
- [x] Email hub (`/admin/emails`) — sent log, inbox, compose, audiences, delivery event tracking via Resend webhooks
|
||||||
|
|
||||||
### SEO & Legal
|
### SEO & Legal
|
||||||
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
||||||
@@ -193,7 +194,7 @@ _Move here when you start working on it._
|
|||||||
### Bugs / Tech Debt
|
### Bugs / Tech Debt
|
||||||
- [ ] Resend audiences: two segments both using "waitlist-auth" — review audience/segment model and fix duplication
|
- [ ] Resend audiences: two segments both using "waitlist-auth" — review audience/segment model and fix duplication
|
||||||
- [ ] Transactional emails not all translated to German — some emails still sent in English regardless of user language
|
- [ ] Transactional emails not all translated to German — some emails still sent in English regardless of user language
|
||||||
- [ ] Resend inbound emails enabled — plan how to integrate (webhook routing, reply handling, support inbox?)
|
- [x] ~~Resend inbound emails enabled~~ — integrated: webhook handler + admin inbox with reply (done in email hub)
|
||||||
- [ ] Extraction: Playtomic API only returns ~20 venues per bbox — investigate smaller/targeted bboxes
|
- [ ] Extraction: Playtomic API only returns ~20 venues per bbox — investigate smaller/targeted bboxes
|
||||||
|
|
||||||
### Marketing & Content
|
### Marketing & Content
|
||||||
|
|||||||
@@ -188,6 +188,10 @@ Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state).
|
|||||||
| Leads | `GET /admin/leads`, `/admin/leads/<id>` | List, filter, view detail, change status, forward to supplier, create |
|
| Leads | `GET /admin/leads`, `/admin/leads/<id>` | List, filter, view detail, change status, forward to supplier, create |
|
||||||
| Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
|
| Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
|
||||||
| Feedback | `GET /admin/feedback` | View all submitted feedback |
|
| Feedback | `GET /admin/feedback` | View all submitted feedback |
|
||||||
|
| Email Sent Log | `GET /admin/emails`, `/admin/emails/<id>` | List all outgoing emails (filter by type/event/search), detail with API-enriched HTML preview |
|
||||||
|
| Email Inbox | `GET /admin/emails/inbox`, `/admin/emails/inbox/<id>` | Inbound emails (unread badge), detail with sandboxed HTML, inline reply |
|
||||||
|
| Email Compose | `GET /admin/emails/compose` | Send ad-hoc emails with from-address selection + optional branded wrapping |
|
||||||
|
| Audiences | `GET /admin/emails/audiences`, `/admin/emails/audiences/<id>/contacts` | Resend audiences, contact list, remove contacts |
|
||||||
| Article Templates | `GET /admin/templates` | CRUD + bulk generate articles from template+data |
|
| Article Templates | `GET /admin/templates` | CRUD + bulk generate articles from template+data |
|
||||||
| Published Scenarios | `GET /admin/scenarios` | CRUD public scenario cards (shown on landing) |
|
| Published Scenarios | `GET /admin/scenarios` | CRUD public scenario cards (shown on landing) |
|
||||||
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML |
|
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML |
|
||||||
@@ -211,6 +215,7 @@ Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state).
|
|||||||
| `dashboard` | `/dashboard` | No |
|
| `dashboard` | `/dashboard` | No |
|
||||||
| `billing` | `/billing` | No |
|
| `billing` | `/billing` | No |
|
||||||
| `admin` | `/admin` | No |
|
| `admin` | `/admin` | No |
|
||||||
|
| `webhooks` | `/webhooks` | No |
|
||||||
|
|
||||||
**Language detection for non-prefixed blueprints:** Cookie (`lang`) → `Accept-Language` header → fallback `"en"`
|
**Language detection for non-prefixed blueprints:** Cookie (`lang`) → `Accept-Language` header → fallback `"en"`
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import date, datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import mistune
|
import mistune
|
||||||
|
import resend
|
||||||
from quart import (
|
from quart import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
Response,
|
Response,
|
||||||
@@ -19,7 +20,16 @@ from quart import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..auth.routes import role_required
|
from ..auth.routes import role_required
|
||||||
from ..core import csrf_protect, execute, fetch_all, fetch_one, slugify
|
from ..core import (
|
||||||
|
EMAIL_ADDRESSES,
|
||||||
|
config,
|
||||||
|
csrf_protect,
|
||||||
|
execute,
|
||||||
|
fetch_all,
|
||||||
|
fetch_one,
|
||||||
|
send_email,
|
||||||
|
slugify,
|
||||||
|
)
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -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
|
# SQL Queries
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -825,6 +853,344 @@ async def feedback():
|
|||||||
return await render_template("admin/feedback.html", feedback_list=feedback_list)
|
return await render_template("admin/feedback.html", feedback_list=feedback_list)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Email Hub
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
EMAIL_TYPES = [
|
||||||
|
"ad_hoc", "magic_link", "welcome", "quote_verification", "waitlist",
|
||||||
|
"lead_forward", "lead_matched", "supplier_enquiry", "business_plan",
|
||||||
|
"generic", "admin_compose", "admin_reply",
|
||||||
|
]
|
||||||
|
|
||||||
|
EVENT_TYPES = ["sent", "delivered", "opened", "clicked", "bounced", "complained"]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_email_log(
|
||||||
|
email_type: str = None, last_event: str = None, search: str = None,
|
||||||
|
page: int = 1, per_page: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get email log with optional filters."""
|
||||||
|
wheres = ["1=1"]
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if email_type:
|
||||||
|
wheres.append("email_type = ?")
|
||||||
|
params.append(email_type)
|
||||||
|
if last_event:
|
||||||
|
wheres.append("last_event = ?")
|
||||||
|
params.append(last_event)
|
||||||
|
if search:
|
||||||
|
wheres.append("(to_addr LIKE ? OR subject LIKE ?)")
|
||||||
|
params.extend([f"%{search}%", f"%{search}%"])
|
||||||
|
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
|
||||||
|
return await fetch_all(
|
||||||
|
f"""SELECT * FROM email_log WHERE {where}
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_email_stats() -> dict:
|
||||||
|
"""Aggregate email stats for the list header."""
|
||||||
|
total = await fetch_one("SELECT COUNT(*) as cnt FROM email_log")
|
||||||
|
delivered = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'delivered'")
|
||||||
|
bounced = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'bounced'")
|
||||||
|
today = datetime.utcnow().date().isoformat()
|
||||||
|
sent_today = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE created_at >= ?", (today,))
|
||||||
|
return {
|
||||||
|
"total": total["cnt"] if total else 0,
|
||||||
|
"delivered": delivered["cnt"] if delivered else 0,
|
||||||
|
"bounced": bounced["cnt"] if bounced else 0,
|
||||||
|
"sent_today": sent_today["cnt"] if sent_today else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unread_count() -> int:
|
||||||
|
"""Count unread inbound emails."""
|
||||||
|
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
|
||||||
|
return row["cnt"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails")
|
||||||
|
@role_required("admin")
|
||||||
|
async def emails():
|
||||||
|
"""Sent email log."""
|
||||||
|
email_type = request.args.get("email_type", "")
|
||||||
|
last_event = request.args.get("last_event", "")
|
||||||
|
search = request.args.get("search", "").strip()
|
||||||
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
|
||||||
|
log = await get_email_log(
|
||||||
|
email_type=email_type or None, last_event=last_event or None,
|
||||||
|
search=search or None, page=page,
|
||||||
|
)
|
||||||
|
stats = await get_email_stats()
|
||||||
|
unread = await get_unread_count()
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/emails.html",
|
||||||
|
emails=log,
|
||||||
|
email_stats=stats,
|
||||||
|
email_types=EMAIL_TYPES,
|
||||||
|
event_types=EVENT_TYPES,
|
||||||
|
current_type=email_type,
|
||||||
|
current_event=last_event,
|
||||||
|
current_search=search,
|
||||||
|
page=page,
|
||||||
|
unread_count=unread,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/results")
|
||||||
|
@role_required("admin")
|
||||||
|
async def email_results():
|
||||||
|
"""HTMX partial for filtered email log."""
|
||||||
|
email_type = request.args.get("email_type", "")
|
||||||
|
last_event = request.args.get("last_event", "")
|
||||||
|
search = request.args.get("search", "").strip()
|
||||||
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
|
||||||
|
log = await get_email_log(
|
||||||
|
email_type=email_type or None, last_event=last_event or None,
|
||||||
|
search=search or None, page=page,
|
||||||
|
)
|
||||||
|
return await render_template("admin/partials/email_results.html", emails=log)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/<int:email_id>")
|
||||||
|
@role_required("admin")
|
||||||
|
async def email_detail(email_id: int):
|
||||||
|
"""Email detail — enriches with Resend API for HTML body."""
|
||||||
|
email = await fetch_one("SELECT * FROM email_log WHERE id = ?", (email_id,))
|
||||||
|
if not email:
|
||||||
|
await flash("Email not found.", "error")
|
||||||
|
return redirect(url_for("admin.emails"))
|
||||||
|
|
||||||
|
# Try to fetch full email from Resend API (5s timeout)
|
||||||
|
enriched_html = None
|
||||||
|
if email["resend_id"] and email["resend_id"] != "dev" and config.RESEND_API_KEY:
|
||||||
|
resend.api_key = config.RESEND_API_KEY
|
||||||
|
try:
|
||||||
|
result = resend.Emails.get(email["resend_id"])
|
||||||
|
if isinstance(result, dict):
|
||||||
|
enriched_html = result.get("html", "")
|
||||||
|
else:
|
||||||
|
enriched_html = getattr(result, "html", "")
|
||||||
|
except Exception:
|
||||||
|
pass # Metadata-only fallback
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/email_detail.html",
|
||||||
|
email=email,
|
||||||
|
enriched_html=enriched_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Inbox ---
|
||||||
|
|
||||||
|
@bp.route("/emails/inbox")
|
||||||
|
@role_required("admin")
|
||||||
|
async def inbox():
|
||||||
|
"""Inbound email list."""
|
||||||
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
per_page = 50
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
unread = await get_unread_count()
|
||||||
|
|
||||||
|
messages = await fetch_all(
|
||||||
|
"SELECT * FROM inbound_emails ORDER BY received_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(per_page, offset),
|
||||||
|
)
|
||||||
|
return await render_template(
|
||||||
|
"admin/inbox.html", messages=messages, unread_count=unread, page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/inbox/<int:msg_id>")
|
||||||
|
@role_required("admin")
|
||||||
|
async def inbox_detail(msg_id: int):
|
||||||
|
"""Inbound email detail — marks as read."""
|
||||||
|
msg = await fetch_one("SELECT * FROM inbound_emails WHERE id = ?", (msg_id,))
|
||||||
|
if not msg:
|
||||||
|
await flash("Message not found.", "error")
|
||||||
|
return redirect(url_for("admin.inbox"))
|
||||||
|
|
||||||
|
if not msg["is_read"]:
|
||||||
|
await execute("UPDATE inbound_emails SET is_read = 1 WHERE id = ?", (msg_id,))
|
||||||
|
|
||||||
|
return await render_template("admin/inbox_detail.html", msg=msg)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/inbox/<int:msg_id>/reply", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def inbox_reply(msg_id: int):
|
||||||
|
"""Reply to an inbound email."""
|
||||||
|
msg = await fetch_one("SELECT * FROM inbound_emails WHERE id = ?", (msg_id,))
|
||||||
|
if not msg:
|
||||||
|
await flash("Message not found.", "error")
|
||||||
|
return redirect(url_for("admin.inbox"))
|
||||||
|
|
||||||
|
form = await request.form
|
||||||
|
body = form.get("body", "").strip()
|
||||||
|
from_addr = form.get("from_addr", "") or EMAIL_ADDRESSES["transactional"]
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
await flash("Reply body is required.", "error")
|
||||||
|
return redirect(url_for("admin.inbox_detail", msg_id=msg_id))
|
||||||
|
|
||||||
|
subject = msg["subject"] or ""
|
||||||
|
if not subject.lower().startswith("re:"):
|
||||||
|
subject = f"Re: {subject}"
|
||||||
|
|
||||||
|
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
|
||||||
|
result = await send_email(
|
||||||
|
to=msg["from_addr"],
|
||||||
|
subject=subject,
|
||||||
|
html=html,
|
||||||
|
from_addr=from_addr,
|
||||||
|
email_type="admin_reply",
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
await flash("Reply sent.", "success")
|
||||||
|
else:
|
||||||
|
await flash("Failed to send reply.", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("admin.inbox_detail", msg_id=msg_id))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Compose ---
|
||||||
|
|
||||||
|
@bp.route("/emails/compose", methods=["GET", "POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def email_compose():
|
||||||
|
"""Compose and send an ad-hoc email."""
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
to = form.get("to", "").strip()
|
||||||
|
subject = form.get("subject", "").strip()
|
||||||
|
body = form.get("body", "").strip()
|
||||||
|
from_addr = form.get("from_addr", "") or EMAIL_ADDRESSES["transactional"]
|
||||||
|
wrap = form.get("wrap", "") == "1"
|
||||||
|
|
||||||
|
if not to or not subject or not body:
|
||||||
|
await flash("To, subject, and body are required.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/email_compose.html",
|
||||||
|
data={"to": to, "subject": subject, "body": body, "from_addr": from_addr},
|
||||||
|
email_addresses=EMAIL_ADDRESSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
|
||||||
|
if wrap:
|
||||||
|
from ..worker import _email_wrap
|
||||||
|
html = _email_wrap(html)
|
||||||
|
|
||||||
|
result = await send_email(
|
||||||
|
to=to, subject=subject, html=html,
|
||||||
|
from_addr=from_addr, email_type="admin_compose",
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
await flash(f"Email sent to {to}.", "success")
|
||||||
|
return redirect(url_for("admin.emails"))
|
||||||
|
else:
|
||||||
|
await flash("Failed to send email.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/email_compose.html",
|
||||||
|
data={"to": to, "subject": subject, "body": body, "from_addr": from_addr},
|
||||||
|
email_addresses=EMAIL_ADDRESSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/email_compose.html", data={}, email_addresses=EMAIL_ADDRESSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Audiences ---
|
||||||
|
|
||||||
|
@bp.route("/emails/audiences")
|
||||||
|
@role_required("admin")
|
||||||
|
async def audiences():
|
||||||
|
"""List Resend audiences with local cache + API contact counts."""
|
||||||
|
audience_list = await fetch_all("SELECT * FROM resend_audiences ORDER BY name")
|
||||||
|
|
||||||
|
# Enrich with contact count from API (best-effort)
|
||||||
|
for a in audience_list:
|
||||||
|
a["contact_count"] = None
|
||||||
|
if config.RESEND_API_KEY and a.get("audience_id"):
|
||||||
|
resend.api_key = config.RESEND_API_KEY
|
||||||
|
try:
|
||||||
|
contacts = resend.Contacts.list(a["audience_id"])
|
||||||
|
if isinstance(contacts, dict):
|
||||||
|
a["contact_count"] = len(contacts.get("data", []))
|
||||||
|
elif isinstance(contacts, list):
|
||||||
|
a["contact_count"] = len(contacts)
|
||||||
|
else:
|
||||||
|
data = getattr(contacts, "data", [])
|
||||||
|
a["contact_count"] = len(data) if data else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return await render_template("admin/audiences.html", audiences=audience_list)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/audiences/<audience_id>/contacts")
|
||||||
|
@role_required("admin")
|
||||||
|
async def audience_contacts(audience_id: str):
|
||||||
|
"""List contacts in a Resend audience."""
|
||||||
|
audience = await fetch_one("SELECT * FROM resend_audiences WHERE audience_id = ?", (audience_id,))
|
||||||
|
if not audience:
|
||||||
|
await flash("Audience not found.", "error")
|
||||||
|
return redirect(url_for("admin.audiences"))
|
||||||
|
|
||||||
|
contacts = []
|
||||||
|
if config.RESEND_API_KEY:
|
||||||
|
resend.api_key = config.RESEND_API_KEY
|
||||||
|
try:
|
||||||
|
result = resend.Contacts.list(audience_id)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
contacts = result.get("data", [])
|
||||||
|
elif isinstance(result, list):
|
||||||
|
contacts = result
|
||||||
|
else:
|
||||||
|
contacts = getattr(result, "data", []) or []
|
||||||
|
except Exception:
|
||||||
|
await flash("Failed to fetch contacts from Resend.", "error")
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/audience_contacts.html", audience=audience, contacts=contacts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/emails/audiences/<audience_id>/contacts/remove", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def audience_contact_remove(audience_id: str):
|
||||||
|
"""Remove a contact from a Resend audience."""
|
||||||
|
form = await request.form
|
||||||
|
contact_id = form.get("contact_id", "")
|
||||||
|
|
||||||
|
if not contact_id:
|
||||||
|
await flash("No contact specified.", "error")
|
||||||
|
return redirect(url_for("admin.audience_contacts", audience_id=audience_id))
|
||||||
|
|
||||||
|
if config.RESEND_API_KEY:
|
||||||
|
resend.api_key = config.RESEND_API_KEY
|
||||||
|
try:
|
||||||
|
resend.Contacts.remove(audience_id, contact_id)
|
||||||
|
await flash("Contact removed.", "success")
|
||||||
|
except Exception as e:
|
||||||
|
await flash(f"Failed to remove contact: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("admin.audience_contacts", audience_id=audience_id))
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Content Templates (read-only — templates live in git as .md.jinja files)
|
# 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
|
Templates
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<div class="admin-sidebar__section">Email</div>
|
||||||
|
<a href="{{ url_for('admin.emails') }}" class="{% if admin_page == 'emails' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||||
|
Sent Log
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.inbox') }}" class="{% if admin_page == 'inbox' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"/></svg>
|
||||||
|
Inbox{% if unread_count %} <span class="badge-danger" style="font-size:10px;padding:1px 6px;margin-left:auto;">{{ unread_count }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.email_compose') }}" class="{% if admin_page == 'compose' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||||
|
Compose
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
|
||||||
|
Audiences
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="admin-sidebar__section">System</div>
|
<div class="admin-sidebar__section">System</div>
|
||||||
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
|
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
|
||||||
|
|||||||
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 .planner.routes import bp as planner_bp
|
||||||
from .public.routes import bp as public_bp
|
from .public.routes import bp as public_bp
|
||||||
from .suppliers.routes import bp as suppliers_bp
|
from .suppliers.routes import bp as suppliers_bp
|
||||||
|
from .webhooks import bp as webhooks_bp
|
||||||
|
|
||||||
# Lang-prefixed blueprints (SEO-relevant, public-facing)
|
# Lang-prefixed blueprints (SEO-relevant, public-facing)
|
||||||
app.register_blueprint(public_bp, url_prefix="/<lang>")
|
app.register_blueprint(public_bp, url_prefix="/<lang>")
|
||||||
@@ -318,6 +319,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(billing_bp)
|
app.register_blueprint(billing_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(webhooks_bp)
|
||||||
|
|
||||||
# Content catch-all LAST — lives under /<lang> too
|
# Content catch-all LAST — lives under /<lang> too
|
||||||
app.register_blueprint(content_bp, url_prefix="/<lang>")
|
app.register_blueprint(content_bp, url_prefix="/<lang>")
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class Config:
|
|||||||
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
|
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
|
||||||
]
|
]
|
||||||
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
|
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
|
||||||
|
RESEND_WEBHOOK_SECRET: str = os.getenv("RESEND_WEBHOOK_SECRET", "")
|
||||||
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
|
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||||
@@ -346,28 +347,47 @@ def is_plausible_phone(phone: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def send_email(
|
async def send_email(
|
||||||
to: str, subject: str, html: str, text: str = None, from_addr: str = None
|
to: str, subject: str, html: str, text: str = None,
|
||||||
) -> bool:
|
from_addr: str = None, email_type: str = "ad_hoc",
|
||||||
"""Send email via Resend SDK."""
|
) -> str | None:
|
||||||
|
"""Send email via Resend SDK. Returns resend_id on success, None on failure.
|
||||||
|
|
||||||
|
Truthy string works like True for existing boolean callers; None is falsy.
|
||||||
|
"""
|
||||||
|
sender = from_addr or config.EMAIL_FROM
|
||||||
|
resend_id = None
|
||||||
|
|
||||||
if not config.RESEND_API_KEY:
|
if not config.RESEND_API_KEY:
|
||||||
print(f"[EMAIL] Would send to {to}: {subject}")
|
print(f"[EMAIL] Would send to {to}: {subject}")
|
||||||
return True
|
resend_id = "dev"
|
||||||
|
else:
|
||||||
resend.api_key = config.RESEND_API_KEY
|
resend.api_key = config.RESEND_API_KEY
|
||||||
try:
|
try:
|
||||||
resend.Emails.send(
|
result = resend.Emails.send(
|
||||||
{
|
{
|
||||||
"from": from_addr or config.EMAIL_FROM,
|
"from": sender,
|
||||||
"to": to,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"html": html,
|
"html": html,
|
||||||
"text": text or html,
|
"text": text or html,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return True
|
resend_id = result.get("id") if isinstance(result, dict) else getattr(result, "id", None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[EMAIL] Error sending to {to}: {e}")
|
print(f"[EMAIL] Error sending to {to}: {e}")
|
||||||
return False
|
return None
|
||||||
|
|
||||||
|
# Log to email_log (best-effort, never fail the send)
|
||||||
|
try:
|
||||||
|
await execute(
|
||||||
|
"""INSERT INTO email_log (resend_id, from_addr, to_addr, subject, email_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(resend_id, sender, to, subject, email_type),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
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",
|
"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_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_body": "Hier ist dein Anmeldelink. Er l\u00e4uft in {expiry_minutes} Minuten ab.",
|
||||||
"email_magic_link_btn": "Anmelden",
|
"email_magic_link_btn": "Anmelden \u2192",
|
||||||
"email_magic_link_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
|
"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_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_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_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_btn": "Best\u00e4tigen & Angebot aktivieren",
|
"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_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_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_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_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_greeting": "Hallo {first_name},",
|
||||||
"email_welcome_btn": "Zum Dashboard",
|
"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_subject": "Willkommen bei {app_name}",
|
"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_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_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": "Du erf\u00e4hrst als Erster, wenn wir starten. Wir senden dir fr\u00fchen Zugang, exklusive Launch-Preise und Onboarding-Unterst\u00fctzung.",
|
"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_meanwhile": "In der Zwischenzeit erkunde unsere kostenlosen Ressourcen:",
|
||||||
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
|
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
|
||||||
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis \u2014 verifizierte Anbieter durchsuchen",
|
"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_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_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": "Du bist unter den Ersten, die Zugang erhalten. Wir senden dir:",
|
"email_waitlist_general_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
|
||||||
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang zur gesamten Plattform",
|
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
|
||||||
"email_waitlist_general_perk_2": "Exklusive Launch-Boni",
|
"email_waitlist_general_perk_2": "Exklusive Launch-Preise",
|
||||||
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
|
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
|
||||||
"email_waitlist_general_outro": "Wir melden uns bald.",
|
"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_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_brief": "Projektbeschreibung",
|
||||||
"email_lead_forward_section_contact": "Kontakt",
|
"email_lead_forward_section_contact": "Kontakt",
|
||||||
"email_lead_forward_lbl_facility": "Anlage",
|
"email_lead_forward_lbl_facility": "Anlage",
|
||||||
@@ -1591,27 +1607,38 @@
|
|||||||
"email_lead_forward_lbl_phone": "Telefon",
|
"email_lead_forward_lbl_phone": "Telefon",
|
||||||
"email_lead_forward_lbl_company": "Unternehmen",
|
"email_lead_forward_lbl_company": "Unternehmen",
|
||||||
"email_lead_forward_lbl_role": "Rolle",
|
"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_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_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_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_heading": "Neue Anfrage von {contact_name}",
|
||||||
"email_enquiry_body": "Du hast eine neue Verzeichnisanfrage f\u00fcr <strong>{supplier_name}</strong>.",
|
"email_enquiry_body": "Du hast eine neue Anfrage \u00fcber deinen <strong>{supplier_name}</strong>-Verzeichniseintrag.",
|
||||||
"email_enquiry_lbl_from": "Von",
|
"email_enquiry_lbl_from": "Von",
|
||||||
"email_enquiry_lbl_message": "Nachricht",
|
"email_enquiry_lbl_message": "Nachricht",
|
||||||
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\">{contact_email}</a>.",
|
"email_enquiry_respond_fast": "Antworte innerhalb von 24 Stunden f\u00fcr den besten Eindruck.",
|
||||||
"email_enquiry_subject": "Neue Anfrage \u00fcber {app_name}: {contact_name}",
|
"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_heading": "Dein Businessplan ist fertig",
|
||||||
"email_business_plan_body": "Dein Padel-Businessplan wurde als PDF erstellt und steht zum Download bereit.",
|
"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_includes": "Dein Plan enth\u00e4lt Investitions\u00fcbersicht, Umsatzprognosen und Break-Even-Analyse.",
|
||||||
"email_business_plan_subject": "Dein Padel-Businessplan ist fertig",
|
"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_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."
|
"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",
|
"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_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_body": "Here's your sign-in link. It expires in {expiry_minutes} minutes.",
|
||||||
"email_magic_link_btn": "Sign In",
|
"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_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_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_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_body": "Thanks for requesting quotes. Verify your email to activate your quote request and create your {app_name} account.",
|
||||||
"email_quote_verify_btn": "Verify & Activate Quote",
|
"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_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_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_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_heading": "Welcome to {app_name}",
|
||||||
"email_welcome_body": "Thanks for signing up. You're all set to start planning your padel business.",
|
"email_welcome_greeting": "Hi {first_name},",
|
||||||
"email_welcome_btn": "Go to Dashboard",
|
"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_subject": "Welcome to {app_name}",
|
"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_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_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": "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_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_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_planner": "Financial Planning Tool \u2014 model your padel facility",
|
||||||
"email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers",
|
"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_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_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": "You'll be among the first to get access when we open. We'll send you:",
|
"email_waitlist_general_perks_intro": "As an early waitlist member, you'll get:",
|
||||||
"email_waitlist_general_perk_1": "Early access to the full platform",
|
"email_waitlist_general_perk_1": "Early access before public launch",
|
||||||
"email_waitlist_general_perk_2": "Exclusive launch bonuses",
|
"email_waitlist_general_perk_2": "Exclusive launch pricing",
|
||||||
"email_waitlist_general_perk_3": "Priority onboarding and support",
|
"email_waitlist_general_perk_3": "Priority onboarding and support",
|
||||||
"email_waitlist_general_outro": "We'll be in touch soon.",
|
"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_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_brief": "Project Brief",
|
||||||
"email_lead_forward_section_contact": "Contact",
|
"email_lead_forward_section_contact": "Contact",
|
||||||
"email_lead_forward_lbl_facility": "Facility",
|
"email_lead_forward_lbl_facility": "Facility",
|
||||||
@@ -1591,27 +1607,38 @@
|
|||||||
"email_lead_forward_lbl_phone": "Phone",
|
"email_lead_forward_lbl_phone": "Phone",
|
||||||
"email_lead_forward_lbl_company": "Company",
|
"email_lead_forward_lbl_company": "Company",
|
||||||
"email_lead_forward_lbl_role": "Role",
|
"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_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_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_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_heading": "New enquiry from {contact_name}",
|
||||||
"email_enquiry_body": "You have a new directory enquiry for <strong>{supplier_name}</strong>.",
|
"email_enquiry_body": "You have a new enquiry via your <strong>{supplier_name}</strong> directory listing.",
|
||||||
"email_enquiry_lbl_from": "From",
|
"email_enquiry_lbl_from": "From",
|
||||||
"email_enquiry_lbl_message": "Message",
|
"email_enquiry_lbl_message": "Message",
|
||||||
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\">{contact_email}</a> to respond.",
|
"email_enquiry_respond_fast": "Respond within 24 hours for the best impression.",
|
||||||
"email_enquiry_subject": "New enquiry via {app_name}: {contact_name}",
|
"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_body": "Your padel business plan PDF has been generated and is ready for download.",
|
||||||
"email_business_plan_btn": "Download PDF",
|
"email_business_plan_includes": "Your plan includes investment breakdown, revenue projections, and break-even analysis.",
|
||||||
"email_business_plan_subject": "Your Padel Business Plan PDF is Ready",
|
"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_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."
|
"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
|
return raw.format(**kwargs) if kwargs else raw
|
||||||
|
|
||||||
|
|
||||||
def _email_wrap(body: str, lang: str = "en") -> str:
|
def _email_wrap(body: str, lang: str = "en", preheader: str = "") -> str:
|
||||||
"""Wrap email body in a branded layout with inline CSS."""
|
"""Wrap email body in a branded layout with inline CSS.
|
||||||
|
|
||||||
|
preheader: hidden preview text shown in email client list views.
|
||||||
|
"""
|
||||||
year = datetime.utcnow().year
|
year = datetime.utcnow().year
|
||||||
tagline = _t("email_footer_tagline", lang)
|
tagline = _t("email_footer_tagline", lang)
|
||||||
copyright_text = _t("email_footer_copyright", lang, year=year, app_name=config.APP_NAME)
|
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"""\
|
return f"""\
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{lang}">
|
<html lang="{lang}">
|
||||||
@@ -38,23 +50,19 @@ def _email_wrap(body: str, lang: str = "en") -> str:
|
|||||||
<title>{config.APP_NAME}</title>
|
<title>{config.APP_NAME}</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;padding:0;background-color:#F1F5F9;font-family:Helvetica,Arial,sans-serif;">
|
<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;">
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F1F5F9;padding:40px 16px;">
|
||||||
<tr><td align="center">
|
<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;">
|
<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 -->
|
<!-- Blue accent border -->
|
||||||
<tr><td style="background-color:#0F172A;padding:28px 36px 24px;">
|
<tr><td style="height:3px;background-color:#1D4ED8;font-size:0;line-height:0;"> </td></tr>
|
||||||
<table cellpadding="0" cellspacing="0">
|
|
||||||
<tr>
|
<!-- Wordmark header -->
|
||||||
<td style="vertical-align:middle;">
|
<tr><td style="background-color:#0F172A;padding:24px 36px;">
|
||||||
<!-- Padel racket monogram -->
|
<a href="{config.BASE_URL}" style="text-decoration:none;">
|
||||||
<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>
|
<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>
|
||||||
</td>
|
</a>
|
||||||
<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>
|
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
@@ -68,7 +76,7 @@ def _email_wrap(body: str, lang: str = "en") -> str:
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
|
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
|
||||||
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
|
<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}
|
{tagline}
|
||||||
</p>
|
</p>
|
||||||
@@ -85,12 +93,16 @@ def _email_wrap(body: str, lang: str = "en") -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _email_button(url: str, label: str) -> 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 (
|
return (
|
||||||
f'<table cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">'
|
f'<table cellpadding="0" cellspacing="0" width="100%" style="margin:28px 0 8px;">'
|
||||||
f'<tr><td style="background-color:#1D4ED8;border-radius:7px;text-align:center;">'
|
f'<tr><td style="background-color:#1D4ED8;border-radius:8px;text-align:center;">'
|
||||||
f'<a href="{url}" style="display:inline-block;padding:13px 30px;'
|
f'<a href="{url}" style="display:block;padding:14px 32px;'
|
||||||
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">'
|
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;'
|
||||||
|
f'letter-spacing:-0.01em;">'
|
||||||
f"{label}</a></td></tr></table>"
|
f"{label}</a></td></tr></table>"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,6 +197,7 @@ async def handle_send_email(payload: dict) -> None:
|
|||||||
html=payload["html"],
|
html=payload["html"],
|
||||||
text=payload.get("text"),
|
text=payload.get("text"),
|
||||||
from_addr=payload.get("from_addr"),
|
from_addr=payload.get("from_addr"),
|
||||||
|
email_type="generic",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -200,9 +213,11 @@ async def handle_send_magic_link(payload: dict) -> None:
|
|||||||
print(f" {link}")
|
print(f" {link}")
|
||||||
print(f"{'=' * 60}\n")
|
print(f"{'=' * 60}\n")
|
||||||
|
|
||||||
|
expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES
|
||||||
body = (
|
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'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">{_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'<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'{_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;">{_t("email_magic_link_fallback", lang)}</p>'
|
||||||
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</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(
|
await send_email(
|
||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
|
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
|
||||||
html=_email_wrap(body, lang),
|
html=_email_wrap(body, lang, preheader=_t("email_magic_link_preheader", lang, expiry_minutes=expiry_minutes)),
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
|
email_type="magic_link",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -235,21 +251,36 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
|||||||
first_name = (
|
first_name = (
|
||||||
payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
|
payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
|
||||||
)
|
)
|
||||||
project_desc = ""
|
court_count = payload.get("court_count", "")
|
||||||
parts = []
|
facility_type = payload.get("facility_type", "")
|
||||||
if payload.get("court_count"):
|
country = payload.get("country", "")
|
||||||
parts.append(f"{payload['court_count']}-court")
|
|
||||||
if payload.get("facility_type"):
|
# Project recap card
|
||||||
parts.append(payload["facility_type"])
|
project_card = ""
|
||||||
if payload.get("country"):
|
recap_parts = []
|
||||||
parts.append(f"in {payload['country']}")
|
if court_count:
|
||||||
if parts:
|
recap_parts.append(f"{court_count} courts")
|
||||||
project_desc = f" for your {' '.join(parts)} project"
|
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 = (
|
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_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'{_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_expires", lang)}</p>'
|
||||||
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_fallback", 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(
|
await send_email(
|
||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=_t("email_quote_verify_subject", lang),
|
subject=_t("email_quote_verify_subject", lang),
|
||||||
html=_email_wrap(body, lang),
|
html=_email_wrap(body, lang, preheader=preheader),
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
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:
|
async def handle_send_welcome(payload: dict) -> None:
|
||||||
"""Send welcome email to new user."""
|
"""Send welcome email to new user."""
|
||||||
lang = payload.get("lang", "en")
|
lang = payload.get("lang", "en")
|
||||||
|
name_parts = (payload.get("name") or "").split()
|
||||||
|
first_name = name_parts[0] if name_parts else "there"
|
||||||
|
|
||||||
body = (
|
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'<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(
|
await send_email(
|
||||||
to=payload["email"],
|
to=payload["email"],
|
||||||
subject=_t("email_welcome_subject", lang, app_name=config.APP_NAME),
|
subject=_t("email_welcome_subject", lang),
|
||||||
html=_email_wrap(body, lang),
|
html=_email_wrap(body, lang, preheader=_t("email_welcome_preheader", lang)),
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
|
email_type="welcome",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -292,21 +339,30 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
|
|||||||
|
|
||||||
if intent.startswith("supplier_"):
|
if intent.startswith("supplier_"):
|
||||||
plan_name = intent.replace("supplier_", "").title()
|
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 = (
|
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_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'<p style="font-size:13px;color:#64748B;">{_t("email_waitlist_supplier_meanwhile", lang)}</p>'
|
||||||
f'<ul style="font-size:13px;color:#64748B;">'
|
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}/planner" style="color:#1D4ED8;">{_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}/directory" style="color:#1D4ED8;">{_t("email_waitlist_supplier_link_directory", lang)}</a></li>'
|
||||||
f'</ul>'
|
f'</ul>'
|
||||||
)
|
)
|
||||||
else:
|
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 = (
|
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_body", lang)}</p>'
|
||||||
f'<p>{_t("email_waitlist_general_perks_intro", lang)}</p>'
|
f'<p>{_t("email_waitlist_general_perks_intro", lang)}</p>'
|
||||||
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
|
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(
|
await send_email(
|
||||||
to=email,
|
to=email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
html=_email_wrap(body, lang),
|
html=_email_wrap(body, lang, preheader=preheader),
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
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"
|
country = lead["country"] or "Unknown"
|
||||||
courts = lead["court_count"] or "?"
|
courts = lead["court_count"] or "?"
|
||||||
budget = lead["budget_estimate"] 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 = [
|
brief_rows = [
|
||||||
(t("email_lead_forward_lbl_facility"), f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
|
(tl("email_lead_forward_lbl_facility"), f"{facility_type} ({lead['build_context'] or '-'})"),
|
||||||
(t("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
|
(tl("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}"),
|
(tl("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"),
|
||||||
(t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: \u20ac{budget}"),
|
(tl("email_lead_forward_lbl_timeline"), f"{timeline or '-'} | Budget: \u20ac{budget}"),
|
||||||
(t("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
|
(tl("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 "-"),
|
(tl("email_lead_forward_lbl_services"), lead["services_needed"] or "-"),
|
||||||
(t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
|
(tl("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
|
||||||
]
|
]
|
||||||
|
|
||||||
brief_html = ""
|
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>'
|
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</td></tr>'
|
||||||
)
|
)
|
||||||
|
|
||||||
contact_rows = [
|
contact_name = lead["contact_name"] or "-"
|
||||||
(t("email_lead_forward_lbl_name"), lead["contact_name"] or "-"),
|
contact_phone = lead["contact_phone"] 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_html = ""
|
# Contact section with prominent email
|
||||||
for label, value in contact_rows:
|
contact_html = (
|
||||||
contact_html += (
|
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{tl("email_lead_forward_lbl_name")}</td>'
|
||||||
f'<tr><td style="padding:4px 12px 4px 0;color:#94A3B8;font-size:13px">{label}</td>'
|
f'<td style="padding:4px 0;font-size:14px;color:#0F172A;font-weight:600">{contact_name}</td></tr>'
|
||||||
f'<td style="padding:4px 0;font-size:13px;color:#1E293B">{value}</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 = (
|
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:#334155;margin:0 0 16px;padding:10px 14px;'
|
||||||
f'<p style="font-size:13px;color:#64748B;margin:0 0 16px">{t("email_lead_forward_subheading")}</p>'
|
f'background-color:#FEF3C7;border-radius:6px;border-left:3px solid #F59E0B;">'
|
||||||
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'{_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'<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'<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
|
# 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(
|
await send_email(
|
||||||
to=to_email,
|
to=to_email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
html=_email_wrap(body, lang),
|
html=_email_wrap(body, lang, preheader=", ".join(preheader_parts)),
|
||||||
from_addr=EMAIL_ADDRESSES["leads"],
|
from_addr=EMAIL_ADDRESSES["leads"],
|
||||||
|
email_type="lead_forward",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update email_sent_at on lead_forward
|
# Update email_sent_at on lead_forward
|
||||||
@@ -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"
|
first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there"
|
||||||
|
|
||||||
body = (
|
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_greeting", lang, first_name=first_name)}</p>'
|
||||||
f'<p>{_t("email_lead_matched_body", lang)}</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>'
|
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'{_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>'
|
f'<p style="font-size:12px;color:#94A3B8;">{_t("email_lead_matched_note", lang)}</p>'
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to=lead["contact_email"],
|
to=lead["contact_email"],
|
||||||
subject=_t("email_lead_matched_subject", lang),
|
subject=_t("email_lead_matched_subject", lang, first_name=first_name),
|
||||||
html=_email_wrap(body, lang),
|
html=_email_wrap(body, lang, preheader=_t("email_lead_matched_preheader", lang)),
|
||||||
from_addr=EMAIL_ADDRESSES["leads"],
|
from_addr=EMAIL_ADDRESSES["leads"],
|
||||||
|
email_type="lead_matched",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -464,8 +554,9 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
|
|||||||
message = payload.get("message", "")
|
message = payload.get("message", "")
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">'
|
f'<h2 style="margin:0 0 8px;color:#0F172A;font-size:22px;">'
|
||||||
f'{_t("email_enquiry_heading", lang, app_name=config.APP_NAME)}</h2>'
|
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'<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'<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>'
|
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'<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'<td style="padding:6px 0;white-space:pre-wrap">{message}</td></tr>'
|
||||||
f'</table>'
|
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>'
|
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_reply", lang, contact_email=contact_email)}</p>'
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_email(
|
await send_email(
|
||||||
to=supplier_email,
|
to=supplier_email,
|
||||||
subject=_t("email_enquiry_subject", lang, app_name=config.APP_NAME, contact_name=contact_name),
|
subject=_t("email_enquiry_subject", lang, contact_name=contact_name),
|
||||||
html=_email_wrap(body, lang),
|
html=_email_wrap(body, lang, preheader=_t("email_enquiry_preheader", lang)),
|
||||||
from_addr=EMAIL_ADDRESSES["transactional"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
|
email_type="supplier_enquiry",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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,))
|
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
|
||||||
if user:
|
if user:
|
||||||
body = (
|
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>{_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'{_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(
|
await send_email(
|
||||||
to=user["email"],
|
to=user["email"],
|
||||||
subject=_t("email_business_plan_subject", language),
|
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"],
|
from_addr=EMAIL_ADDRESSES["transactional"],
|
||||||
|
email_type="business_plan",
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"[WORKER] Generated business plan PDF: export_id={export_id}")
|
print(f"[WORKER] Generated business plan PDF: export_id={export_id}")
|
||||||
|
|||||||
842
web/tests/test_emails.py
Normal file
842
web/tests/test_emails.py
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
"""
|
||||||
|
Tests for all transactional email worker handlers.
|
||||||
|
|
||||||
|
Each handler is tested by mocking send_email and calling the handler
|
||||||
|
directly with a payload. Assertions cover: recipient, subject keywords,
|
||||||
|
HTML content (design elements, i18n keys resolved), and from_addr.
|
||||||
|
|
||||||
|
The TestResendLive class at the bottom sends real emails via Resend's
|
||||||
|
@resend.dev test addresses. Skipped unless RESEND_API_KEY is set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from padelnomics.worker import (
|
||||||
|
handle_send_lead_forward_email,
|
||||||
|
handle_send_lead_matched_notification,
|
||||||
|
handle_send_magic_link,
|
||||||
|
handle_send_quote_verification,
|
||||||
|
handle_send_supplier_enquiry_email,
|
||||||
|
handle_send_waitlist_confirmation,
|
||||||
|
handle_send_welcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
from padelnomics import core
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _call_kwargs(mock_send: AsyncMock) -> dict:
|
||||||
|
"""Extract kwargs from first call to mock send_email."""
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
return mock_send.call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_common_design(html: str, lang: str = "en"):
|
||||||
|
"""Verify shared email wrapper design elements are present."""
|
||||||
|
assert "padelnomics" in html # wordmark
|
||||||
|
assert "Bricolage Grotesque" in html # brand font
|
||||||
|
assert "#1D4ED8" in html # blue accent / button
|
||||||
|
assert "padelnomics.io" in html # footer link
|
||||||
|
assert f'lang="{lang}"' in html # html lang attribute
|
||||||
|
|
||||||
|
|
||||||
|
# ── Magic Link ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMagicLink:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_to_correct_recipient(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
|
||||||
|
kw = _call_kwargs(mock_send)
|
||||||
|
assert kw["to"] == "user@example.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subject_contains_app_name(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
|
||||||
|
kw = _call_kwargs(mock_send)
|
||||||
|
assert core.config.APP_NAME.lower() in kw["subject"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_verify_link(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_magic_link({"email": "user@example.com", "token": "abc123"})
|
||||||
|
kw = _call_kwargs(mock_send)
|
||||||
|
assert "/auth/verify?token=abc123" in kw["html"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_fallback_link_text(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "word-break:break-all" in html # fallback URL block
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_transactional_from_addr(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
|
||||||
|
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preheader_mentions_expiry(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
# preheader is hidden span; should mention minutes
|
||||||
|
assert "display:none" in html # preheader present
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_design_elements_present(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_magic_link({"email": "user@example.com", "token": "tok"})
|
||||||
|
_assert_common_design(_call_kwargs(mock_send)["html"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_respects_lang_parameter(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_magic_link({"email": "user@example.com", "token": "tok", "lang": "de"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
_assert_common_design(html, lang="de")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Welcome ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestWelcome:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_to_correct_recipient(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_welcome({"email": "new@example.com"})
|
||||||
|
assert _call_kwargs(mock_send)["to"] == "new@example.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subject_not_empty(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_welcome({"email": "new@example.com"})
|
||||||
|
assert len(_call_kwargs(mock_send)["subject"]) > 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_quickstart_links(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_welcome({"email": "new@example.com"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "/planner" in html
|
||||||
|
assert "/markets" in html
|
||||||
|
assert "/leads/quote" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_first_name_when_provided(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_welcome({"email": "new@example.com", "name": "Alice Smith"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "Alice" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_greeting_when_no_name(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_welcome({"email": "new@example.com"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
# Should use "there" as fallback first_name
|
||||||
|
assert "there" in html.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_transactional_from_addr(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_welcome({"email": "new@example.com"})
|
||||||
|
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_design_elements_present(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_welcome({"email": "new@example.com"})
|
||||||
|
_assert_common_design(_call_kwargs(mock_send)["html"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_german_welcome(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_welcome({"email": "new@example.com", "lang": "de"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
_assert_common_design(html, lang="de")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quote Verification ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuoteVerification:
|
||||||
|
_BASE_PAYLOAD = {
|
||||||
|
"email": "lead@example.com",
|
||||||
|
"token": "verify_tok",
|
||||||
|
"lead_token": "lead_tok",
|
||||||
|
"contact_name": "Bob Builder",
|
||||||
|
"court_count": 6,
|
||||||
|
"facility_type": "Indoor",
|
||||||
|
"country": "Germany",
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_to_contact_email(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_quote_verification(self._BASE_PAYLOAD)
|
||||||
|
assert _call_kwargs(mock_send)["to"] == "lead@example.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_verify_link(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_quote_verification(self._BASE_PAYLOAD)
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "token=verify_tok" in html
|
||||||
|
assert "lead=lead_tok" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_project_recap(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_quote_verification(self._BASE_PAYLOAD)
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "6 courts" in html
|
||||||
|
assert "Indoor" in html
|
||||||
|
assert "Germany" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_first_name_from_contact(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_quote_verification(self._BASE_PAYLOAD)
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "Bob" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_minimal_payload(self):
|
||||||
|
"""No court_count/facility_type/country — should still send."""
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_quote_verification({
|
||||||
|
"email": "lead@example.com",
|
||||||
|
"token": "tok",
|
||||||
|
"lead_token": "ltok",
|
||||||
|
})
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_transactional_from_addr(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_quote_verification(self._BASE_PAYLOAD)
|
||||||
|
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_design_elements_present(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_quote_verification(self._BASE_PAYLOAD)
|
||||||
|
_assert_common_design(_call_kwargs(mock_send)["html"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lead Forward (the money email) ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeadForward:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_to_supplier_email(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
assert _call_kwargs(mock_send)["to"] == "supplier@test.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subject_contains_heat_and_country(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
subject = _call_kwargs(mock_send)["subject"]
|
||||||
|
assert "[HOT]" in subject
|
||||||
|
assert "Germany" in subject
|
||||||
|
assert "4 courts" in subject
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_heat_badge(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "#DC2626" in html # HOT badge color
|
||||||
|
assert "HOT" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_project_brief(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "Indoor" in html
|
||||||
|
assert "Germany" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_contact_info(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "lead@buyer.com" in html
|
||||||
|
assert "mailto:lead@buyer.com" in html
|
||||||
|
assert "John Doe" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_urgency_callout(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
# Urgency callout has yellow background
|
||||||
|
assert "#FEF3C7" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_direct_reply_cta(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
# Direct reply link text should mention the contact email
|
||||||
|
assert "lead@buyer.com" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_leads_from_addr(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_email_sent_at(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db, create_forward=True)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock):
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT email_sent_at FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
|
||||||
|
(lead_id, supplier_id),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row["email_sent_at"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_when_no_supplier_email(self, db):
|
||||||
|
"""No email on supplier record — handler exits without sending."""
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db, supplier_email="")
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_when_lead_not_found(self, db):
|
||||||
|
"""Non-existent lead_id — handler exits without sending."""
|
||||||
|
_, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": 99999, "supplier_id": supplier_id})
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_design_elements_present(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_forward_email({"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
_assert_common_design(_call_kwargs(mock_send)["html"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lead Matched Notification ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeadMatched:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_to_lead_contact_email(self, db):
|
||||||
|
lead_id = await _seed_lead(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_matched_notification({"lead_id": lead_id})
|
||||||
|
assert _call_kwargs(mock_send)["to"] == "lead@buyer.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subject_contains_first_name(self, db):
|
||||||
|
lead_id = await _seed_lead(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_matched_notification({"lead_id": lead_id})
|
||||||
|
assert "John" in _call_kwargs(mock_send)["subject"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_what_happens_next(self, db):
|
||||||
|
lead_id = await _seed_lead(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_matched_notification({"lead_id": lead_id})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
# "What happens next" section and tip callout (blue bg)
|
||||||
|
assert "#F0F9FF" in html # tip callout background
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_project_context(self, db):
|
||||||
|
lead_id = await _seed_lead(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_matched_notification({"lead_id": lead_id})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "Indoor" in html
|
||||||
|
assert "Germany" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_leads_from_addr(self, db):
|
||||||
|
lead_id = await _seed_lead(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_matched_notification({"lead_id": lead_id})
|
||||||
|
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["leads"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_when_lead_not_found(self, db):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_matched_notification({"lead_id": 99999})
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_when_no_contact_email(self, db):
|
||||||
|
lead_id = await _seed_lead(db, contact_email="")
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_matched_notification({"lead_id": lead_id})
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_design_elements_present(self, db):
|
||||||
|
lead_id = await _seed_lead(db)
|
||||||
|
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_lead_matched_notification({"lead_id": lead_id})
|
||||||
|
_assert_common_design(_call_kwargs(mock_send)["html"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Supplier Enquiry ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSupplierEnquiry:
|
||||||
|
_BASE_PAYLOAD = {
|
||||||
|
"supplier_email": "supplier@corp.com",
|
||||||
|
"supplier_name": "PadelBuild GmbH",
|
||||||
|
"contact_name": "Alice Smith",
|
||||||
|
"contact_email": "alice@buyer.com",
|
||||||
|
"message": "I need 4 courts, can you quote?",
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_to_supplier_email(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
|
||||||
|
assert _call_kwargs(mock_send)["to"] == "supplier@corp.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subject_contains_contact_name(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
|
||||||
|
assert "Alice Smith" in _call_kwargs(mock_send)["subject"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_message(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "4 courts" in html
|
||||||
|
assert "alice@buyer.com" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_html_contains_respond_fast_nudge(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
# The respond-fast nudge line should be present
|
||||||
|
assert "24" in html # "24 hours" reference
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_when_no_supplier_email(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_supplier_enquiry_email({**self._BASE_PAYLOAD, "supplier_email": ""})
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_transactional_from_addr(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
|
||||||
|
assert _call_kwargs(mock_send)["from_addr"] == core.EMAIL_ADDRESSES["transactional"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_design_elements_present(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_supplier_enquiry_email(self._BASE_PAYLOAD)
|
||||||
|
_assert_common_design(_call_kwargs(mock_send)["html"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Waitlist (supplement existing test_waitlist.py) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitlistEmails:
|
||||||
|
"""Verify design & content for waitlist confirmation emails."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_general_waitlist_has_preheader(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
assert "display:none" in html # preheader span
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_supplier_waitlist_mentions_plan(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_growth"})
|
||||||
|
kw = _call_kwargs(mock_send)
|
||||||
|
assert "growth" in kw["subject"].lower()
|
||||||
|
assert "supplier" in kw["html"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_general_waitlist_design_elements(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_waitlist_confirmation({"email": "u@example.com", "intent": "signup"})
|
||||||
|
_assert_common_design(_call_kwargs(mock_send)["html"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_supplier_waitlist_perks_listed(self):
|
||||||
|
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_send:
|
||||||
|
await handle_send_waitlist_confirmation({"email": "s@example.com", "intent": "supplier_pro"})
|
||||||
|
html = _call_kwargs(mock_send)["html"]
|
||||||
|
# Should have <li> perks
|
||||||
|
assert html.count("<li>") >= 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB seed helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_lead(
|
||||||
|
db,
|
||||||
|
contact_email: str = "lead@buyer.com",
|
||||||
|
contact_name: str = "John Doe",
|
||||||
|
) -> int:
|
||||||
|
"""Insert a lead_request row, return its id."""
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
async with db.execute(
|
||||||
|
"""INSERT INTO lead_requests (
|
||||||
|
lead_type, contact_name, contact_email, contact_phone,
|
||||||
|
contact_company, stakeholder_type, facility_type, court_count,
|
||||||
|
country, location, build_context, glass_type, lighting_type,
|
||||||
|
timeline, budget_estimate, location_status, financing_status,
|
||||||
|
services_needed, additional_info, heat_score,
|
||||||
|
status, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
"quote", contact_name, contact_email, "+49123456",
|
||||||
|
"Padel Corp", "investor", "Indoor", 4,
|
||||||
|
"Germany", "Berlin", "new_build", "tempered", "LED",
|
||||||
|
"6-12 months", 200000, "secured", "seeking",
|
||||||
|
"construction, lighting", "Need turnkey solution", "hot",
|
||||||
|
"verified", now,
|
||||||
|
),
|
||||||
|
) as cursor:
|
||||||
|
lead_id = cursor.lastrowid
|
||||||
|
await db.commit()
|
||||||
|
return lead_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_supplier(
|
||||||
|
db,
|
||||||
|
contact_email: str = "supplier@test.com",
|
||||||
|
) -> int:
|
||||||
|
"""Insert a supplier row, return its id."""
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
async with db.execute(
|
||||||
|
"""INSERT INTO suppliers (
|
||||||
|
name, slug, country_code, region, category,
|
||||||
|
contact_email, tier, claimed_by, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
"Test Supplier", "test-supplier", "DE", "Europe", "construction",
|
||||||
|
contact_email, "growth", None, now,
|
||||||
|
),
|
||||||
|
) as cursor:
|
||||||
|
supplier_id = cursor.lastrowid
|
||||||
|
await db.commit()
|
||||||
|
return supplier_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_lead_and_supplier(
|
||||||
|
db,
|
||||||
|
supplier_email: str = "supplier@test.com",
|
||||||
|
create_forward: bool = False,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Insert a lead + supplier pair, optionally with a lead_forwards row."""
|
||||||
|
lead_id = await _seed_lead(db)
|
||||||
|
supplier_id = await _seed_supplier(db, contact_email=supplier_email)
|
||||||
|
|
||||||
|
if create_forward:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)""",
|
||||||
|
(lead_id, supplier_id, 1, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return lead_id, supplier_id
|
||||||
|
|
||||||
|
|
||||||
|
# ── Live Resend integration tests ────────────────────────────────
|
||||||
|
#
|
||||||
|
# These send real emails to Resend's @resend.dev test addresses,
|
||||||
|
# then retrieve the sent email via resend.Emails.get() to verify the
|
||||||
|
# rendered HTML contains expected design elements and content.
|
||||||
|
#
|
||||||
|
# Skipped unless RESEND_API_KEY is set in the environment.
|
||||||
|
# Run with: RESEND_API_KEY=re_xxx uv run pytest web/tests/test_emails.py -k resend_live -v
|
||||||
|
|
||||||
|
_has_resend_key = bool(os.environ.get("RESEND_API_KEY"))
|
||||||
|
_skip_no_key = pytest.mark.skipif(not _has_resend_key, reason="RESEND_API_KEY not set")
|
||||||
|
|
||||||
|
|
||||||
|
def _send_and_capture():
|
||||||
|
"""Patch resend.Emails.send to capture the returned email ID.
|
||||||
|
|
||||||
|
Returns (wrapper, captured) where captured is a dict that will
|
||||||
|
contain {"id": ...} after send is called. The real send still
|
||||||
|
executes — this just grabs the return value that core.send_email
|
||||||
|
discards.
|
||||||
|
"""
|
||||||
|
import resend as _resend
|
||||||
|
|
||||||
|
_original_send = _resend.Emails.send
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _wrapper(params):
|
||||||
|
result = _original_send(params)
|
||||||
|
captured["id"] = result.id if hasattr(result, "id") else result.get("id")
|
||||||
|
return result
|
||||||
|
|
||||||
|
return _wrapper, captured
|
||||||
|
|
||||||
|
|
||||||
|
def _retrieve_html(captured: dict) -> str | None:
|
||||||
|
"""Retrieve the sent email from Resend and return its HTML.
|
||||||
|
|
||||||
|
Returns None if:
|
||||||
|
- The send failed (rate limit, network error) — captured has no id
|
||||||
|
- The API key lacks read permission (sending_access only)
|
||||||
|
"""
|
||||||
|
import resend as _resend
|
||||||
|
|
||||||
|
email_id = captured.get("id")
|
||||||
|
if not email_id:
|
||||||
|
return None # send failed (e.g. rate limit) — can't retrieve
|
||||||
|
try:
|
||||||
|
email = _resend.Emails.get(email_id)
|
||||||
|
except Exception:
|
||||||
|
# sending_access key — can't retrieve, that's OK
|
||||||
|
return None
|
||||||
|
return email.html if hasattr(email, "html") else email.get("html", "")
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_no_key
|
||||||
|
class TestResendLive:
|
||||||
|
"""Send real emails via Resend to @resend.dev test addresses.
|
||||||
|
|
||||||
|
Uses ``delivered@resend.dev`` which simulates successful delivery.
|
||||||
|
After sending, retrieves the email via resend.Emails.get() and
|
||||||
|
asserts on the rendered HTML (design elements, content, structure).
|
||||||
|
|
||||||
|
Each test calls the handler WITHOUT mocking send_email, so the full
|
||||||
|
path is exercised: handler → _email_wrap → send_email → Resend API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def _set_resend_key(self):
|
||||||
|
"""Ensure config picks up the real API key from env.
|
||||||
|
|
||||||
|
Adds a 0.6s pause between tests to respect Resend's 2 req/sec
|
||||||
|
rate limit.
|
||||||
|
"""
|
||||||
|
import resend as _resend
|
||||||
|
|
||||||
|
original = core.config.RESEND_API_KEY
|
||||||
|
core.config.RESEND_API_KEY = os.environ["RESEND_API_KEY"]
|
||||||
|
_resend.api_key = os.environ["RESEND_API_KEY"]
|
||||||
|
yield
|
||||||
|
core.config.RESEND_API_KEY = original
|
||||||
|
await asyncio.sleep(0.6)
|
||||||
|
|
||||||
|
def _check_html(self, captured: dict, assertions: list[tuple[str, str]]):
|
||||||
|
"""Retrieve sent email and run assertions on HTML.
|
||||||
|
|
||||||
|
If the API key lacks read permission (sending_access only),
|
||||||
|
the retrieve is skipped — the test still passes because the
|
||||||
|
send itself succeeded (no exception from handler).
|
||||||
|
|
||||||
|
assertions: list of (needle, description) tuples.
|
||||||
|
"""
|
||||||
|
html = _retrieve_html(captured)
|
||||||
|
if html is None:
|
||||||
|
return # sending_access key — can't retrieve, send was enough
|
||||||
|
for needle, desc in assertions:
|
||||||
|
assert needle in html, f"Expected {desc!r} ({needle!r}) in rendered HTML"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_magic_link_delivered(self):
|
||||||
|
wrapper, captured = _send_and_capture()
|
||||||
|
with patch("resend.Emails.send", side_effect=wrapper):
|
||||||
|
await handle_send_magic_link({
|
||||||
|
"email": "delivered@resend.dev",
|
||||||
|
"token": "test_token_magic",
|
||||||
|
"lang": "en",
|
||||||
|
})
|
||||||
|
self._check_html(captured, [
|
||||||
|
("padelnomics", "wordmark"),
|
||||||
|
("Bricolage Grotesque", "brand font"),
|
||||||
|
("/auth/verify?token=test_token_magic", "verify link"),
|
||||||
|
("padelnomics.io", "footer link"),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_welcome_delivered(self):
|
||||||
|
wrapper, captured = _send_and_capture()
|
||||||
|
with patch("resend.Emails.send", side_effect=wrapper):
|
||||||
|
await handle_send_welcome({
|
||||||
|
"email": "delivered@resend.dev",
|
||||||
|
"name": "Test User",
|
||||||
|
"lang": "en",
|
||||||
|
})
|
||||||
|
self._check_html(captured, [
|
||||||
|
("Test", "first name"),
|
||||||
|
("/planner", "planner link"),
|
||||||
|
("/markets", "markets link"),
|
||||||
|
("/leads/quote", "quotes link"),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quote_verification_delivered(self):
|
||||||
|
wrapper, captured = _send_and_capture()
|
||||||
|
with patch("resend.Emails.send", side_effect=wrapper):
|
||||||
|
await handle_send_quote_verification({
|
||||||
|
"email": "delivered@resend.dev",
|
||||||
|
"token": "test_verify_tok",
|
||||||
|
"lead_token": "test_lead_tok",
|
||||||
|
"contact_name": "Test Buyer",
|
||||||
|
"court_count": 4,
|
||||||
|
"facility_type": "Indoor",
|
||||||
|
"country": "Germany",
|
||||||
|
"lang": "en",
|
||||||
|
})
|
||||||
|
self._check_html(captured, [
|
||||||
|
("4 courts", "court count"),
|
||||||
|
("Indoor", "facility type"),
|
||||||
|
("Germany", "country"),
|
||||||
|
("token=test_verify_tok", "verify token"),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_waitlist_general_delivered(self):
|
||||||
|
wrapper, captured = _send_and_capture()
|
||||||
|
with patch("resend.Emails.send", side_effect=wrapper):
|
||||||
|
await handle_send_waitlist_confirmation({
|
||||||
|
"email": "delivered@resend.dev",
|
||||||
|
"intent": "signup",
|
||||||
|
"lang": "en",
|
||||||
|
})
|
||||||
|
self._check_html(captured, [
|
||||||
|
("padelnomics", "wordmark"),
|
||||||
|
("<li>", "perk bullets"),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_waitlist_supplier_delivered(self):
|
||||||
|
wrapper, captured = _send_and_capture()
|
||||||
|
with patch("resend.Emails.send", side_effect=wrapper):
|
||||||
|
await handle_send_waitlist_confirmation({
|
||||||
|
"email": "delivered@resend.dev",
|
||||||
|
"intent": "supplier_growth",
|
||||||
|
"lang": "en",
|
||||||
|
})
|
||||||
|
self._check_html(captured, [
|
||||||
|
("Growth", "plan name"),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lead_forward_delivered(self, db):
|
||||||
|
lead_id, supplier_id = await _seed_lead_and_supplier(
|
||||||
|
db, supplier_email="delivered@resend.dev",
|
||||||
|
)
|
||||||
|
wrapper, captured = _send_and_capture()
|
||||||
|
with patch("resend.Emails.send", side_effect=wrapper):
|
||||||
|
await handle_send_lead_forward_email({
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"supplier_id": supplier_id,
|
||||||
|
"lang": "en",
|
||||||
|
})
|
||||||
|
self._check_html(captured, [
|
||||||
|
("#DC2626", "HOT badge color"),
|
||||||
|
("lead@buyer.com", "contact email"),
|
||||||
|
("mailto:lead@buyer.com", "mailto link"),
|
||||||
|
("#FEF3C7", "urgency callout bg"),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lead_matched_delivered(self, db):
|
||||||
|
lead_id = await _seed_lead(db, contact_email="delivered@resend.dev")
|
||||||
|
wrapper, captured = _send_and_capture()
|
||||||
|
with patch("resend.Emails.send", side_effect=wrapper):
|
||||||
|
await handle_send_lead_matched_notification({
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"lang": "en",
|
||||||
|
})
|
||||||
|
self._check_html(captured, [
|
||||||
|
("#F0F9FF", "tip callout bg"),
|
||||||
|
("Indoor", "facility type"),
|
||||||
|
("Germany", "country"),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_supplier_enquiry_delivered(self):
|
||||||
|
wrapper, captured = _send_and_capture()
|
||||||
|
with patch("resend.Emails.send", side_effect=wrapper):
|
||||||
|
await handle_send_supplier_enquiry_email({
|
||||||
|
"supplier_email": "delivered@resend.dev",
|
||||||
|
"supplier_name": "PadelBuild GmbH",
|
||||||
|
"contact_name": "Alice Smith",
|
||||||
|
"contact_email": "alice@buyer.example",
|
||||||
|
"message": "I need 4 courts, can you quote?",
|
||||||
|
"lang": "en",
|
||||||
|
})
|
||||||
|
self._check_html(captured, [
|
||||||
|
("Alice Smith", "contact name"),
|
||||||
|
("alice@buyer.example", "contact email"),
|
||||||
|
("4 courts", "message content"),
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bounce_handled_gracefully(self):
|
||||||
|
"""Sending to bounced@resend.dev should not raise — send_email returns str|None."""
|
||||||
|
result = await core.send_email(
|
||||||
|
to="bounced@resend.dev",
|
||||||
|
subject="Bounce test",
|
||||||
|
html="<p>This should bounce.</p>",
|
||||||
|
from_addr=core.EMAIL_ADDRESSES["transactional"],
|
||||||
|
)
|
||||||
|
# Resend may return success (delivery fails async) or error;
|
||||||
|
# either way the handler must not crash.
|
||||||
|
# send_email now returns resend_id (str) on success, None on failure.
|
||||||
|
assert result is None or isinstance(result, str)
|
||||||
@@ -48,6 +48,8 @@ _IDENTICAL_VALUE_ALLOWLIST = {
|
|||||||
# "Budget", "Name", "Phase", "Investor" — same in both languages
|
# "Budget", "Name", "Phase", "Investor" — same in both languages
|
||||||
"sd_leads_budget", "sd_card_budget", "sd_unlocked_label_budget",
|
"sd_leads_budget", "sd_card_budget", "sd_unlocked_label_budget",
|
||||||
"sd_unlocked_label_name", "sd_unlocked_label_phase", "sd_stakeholder_investor",
|
"sd_unlocked_label_name", "sd_unlocked_label_phase", "sd_stakeholder_investor",
|
||||||
|
# Email lead forward labels — "Phase" and "Name" are identical in DE
|
||||||
|
"email_lead_forward_lbl_phase", "email_lead_forward_lbl_name",
|
||||||
# Listing form labels that are English brand terms / same in DE
|
# Listing form labels that are English brand terms / same in DE
|
||||||
"sd_lst_logo", "sd_lst_website",
|
"sd_lst_logo", "sd_lst_website",
|
||||||
# Boost option name — "Logo" is the same in DE
|
# Boost option name — "Logo" is the same in DE
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class TestWorkerTask:
|
|||||||
mock_send.assert_called_once()
|
mock_send.assert_called_once()
|
||||||
call_args = mock_send.call_args
|
call_args = mock_send.call_args
|
||||||
assert call_args.kwargs["to"] == "entrepreneur@example.com"
|
assert call_args.kwargs["to"] == "entrepreneur@example.com"
|
||||||
assert "launching soon" in call_args.kwargs["subject"].lower()
|
assert "notify you at launch" in call_args.kwargs["subject"].lower()
|
||||||
assert "waitlist" in call_args.kwargs["html"].lower()
|
assert "waitlist" in call_args.kwargs["html"].lower()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user