4.2.2 - tracking across email, fixes to dark mode.

This commit is contained in:
2026-05-06 15:50:01 +12:00
parent a7ce4c74b5
commit 2f4001b8af
11 changed files with 323 additions and 41 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.2
ARG APP_VERSION=4.2.2
FROM python:3.12-slim
ARG APP_VERSION
+210 -12
View File
@@ -141,6 +141,7 @@ logger.info(
)
app = FastAPI(title="GoodWalk Mail API")
STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else ""
app.add_middleware(
CORSMiddleware,
@@ -184,6 +185,12 @@ class BookingSubmission(BaseModel):
services: list[str] = []
website: str = ""
formStartedAt: int | None = None
visitStartedAt: int | None = None
pageEnteredAt: int | None = None
firstInteractionAt: int | None = None
sendClickedAt: int | None = None
stepChanges: int = 0
journey: list[str] = []
referrer: str = ""
page: str = ""
@@ -382,6 +389,13 @@ def _normalize_submission(data: BookingSubmission) -> None:
data.referrer = _trimmed(data.referrer)
data.page = _trimmed(data.page)
data.services = [_trimmed(service) for service in data.services if _trimmed(service)]
data.journey = [_trimmed(step) for step in data.journey if _trimmed(step)][:12]
data.stepChanges = max(0, data.stepChanges)
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
value = getattr(data, field_name)
if value is None or value <= 0:
setattr(data, field_name, None)
if _is_general_enquiry(data):
data.petName = ""
@@ -430,6 +444,33 @@ def _meta_row(label: str, value: str) -> str:
</tr>"""
def _format_duration_ms(duration_ms: int | None) -> str:
if duration_ms is None or duration_ms < 0:
return ""
total_seconds = int(round(duration_ms / 1000))
minutes, seconds = divmod(total_seconds, 60)
hours, minutes = divmod(minutes, 60)
if hours > 0:
return f"{hours}h {minutes}m"
if minutes > 0:
return f"{minutes}m {seconds}s"
return f"{seconds}s"
def _duration_between(start_ms: int | None, end_ms: int | None) -> str:
if start_ms is None or end_ms is None or end_ms < start_ms:
return ""
return _format_duration_ms(end_ms - start_ms)
def _journey_text(journey: list[str]) -> str:
if not journey:
return ""
return " -> ".join(journey)
# ── Email templates ──────────────────────────────────────────────────────────
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
@@ -620,6 +661,12 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark")
page_row = _meta_row("Page", data.page) if data.page else ""
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
page_time_row = _meta_row("Time on page", _duration_between(data.pageEnteredAt, data.sendClickedAt))
active_time_row = _meta_row("Active form time", _duration_between(data.firstInteractionAt, data.sendClickedAt))
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
step_changes_row = _meta_row("Step changes", str(data.stepChanges)) if data.stepChanges else ""
journey_row = _meta_row("Journey", _journey_text(data.journey))
detail_heading = "Enquiry details" if is_general else "Dog &amp; services"
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
@@ -642,29 +689,104 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light only">
<meta name="supported-color-schemes" content="light">
<title>{email_title}</title>
</head>
<body style="margin:0;padding:0;background:#f2f2f0;">
<style>
:root {{
color-scheme: light only;
supported-color-schemes: light;
}}
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
body,
table,
td,
div,
p,
span,
a {{
forced-color-adjust: none !important;
-webkit-text-size-adjust: 100%;
}}
.gw-owner-body {{
background: #f2f2f0 !important;
color: #213021 !important;
}}
.gw-owner-shell {{
background: #ffffff !important;
}}
.gw-owner-dark-panel {{
background: #213021 !important;
}}
.gw-owner-email-chip {{
display: inline-block;
background: #ffffff !important;
color: #213021 !important;
border-radius: 10px;
padding: 12px 14px;
border: 1px solid #d9dfd9;
text-decoration: none !important;
}}
.gw-owner-email-chip,
.gw-owner-email-chip a,
a.gw-owner-email-chip {{
color: #213021 !important;
}}
@media (prefers-color-scheme: dark) {{
html,
body,
.gw-owner-body {{
background: #f2f2f0 !important;
color: #213021 !important;
}}
.gw-owner-shell,
.gw-owner-shell td {{
background: #ffffff !important;
color: #213021 !important;
}}
.gw-owner-dark-panel,
.gw-owner-dark-panel td {{
background: #213021 !important;
}}
.gw-owner-email-chip,
.gw-owner-email-chip a,
a.gw-owner-email-chip {{
background: #ffffff !important;
color: #213021 !important;
}}
}}
</style>
</head>
<body class="gw-owner-body" style="margin:0;padding:0;background:#f2f2f0;color:#213021;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0"
style="background:#f2f2f0;padding:40px 16px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
<table class="gw-owner-shell" width="600" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
box-shadow:0 4px 24px rgba(0,0,0,0.08);background:#ffffff;">
{_logo_header(badge_html=badge)}
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:40px 48px 36px;">
<td bgcolor="#ffffff" style="background:#ffffff;padding:40px 48px 36px;color:#213021;">
<!-- Quick contact -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
<table class="gw-owner-dark-panel" width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#213021"
style="background:#213021;border-radius:12px;margin-bottom:28px;">
<tr>
<td style="padding:22px 24px;">
<td bgcolor="#213021" style="padding:22px 24px;background:#213021;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;
text-transform:uppercase;margin-bottom:10px;">
@@ -674,10 +796,15 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
font-size:14px;color:#d8e6d8;line-height:1.6;margin-bottom:10px;">
Email {first_name} directly:
</div>
<div style="font-family:Menlo,Consolas,'SFMono-Regular',monospace;
font-size:20px;font-weight:700;color:#ffffff;line-height:1.4;
word-break:break-all;margin-bottom:12px;">
{data.email}
<div style="margin-bottom:12px;">
<a href="mailto:{data.email}" class="gw-owner-email-chip"
style="display:inline-block;background:#ffffff;color:#213021 !important;
font-family:Menlo,Consolas,'SFMono-Regular',monospace;
font-size:20px;font-weight:700;line-height:1.4;word-break:break-all;
border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;
text-decoration:none;">
{data.email}
</a>
</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:12px;color:#b7cbb7;line-height:1.6;">
@@ -779,8 +906,14 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{_meta_row("IP address", ip)}
{_meta_row("Browser", browser)}
{visit_time_row}
{page_time_row}
{active_time_row}
{form_time_row}
{step_changes_row}
{referrer_row}
{page_row}
{journey_row}
</table>
</td></tr>
</table>
@@ -853,8 +986,73 @@ async def _send_email(payload: dict, label: str, request_id: str) -> dict:
raise last_exc
async def _send_startup_test_email() -> None:
if not STARTUP_TEST_RECIPIENT:
logger.info("Startup test email skipped: OWNER_BCC is not set to a real address")
return
request_id = "startup-test"
payload = {
"from": FROM_EMAIL,
"to": [STARTUP_TEST_RECIPIENT],
"reply_to": REPLY_TO,
"subject": f"GoodWalk Mail API startup check ({APP_VERSION})",
"html": f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light only">
<meta name="supported-color-schemes" content="light">
<title>GoodWalk Mail API startup check</title>
</head>
<body style="margin:0;padding:24px;background:#f2f2f0;color:#213021;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0" style="background:#f2f2f0;">
<tr>
<td align="center">
<table width="560" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
style="max-width:560px;width:100%;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
{_logo_header(subtitle="Mail API startup check")}
<tr>
<td bgcolor="#ffffff" style="background:#ffffff;padding:36px 40px;">
<h1 style="margin:0 0 12px;font-size:24px;line-height:1.2;color:#213021;">Startup test email</h1>
<p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#4a4f4a;">
The GoodWalk mail service started successfully and sent this boot check to the Gmail monitoring address only.
</p>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f8f7f4;border-radius:12px;">
<tr>
<td style="padding:20px 24px;">
<div style="font-size:13px;line-height:1.7;color:#213021;">
<strong>Version:</strong> {APP_VERSION}<br>
<strong>Started:</strong> {datetime.now().strftime("%d %b %Y %I:%M %p").lstrip("0")}<br>
<strong>Recipient:</strong> {STARTUP_TEST_RECIPIENT}
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>""",
}
await _send_email(payload, label="startup_test_email", request_id=request_id)
# ── Routes ───────────────────────────────────────────────────────────────────
@app.on_event("startup")
async def _startup_mail_check() -> None:
try:
await _send_startup_test_email()
except Exception:
logger.exception("Startup test email failed")
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}