4.2.2 - tracking across email, fixes to dark mode.
This commit is contained in:
+1
-1
@@ -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
@@ -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 & 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"}
|
||||
|
||||
Reference in New Issue
Block a user