Files
gw-svelte/mail-api/main.py
T
2026-05-02 09:08:31 +12:00

660 lines
27 KiB
Python

import asyncio
import logging
import logging.handlers
import os
import random
import sys
import time
import uuid
from datetime import datetime
from pathlib import Path
import resend
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr
# ── Logging ──────────────────────────────────────────────────────────────────
def _setup_logging() -> logging.Logger:
log_dir = Path(os.environ.get("LOG_DIR", "logs"))
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "mail-api.log"
fmt = logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
root = logging.getLogger()
root.setLevel(logging.DEBUG)
for handler in list(root.handlers):
root.removeHandler(handler)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(fmt)
root.addHandler(console)
rotating = logging.handlers.RotatingFileHandler(
log_file, maxBytes=2_000_000, backupCount=5, encoding="utf-8"
)
rotating.setLevel(logging.DEBUG)
rotating.setFormatter(fmt)
root.addHandler(rotating)
log = logging.getLogger("mail-api")
log.info("Logging initialised → console=INFO, file=%s (DEBUG, rotating)", log_file)
return log
logger = _setup_logging()
# ── Configuration ────────────────────────────────────────────────────────────
REQUIRED_ENV = {
"RESEND_API_KEY": "API key from https://resend.com/api-keys",
"OWNER_EMAIL": "Email address that receives new lead notifications",
}
def _load_config() -> dict:
missing = [(name, hint) for name, hint in REQUIRED_ENV.items() if not os.environ.get(name)]
if missing:
lines = [
"",
"Mail API cannot start — required environment variables are not set:",
"",
]
for name, hint in missing:
lines.append(f"{name} ({hint})")
lines += [
"",
"Set them in your shell and try again. For example, in PowerShell:",
"",
]
for name, _ in missing:
lines.append(f' $env:{name} = "..."')
lines.append("")
message = "\n".join(lines)
logger.critical("Startup aborted: missing env vars: %s", [n for n, _ in missing])
print(message, file=sys.stderr)
sys.exit(1)
return {
"resend_api_key": os.environ["RESEND_API_KEY"],
"owner_email": os.environ["OWNER_EMAIL"],
"from_email": os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>"),
"reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"),
"max_attempts": max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))),
}
_config = _load_config()
resend.api_key = _config["resend_api_key"]
OWNER_EMAIL = _config["owner_email"]
FROM_EMAIL = _config["from_email"]
REPLY_TO = _config["reply_to"]
MAX_SEND_ATTEMPTS = _config["max_attempts"]
LOGO_URL = "https://www.goodwalk.co.nz/static/images/goodwalk-auckland-dog-walking-logo.png"
logger.info(
"Mail API config: from=%r reply_to=%r owner=%r max_attempts=%d",
FROM_EMAIL, REPLY_TO, OWNER_EMAIL, MAX_SEND_ATTEMPTS,
)
app = FastAPI(title="GoodWalk Mail API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["POST", "GET"],
allow_headers=["*"],
)
@app.middleware("http")
async def _request_logging_middleware(request: Request, call_next):
request_id = uuid.uuid4().hex[:8]
request.state.request_id = request_id
started = time.monotonic()
try:
response = await call_next(request)
except Exception:
elapsed_ms = (time.monotonic() - started) * 1000
logger.exception(
"[%s] %s %s crashed after %.0fms",
request_id, request.method, request.url.path, elapsed_ms,
)
raise
elapsed_ms = (time.monotonic() - started) * 1000
logger.info(
"[%s] %s %s%d (%.0fms)",
request_id, request.method, request.url.path, response.status_code, elapsed_ms,
)
response.headers["X-Request-ID"] = request_id
return response
class BookingSubmission(BaseModel):
fullName: str
email: EmailStr
phone: str
petName: str
location: str
message: str = ""
services: list[str] = []
referrer: str = ""
page: str = ""
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _parse_ua(ua: str) -> str:
if not ua:
return "Unknown"
browsers = [("Edg/", "Edge"), ("OPR/", "Opera"), ("Chrome/", "Chrome"),
("Firefox/", "Firefox"), ("Safari/", "Safari")]
systems = [("Windows NT 10", "Windows 10/11"), ("Windows NT 6", "Windows 8"),
("Mac OS X", "macOS"), ("iPhone", "iPhone"), ("iPad", "iPad"),
("Android", "Android"), ("Linux", "Linux")]
browser = next((n for p, n in browsers if p in ua), "Unknown browser")
system = next((n for p, n in systems if p in ua), "Unknown OS")
return f"{browser} on {system}"
def _detail_row(label: str, value: str) -> str:
if not value:
return ""
return f"""
<tr>
<td style="padding:8px 0;color:#888;font-size:13px;white-space:nowrap;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;width:130px;">{label}</td>
<td style="padding:8px 0 8px 16px;color:#213021;font-size:14px;font-weight:500;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">{value}</td>
</tr>"""
def _meta_row(label: str, value: str) -> str:
if not value:
return ""
return f"""
<tr>
<td style="padding:5px 0;color:#aaa;font-size:12px;white-space:nowrap;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;width:100px;">{label}</td>
<td style="padding:5px 0 5px 16px;color:#666;font-size:12px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;word-break:break-all;">{value}</td>
</tr>"""
# ── Email templates ──────────────────────────────────────────────────────────
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
badge = f'<div style="margin-top:20px;">{badge_html}</div>' if badge_html else ""
sub = f"""<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:13px;color:#7aaa7a;letter-spacing:0.04em;margin-top:8px;">
{subtitle}</div>""" if subtitle else ""
return f"""
<tr>
<td style="background:#213021;padding:36px 48px 32px;text-align:center;">
<img src="{LOGO_URL}" width="161" height="32" alt="GoodWalk"
style="display:inline-block;max-width:161px;height:auto;border:0;">
{sub}
{badge}
</td>
</tr>"""
def client_email(data: BookingSubmission) -> str:
services_text = ", ".join(data.services) if data.services else "Not specified"
message_row = _detail_row("About the dog", data.message) if data.message else ""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>We received your enquiry</title>
</head>
<body style="margin:0;padding:0;background:#f2f2f0;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f2f2f0;padding:40px 16px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
{_logo_header(subtitle="Auckland&rsquo;s favourite dog walking service")}
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:48px 48px 40px;">
<h1 style="margin:0 0 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:26px;font-weight:700;color:#213021;line-height:1.2;">
Thanks, {data.fullName.split()[0]}! &#x1F43E;
</h1>
<p style="margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:16px;color:#555;line-height:1.65;">
We&rsquo;ve received your enquiry and Aless will be in touch shortly to arrange
a <strong style="color:#213021;">Meet &amp; Greet</strong> with you and
{data.petName}.
</p>
<!-- Details card -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f8f7f4;border-radius:12px;margin-bottom:36px;">
<tr>
<td style="padding:28px 32px;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;
color:#888;text-transform:uppercase;margin-bottom:20px;">
Your enquiry summary
</div>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{_detail_row("Your name", data.fullName)}
{_detail_row("Email", data.email)}
{_detail_row("Phone", data.phone)}
{_detail_row("Dog&rsquo;s name", data.petName)}
{_detail_row("Location", data.location)}
{_detail_row("Services", services_text)}
{message_row}
</table>
</td>
</tr>
</table>
<!-- What's next -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="border-left:3px solid #FFD100;margin-bottom:36px;">
<tr>
<td style="padding:4px 0 4px 20px;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:13px;font-weight:700;color:#213021;margin-bottom:6px;">
What happens next?
</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;color:#666;line-height:1.6;">
Aless will review your details and reach out within 1&ndash;2 business days
to schedule a free Meet &amp; Greet. No commitment required &mdash; just a
chance for {data.petName} to make a new best friend.
</div>
</td>
</tr>
</table>
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;color:#888;line-height:1.6;">
Questions? Just reply to this email or reach us at
<a href="mailto:{REPLY_TO}" style="color:#213021;font-weight:600;
text-decoration:none;">{REPLY_TO}</a>.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#213021;padding:24px 48px;text-align:center;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:12px;color:#5a8a5a;line-height:1.6;">
GoodWalk &middot; Auckland, New Zealand<br>
<a href="https://www.goodwalk.co.nz" style="color:#7aaa7a;text-decoration:none;">
goodwalk.co.nz
</a>
</div>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>"""
def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
services_text = ", ".join(data.services) if data.services else ""
now = datetime.now()
submitted_at = now.strftime("%d %b %Y at %I:%M %p").lstrip("0")
message_block = f"""
<tr>
<td colspan="2" style="padding:16px 0 0;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
text-transform:uppercase;margin-bottom:8px;">About the dog</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
border-radius:8px;padding:14px 16px;">{data.message}</div>
</td>
</tr>""" if data.message else ""
badge = """<div style="display:inline-block;background:#FFD100;border-radius:100px;
padding:10px 28px;">
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:18px;font-weight:700;color:#213021;">
&#x1F4E9;&nbsp; New lead!
</span>
</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:12px;color:#5a8a5a;margin-top:12px;">
Submitted {submitted_at}
</div>""".format(submitted_at=submitted_at)
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 ""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>New GoodWalk Lead</title>
</head>
<body style="margin:0;padding:0;background:#f2f2f0;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f2f2f0;padding:40px 16px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
{_logo_header(badge_html=badge)}
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:40px 48px 36px;">
<!-- Owner details -->
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
text-transform:uppercase;margin-bottom:16px;">Owner details</div>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
<tr><td style="padding:24px 28px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">Name</td>
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
'Segoe UI',sans-serif;vertical-align:top;">{data.fullName}</td>
</tr>
<tr>
<td style="padding:6px 0;font-size:13px;color:#888;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">Email</td>
<td style="padding:6px 0 6px 16px;font-size:14px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">
<a href="mailto:{data.email}" style="color:#213021;font-weight:500;
text-decoration:none;">{data.email}</a>
</td>
</tr>
<tr>
<td style="padding:6px 0;font-size:13px;color:#888;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">Phone</td>
<td style="padding:6px 0 6px 16px;font-size:14px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">
<a href="tel:{data.phone}" style="color:#213021;font-weight:500;
text-decoration:none;">{data.phone}</a>
</td>
</tr>
</table>
</td></tr>
</table>
<!-- Dog & service details -->
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
text-transform:uppercase;margin-bottom:16px;">Dog &amp; services</div>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
<tr><td style="padding:24px 28px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">Dog</td>
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
'Segoe UI',sans-serif;vertical-align:top;">{data.petName}</td>
</tr>
<tr>
<td style="padding:6px 0;font-size:13px;color:#888;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">Location</td>
<td style="padding:6px 0 6px 16px;font-size:14px;font-weight:500;color:#213021;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">{data.location}</td>
</tr>
<tr>
<td style="padding:6px 0;font-size:13px;color:#888;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">Services</td>
<td style="padding:6px 0 6px 16px;font-size:14px;color:#444;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
vertical-align:top;">{services_text}</td>
</tr>
{message_block}
</table>
</td></tr>
</table>
<!-- CTA buttons -->
<table cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:32px;">
<tr>
<td style="padding-right:12px;">
<a href="mailto:{data.email}"
style="display:inline-block;background:#213021;color:#FFD100;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;font-weight:600;text-decoration:none;
border-radius:8px;padding:12px 24px;">
Reply to {data.fullName.split()[0]}
</a>
</td>
<td>
<a href="tel:{data.phone}"
style="display:inline-block;background:#f8f7f4;color:#213021;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;font-weight:600;text-decoration:none;
border-radius:8px;padding:12px 24px;border:1px solid #e0e0d8;">
Call {data.phone}
</a>
</td>
</tr>
</table>
<!-- Session info -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="border-top:1px solid #eeeee8;padding-top:20px;">
<tr><td>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#ccc;
text-transform:uppercase;margin-bottom:12px;">Session info</div>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{_meta_row("IP address", ip)}
{_meta_row("Browser", browser)}
{referrer_row}
{page_row}
</table>
</td></tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f8f7f4;padding:18px 48px;text-align:center;
border-top:1px solid #e8e8e4;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:12px;color:#bbb;">
Sent automatically by GoodWalk booking form
</div>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>"""
# ── Sending with retries ─────────────────────────────────────────────────────
async def _send_email(payload: dict, label: str, request_id: str) -> dict:
last_exc: Exception | None = None
for attempt in range(1, MAX_SEND_ATTEMPTS + 1):
started = time.monotonic()
try:
result = await asyncio.to_thread(resend.Emails.send, payload)
elapsed_ms = (time.monotonic() - started) * 1000
email_id = result.get("id") if isinstance(result, dict) else None
logger.info(
"[%s] %s sent to %s (attempt %d/%d, %.0fms, id=%s)",
request_id, label, payload.get("to"), attempt, MAX_SEND_ATTEMPTS,
elapsed_ms, email_id or "n/a",
)
return result or {}
except Exception as exc:
last_exc = exc
elapsed_ms = (time.monotonic() - started) * 1000
status = getattr(exc, "status_code", None) or getattr(exc, "code", None)
non_retryable = (
isinstance(status, int) and 400 <= status < 500 and status != 429
)
logger.warning(
"[%s] %s send failed (attempt %d/%d, %.0fms): %s: %s (status=%s)",
request_id, label, attempt, MAX_SEND_ATTEMPTS, elapsed_ms,
type(exc).__name__, exc, status,
exc_info=True,
)
if non_retryable:
logger.info(
"[%s] %s: non-retryable status %s, aborting retries",
request_id, label, status,
)
break
if attempt == MAX_SEND_ATTEMPTS:
break
backoff = (2 ** (attempt - 1)) + random.uniform(0, 0.4)
logger.info("[%s] retrying %s in %.2fs", request_id, label, backoff)
await asyncio.sleep(backoff)
assert last_exc is not None
raise last_exc
# ── Routes ───────────────────────────────────────────────────────────────────
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@app.post("/submit")
async def submit_booking(data: BookingSubmission, request: Request):
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
ip = _get_ip(request)
browser = _parse_ua(request.headers.get("user-agent", ""))
name_parts = data.fullName.strip().split()
first_name = name_parts[0] if name_parts else "there"
logger.info(
"[%s] /submit: email=%s ip=%s browser=%r dog=%s services=%s page=%r",
request_id, data.email, ip, browser, data.petName, data.services, data.page,
)
logger.debug("[%s] full payload: %s", request_id, data.model_dump())
failures: list[dict] = []
try:
await _send_email(
{
"from": FROM_EMAIL,
"to": [data.email],
"reply_to": REPLY_TO,
"subject": f"We received your enquiry, {first_name}! 🐾",
"html": client_email(data),
},
label="client_email",
request_id=request_id,
)
except Exception as exc:
failures.append({
"label": "client_email",
"error_type": type(exc).__name__,
"error": str(exc),
"status": getattr(exc, "status_code", None) or getattr(exc, "code", None),
})
try:
await _send_email(
{
"from": FROM_EMAIL,
"to": [OWNER_EMAIL],
"reply_to": data.email,
"subject": f"New GoodWalk lead — {data.fullName} ({data.petName})",
"html": owner_email(data, ip, browser),
},
label="owner_email",
request_id=request_id,
)
except Exception as exc:
failures.append({
"label": "owner_email",
"error_type": type(exc).__name__,
"error": str(exc),
"status": getattr(exc, "status_code", None) or getattr(exc, "code", None),
})
if len(failures) == 2:
logger.error("[%s] both emails failed after retries: %s", request_id, failures)
raise HTTPException(
status_code=502,
detail={
"request_id": request_id,
"message": "Both confirmation and notification emails failed to send. Please try again shortly.",
"failures": failures,
},
)
if failures:
logger.warning("[%s] partial failure: %s", request_id, failures)
return {
"ok": True,
"request_id": request_id,
"partial_failures": [f["label"] for f in failures],
}