import asyncio from collections import deque import json import logging import logging.handlers import os import random import re import secrets 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="%d/%m/%Y %H:%M:%S %Z", ) 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 ──────────────────────────────────────────────────────────── DEV_MODE = os.environ.get("DEV_MODE", "").strip().lower() in {"1", "true", "yes"} 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: if DEV_MODE: return { "resend_api_key": os.environ.get("RESEND_API_KEY", "dev"), "owner_email": os.environ.get("OWNER_EMAIL", "dev@localhost"), "from_email": os.environ.get("FROM_EMAIL", "GoodWalk "), "reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"), "owner_bcc": "", "client_bcc": "", "enable_general_enquiries": False, "max_attempts": 3, "form_min_seconds": 1, "form_max_seconds": 7200, "rate_limit_window_seconds": 900, "rate_limit_max_per_ip": 50, "rate_limit_max_per_email": 50, "rate_limit_min_interval_seconds": 1, } 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 "), "reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"), "owner_bcc": os.environ.get("OWNER_BCC", "example@example.com").strip(), "client_bcc": os.environ.get("CLIENT_BCC", "").strip(), "enable_general_enquiries": os.environ.get("ENABLE_GENERAL_ENQUIRIES", "false").strip().lower() in {"1", "true", "yes", "on", "enabled"}, "max_attempts": max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))), "form_min_seconds": max(1, int(os.environ.get("FORM_MIN_SECONDS", "4"))), "form_max_seconds": max(60, int(os.environ.get("FORM_MAX_SECONDS", "7200"))), "rate_limit_window_seconds": max(60, int(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", "900"))), "rate_limit_max_per_ip": max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_IP", "5"))), "rate_limit_max_per_email": max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_EMAIL", "3"))), "rate_limit_min_interval_seconds": max(1, int(os.environ.get("RATE_LIMIT_MIN_INTERVAL_SECONDS", "20"))), } _config = _load_config() APP_VERSION = os.environ.get("APP_VERSION", "unknown") AUTH_CODE_TTL_SECONDS = max(60, int(os.environ.get("AUTH_CODE_TTL_SECONDS", "600"))) AUTH_SESSION_TTL_SECONDS = max(3600, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(7 * 24 * 3600)))) AUTH_CODE_MAX_ATTEMPTS = 5 AUTH_CODE_REQUESTS_PER_HOUR = 5 AUTH_IP_MAX_FAILURES = max(3, int(os.environ.get("AUTH_IP_MAX_FAILURES", "10"))) AUTH_IP_FAILURE_WINDOW = max(60, int(os.environ.get("AUTH_IP_FAILURE_WINDOW", "600"))) AUTH_IP_BLOCK_DURATION = max(60, int(os.environ.get("AUTH_IP_BLOCK_DURATION", "3600"))) _ALLOWED_EMAILS_FILE = Path(os.environ.get("DATA_DIR", "data")) / "allowed_emails.json" _CLIENT_PROFILES_FILE = Path(os.environ.get("DATA_DIR", "data")) / "client_profiles.json" _DRAFTS_FILE = Path(os.environ.get("DATA_DIR", "data")) / "drafts.json" resend.api_key = _config["resend_api_key"] OWNER_EMAIL = _config["owner_email"] OWNER_BCC = _config["owner_bcc"] CLIENT_BCC = _config["client_bcc"] FROM_EMAIL = _config["from_email"] REPLY_TO = _config["reply_to"] ENABLE_GENERAL_ENQUIRIES = _config["enable_general_enquiries"] MAX_SEND_ATTEMPTS = _config["max_attempts"] FORM_MIN_SECONDS = _config["form_min_seconds"] FORM_MAX_SECONDS = _config["form_max_seconds"] RATE_LIMIT_WINDOW_SECONDS = _config["rate_limit_window_seconds"] RATE_LIMIT_MAX_PER_IP = _config["rate_limit_max_per_ip"] RATE_LIMIT_MAX_PER_EMAIL = _config["rate_limit_max_per_email"] RATE_LIMIT_MIN_INTERVAL_SECONDS = _config["rate_limit_min_interval_seconds"] LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png" logger.info( "Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r owner_bcc=%r client_bcc=%r general_enquiries=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss", APP_VERSION, os.environ.get("TZ", "system-default"), FROM_EMAIL, REPLY_TO, OWNER_EMAIL, OWNER_BCC, CLIENT_BCC, ENABLE_GENERAL_ENQUIRIES, MAX_SEND_ATTEMPTS, FORM_MIN_SECONDS, FORM_MAX_SECONDS, RATE_LIMIT_WINDOW_SECONDS, RATE_LIMIT_MAX_PER_IP, RATE_LIMIT_MAX_PER_EMAIL, RATE_LIMIT_MIN_INTERVAL_SECONDS, ) app = FastAPI(title="GoodWalk Mail API") STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else "" # ── Auth state ─────────────────────────────────────────────────────────────── def _load_allowed_emails() -> set[str]: seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()} try: if _ALLOWED_EMAILS_FILE.exists(): data = json.loads(_ALLOWED_EMAILS_FILE.read_text(encoding="utf-8")) seed.update(e.lower() for e in data.get("emails", []) if isinstance(e, str)) except Exception as exc: logger.warning("Could not load allowed_emails file: %s", exc) return seed def _save_allowed_emails_sync(emails: set[str]) -> None: try: _ALLOWED_EMAILS_FILE.parent.mkdir(parents=True, exist_ok=True) _ALLOWED_EMAILS_FILE.write_text( json.dumps({"emails": sorted(emails)}, indent=2), encoding="utf-8" ) except Exception as exc: logger.warning("Could not save allowed_emails: %s", exc) def _load_client_profiles() -> dict[str, dict]: try: if _CLIENT_PROFILES_FILE.exists(): return json.loads(_CLIENT_PROFILES_FILE.read_text(encoding="utf-8")) except Exception as exc: logger.warning("Could not load client_profiles file: %s", exc) return {} def _save_client_profiles_sync(profiles: dict) -> None: try: _CLIENT_PROFILES_FILE.parent.mkdir(parents=True, exist_ok=True) _CLIENT_PROFILES_FILE.write_text(json.dumps(profiles, indent=2), encoding="utf-8") except Exception as exc: logger.warning("Could not save client_profiles: %s", exc) def _load_drafts() -> dict: try: if _DRAFTS_FILE.exists(): return json.loads(_DRAFTS_FILE.read_text(encoding="utf-8")) except Exception as exc: logger.warning("Could not load drafts file: %s", exc) return {} def _save_drafts_sync(drafts: dict) -> None: try: _DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True) _DRAFTS_FILE.write_text(json.dumps(drafts, indent=2), encoding="utf-8") except Exception as exc: logger.warning("Could not save drafts: %s", exc) _allowed_emails: set[str] = _load_allowed_emails() _pending_codes: dict[str, dict] = {} # email -> {code, expires_at, attempts} _active_sessions: dict[str, dict] = {} # token -> {email, expires_at} _code_requests: dict[str, deque] = {} # email -> deque of monotonic timestamps _client_profiles: dict[str, dict] = _load_client_profiles() _drafts: dict[str, dict] = _load_drafts() # email -> {onboarding: {...}, contract: {...}} _auth_failures_by_ip: dict[str, deque] = {} # ip -> deque of failure timestamps _blocked_ips: dict[str, float] = {} # ip -> unblock_at (monotonic) _auth_lock = asyncio.Lock() logger.info("Auth: loaded %d allowed email(s)", len(_allowed_emails)) async def _register_email(email: str) -> None: normalized = email.strip().lower() if not normalized: return async with _auth_lock: if normalized not in _allowed_emails: _allowed_emails.add(normalized) await asyncio.to_thread(_save_allowed_emails_sync, set(_allowed_emails)) logger.info("Auth: registered new allowed email: %s", normalized) async def _store_client_profile(email: str, profile: dict) -> None: normalized = email.strip().lower() if not normalized: return async with _auth_lock: existing = _client_profiles.get(normalized, {}) merged = {k: v for k, v in {**existing, **profile}.items() if v} if merged != existing: _client_profiles[normalized] = merged await asyncio.to_thread(_save_client_profiles_sync, dict(_client_profiles)) def _check_ip_blocked(ip: str, request_id: str) -> None: now = time.monotonic() unblock_at = _blocked_ips.get(ip) if unblock_at is not None: if now < unblock_at: remaining = int(unblock_at - now) logger.warning("[%s] auth: blocked ip=%s (%ds remaining)", request_id, ip, remaining) raise HTTPException( status_code=429, detail=f"Too many failed attempts. Try again in {remaining // 60 + 1} minute(s).", headers={"Retry-After": str(remaining)}, ) else: del _blocked_ips[ip] def _record_auth_failure(ip: str, request_id: str, reason: str) -> None: now = time.monotonic() failures = _auth_failures_by_ip.setdefault(ip, deque()) while failures and now - failures[0] > AUTH_IP_FAILURE_WINDOW: failures.popleft() failures.append(now) logger.warning("[%s] auth: failure ip=%s reason=%r total_in_window=%d", request_id, ip, reason, len(failures)) if len(failures) >= AUTH_IP_MAX_FAILURES: _blocked_ips[ip] = now + AUTH_IP_BLOCK_DURATION logger.warning( "[%s] auth: ip=%s BLOCKED for %ds after %d failures", request_id, ip, AUTH_IP_BLOCK_DURATION, len(failures), ) 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 BaseSubmission(BaseModel): fullName: str email: EmailStr phone: str website: str = "" formStartedAt: int | None = None visitStartedAt: int | None = None pageEnteredAt: int | None = None firstInteractionAt: int | None = None sendClickedAt: int | None = None referrer: str = "" page: str = "" class BookingSubmission(BaseSubmission): enquiryType: str = "booking" petName: str = "" location: str = "" message: str = "" services: list[str] = [] stepChanges: int = 0 journey: list[str] = [] class OnboardingSubmission(BaseSubmission): address: str dogName: str dogBreed: str dogAge: str = "" servicesNeeded: list[str] = [] temperament: str = "" medicalNotes: str = "" accessInstructions: str = "" vetName: str vetPhone: str emergencyContactName: str emergencyContactPhone: str councilRegistrationConfirmed: bool = False vaccinationsConfirmed: bool = False emergencyVetConsent: bool = False termsAccepted: bool = False signatureDataUrl: str class ContractSubmission(BaseSubmission): address: str dogName: str dogBreed: str dogAge: str = "" serviceType: str startDate: str walkFrequency: str = "" additionalNotes: str = "" agreeServiceTerms: bool = False agreeCancellation: bool = False agreePayment: bool = False agreeEmergency: bool = False agreeLiability: bool = False agreeAccuracy: bool = False signatureDataUrl: 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" _submit_attempts_by_ip: dict[str, deque[float]] = {} _submit_attempts_by_email: dict[str, deque[float]] = {} _submit_rate_limit_lock = asyncio.Lock() def _trimmed(value: str) -> str: return value.strip() def _prune_attempts(attempts: deque[float], now: float, window_seconds: int) -> None: while attempts and now - attempts[0] > window_seconds: attempts.popleft() def _seconds_until_allowed(last_attempt_at: float, now: float, min_interval_seconds: int) -> int: retry_after = max(1, int(min_interval_seconds - (now - last_attempt_at))) return retry_after async def _enforce_submit_rate_limits(request_id: str, ip: str, email: str) -> None: now = time.monotonic() normalized_email = email.strip().lower() async with _submit_rate_limit_lock: ip_attempts = _submit_attempts_by_ip.setdefault(ip, deque()) email_attempts = _submit_attempts_by_email.setdefault(normalized_email, deque()) _prune_attempts(ip_attempts, now, RATE_LIMIT_WINDOW_SECONDS) _prune_attempts(email_attempts, now, RATE_LIMIT_WINDOW_SECONDS) if ip_attempts and now - ip_attempts[-1] < RATE_LIMIT_MIN_INTERVAL_SECONDS: retry_after = _seconds_until_allowed(ip_attempts[-1], now, RATE_LIMIT_MIN_INTERVAL_SECONDS) logger.warning( "[%s] rate limited: ip=%s submitted again after %.1fs (minimum %ss)", request_id, ip, now - ip_attempts[-1], RATE_LIMIT_MIN_INTERVAL_SECONDS, ) raise HTTPException( status_code=429, detail=f"Please wait about {retry_after} seconds before trying again.", ) if len(ip_attempts) >= RATE_LIMIT_MAX_PER_IP: logger.warning( "[%s] rate limited: ip=%s exceeded %d submissions in %ss", request_id, ip, RATE_LIMIT_MAX_PER_IP, RATE_LIMIT_WINDOW_SECONDS, ) raise HTTPException( status_code=429, detail="Too many enquiries from this connection. Please try again a little later.", ) if len(email_attempts) >= RATE_LIMIT_MAX_PER_EMAIL: logger.warning( "[%s] rate limited: email=%s exceeded %d submissions in %ss", request_id, normalized_email, RATE_LIMIT_MAX_PER_EMAIL, RATE_LIMIT_WINDOW_SECONDS, ) raise HTTPException( status_code=429, detail="That email address has reached the enquiry limit for now. Please try again later.", ) ip_attempts.append(now) email_attempts.append(now) def _enforce_form_timing(request_id: str, data: BaseSubmission) -> None: if data.formStartedAt is None or data.formStartedAt <= 0: logger.warning("[%s] rejected: missing or invalid formStartedAt", request_id) raise HTTPException( status_code=400, detail="Please refresh the page and try again.", ) elapsed_seconds = (time.time() * 1000 - data.formStartedAt) / 1000 if elapsed_seconds < FORM_MIN_SECONDS: logger.warning( "[%s] rejected: form submitted too quickly (%.2fs < %ss)", request_id, elapsed_seconds, FORM_MIN_SECONDS, ) raise HTTPException( status_code=400, detail="Please take a moment to fill in the form before sending it.", ) if elapsed_seconds > FORM_MAX_SECONDS: logger.warning( "[%s] rejected: stale form submission (%.0fs > %ss)", request_id, elapsed_seconds, FORM_MAX_SECONDS, ) raise HTTPException( status_code=400, detail="This form has been open for too long. Please refresh the page and try again.", ) def _is_honeypot_triggered(data: BaseSubmission) -> bool: return bool(_trimmed(data.website)) def _is_general_enquiry(data: BookingSubmission) -> bool: return _trimmed(data.enquiryType).lower() == "general" def _enquiry_type_label(data: BookingSubmission) -> str: return "General enquiry" if _is_general_enquiry(data) else "Booking enquiry" def _validate_submission(request_id: str, data: BookingSubmission) -> None: enquiry_type = _trimmed(data.enquiryType).lower() if enquiry_type not in {"booking", "general"}: logger.warning("[%s] rejected: invalid enquiryType=%r", request_id, data.enquiryType) raise HTTPException( status_code=400, detail="Please choose a valid enquiry type and try again.", ) if not _trimmed(data.fullName): logger.warning("[%s] rejected: missing full name", request_id) raise HTTPException( status_code=400, detail="Please enter your full name.", ) if not _trimmed(data.phone): logger.warning("[%s] rejected: missing phone number", request_id) raise HTTPException( status_code=400, detail="Please enter your contact number.", ) if _is_general_enquiry(data): if not ENABLE_GENERAL_ENQUIRIES: logger.warning("[%s] rejected: general enquiries are disabled", request_id) raise HTTPException( status_code=403, detail="General enquiries are currently unavailable through this form.", ) if not _trimmed(data.message): logger.warning("[%s] rejected: missing general enquiry message", request_id) raise HTTPException( status_code=400, detail="Please tell us how we can help.", ) return if not _trimmed(data.petName): logger.warning("[%s] rejected: missing pet name", request_id) raise HTTPException( status_code=400, detail="Please enter your dog's name.", ) if not _trimmed(data.location): logger.warning("[%s] rejected: missing location", request_id) raise HTTPException( status_code=400, detail="Please enter your location.", ) def _normalize_submission(data: BookingSubmission) -> None: data.enquiryType = "general" if _is_general_enquiry(data) else "booking" data.fullName = _trimmed(data.fullName) data.phone = _trimmed(data.phone) data.petName = _trimmed(data.petName) data.location = _trimmed(data.location) data.message = _trimmed(data.message) 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 = "" data.location = "" data.services = [] def _validate_onboarding_submission(request_id: str, data: OnboardingSubmission) -> None: if not _trimmed(data.fullName): logger.warning("[%s] onboarding rejected: missing full name", request_id) raise HTTPException(status_code=400, detail="Please enter your full name.") if not _trimmed(data.phone): logger.warning("[%s] onboarding rejected: missing phone", request_id) raise HTTPException(status_code=400, detail="Please enter your phone number.") required_fields = { "address": "Please enter your address.", "dogName": "Please enter your dog's name.", "dogBreed": "Please enter your dog's breed.", "vetName": "Please enter your vet clinic name.", "vetPhone": "Please enter your vet phone number.", "emergencyContactName": "Please enter an emergency contact name.", "emergencyContactPhone": "Please enter an emergency contact phone number.", } for field_name, message in required_fields.items(): if not _trimmed(getattr(data, field_name)): logger.warning("[%s] onboarding rejected: missing %s", request_id, field_name) raise HTTPException(status_code=400, detail=message) if not data.servicesNeeded: logger.warning("[%s] onboarding rejected: missing services", request_id) raise HTTPException(status_code=400, detail="Please choose at least one service.") if not data.councilRegistrationConfirmed: raise HTTPException(status_code=400, detail="Please confirm council registration.") if not data.vaccinationsConfirmed: raise HTTPException(status_code=400, detail="Please confirm vaccinations are current.") if not data.emergencyVetConsent: raise HTTPException(status_code=400, detail="Please confirm emergency veterinary consent.") if not data.termsAccepted: raise HTTPException(status_code=400, detail="Please confirm the onboarding declaration.") signature = _trimmed(data.signatureDataUrl) if not signature.startswith("data:image/png;base64,") or len(signature) < 128: logger.warning("[%s] onboarding rejected: invalid signature payload", request_id) raise HTTPException(status_code=400, detail="Please add your signature before sending.") def _normalize_onboarding_submission(data: OnboardingSubmission) -> None: data.fullName = _trimmed(data.fullName) data.phone = _trimmed(data.phone) data.address = _trimmed(data.address) data.dogName = _trimmed(data.dogName) data.dogBreed = _trimmed(data.dogBreed) data.dogAge = _trimmed(data.dogAge) data.temperament = _trimmed(data.temperament) data.medicalNotes = _trimmed(data.medicalNotes) data.accessInstructions = _trimmed(data.accessInstructions) data.vetName = _trimmed(data.vetName) data.vetPhone = _trimmed(data.vetPhone) data.emergencyContactName = _trimmed(data.emergencyContactName) data.emergencyContactPhone = _trimmed(data.emergencyContactPhone) data.referrer = _trimmed(data.referrer) data.page = _trimmed(data.page) data.servicesNeeded = [_trimmed(service) for service in data.servicesNeeded if _trimmed(service)][:8] 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) 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""" {label} {value} """ def _meta_row(label: str, value: str) -> str: if not value: return "" return f""" {label} {value} """ 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: badge = f'
{badge_html}
' if badge_html else "" sub = f"""
{subtitle}
""" if subtitle else "" return f""" GoodWalk {sub} {badge} """ def client_email(data: BookingSubmission) -> str: is_general = _is_general_enquiry(data) services_text = ", ".join(data.services) if data.services else "Not specified" enquiry_summary_rows = [ _detail_row("Your name", data.fullName), _detail_row("Email", str(data.email)), _detail_row("Phone", data.phone), _detail_row("Type", _enquiry_type_label(data)), ] if is_general: if data.message: enquiry_summary_rows.append(_detail_row("Message", data.message)) intro_html = ( "We’ve received your message and we will be in touch shortly." ) next_steps_html = ( "We will review your message and reply within 1 business day." ) logo_subtitle = "General enquiries and dog walking support" else: enquiry_summary_rows.extend( [ _detail_row("Dog’s name", data.petName), _detail_row("Location", data.location), _detail_row("Services", services_text), ] ) if data.message: enquiry_summary_rows.append(_detail_row("About the dog", data.message)) intro_html = ( "We’ve received your enquiry and we will be in touch shortly to arrange " "a Meet & Greet with you and " f"{data.petName}." ) next_steps_html = ( "We will review your details and reach out within 1 business day " "to schedule a free Meet & Greet. No commitment required — just a " f"chance for {data.petName} to make a new best friend." ) logo_subtitle = "Professional dog walking services" return f""" We received your enquiry
{_logo_header(subtitle=logo_subtitle)}

Thanks, {data.fullName.split()[0]}! 🐾

{intro_html}

Your enquiry summary
{"".join(enquiry_summary_rows)}
What happens next?
{next_steps_html}

Questions? Just reply to this email or reach us at 022 642 1011.

GoodWalk · Auckland, New Zealand
goodwalk.co.nz
""" def owner_email(data: BookingSubmission, ip: str, browser: str) -> str: is_general = _is_general_enquiry(data) 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") first_name = data.fullName.split()[0] if data.fullName.strip() else "them" email_title = "New GoodWalk Enquiry" if is_general else "New GoodWalk Lead" message_label = "Message" if is_general else "About the dog" message_block = f"""
{message_label}
{data.message}
""" if data.message else "" badge = """
📩  New enquiry!
Submitted {submitted_at}
""".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 "" 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))] if is_general: if data.petName: detail_rows.append(_detail_row("Dog", data.petName)) if data.location: detail_rows.append(_detail_row("Location", data.location)) else: detail_rows.extend( [ _detail_row("Dog", data.petName), _detail_row("Location", data.location), _detail_row("Services", services_text), ] ) return f""" {email_title}
{_logo_header(badge_html=badge)}
""" def owner_onboarding_email(data: OnboardingSubmission, ip: str, browser: str) -> str: submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0") services_text = ", ".join(data.servicesNeeded) 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)) 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 "" dog_notes_block = f"""
Temperament and routine
{data.temperament}
""" if data.temperament else "" medical_block = f"""
Medical notes
{data.medicalNotes}
""" if data.medicalNotes else "" access_block = f"""
Home access instructions
{data.accessInstructions}
""" if data.accessInstructions else "" signature_block = f"""
Captured signature
Client signature
""" badge = f"""
✍  New onboarding form
Submitted {submitted_at}
""" return f""" New GoodWalk onboarding form
{_logo_header(badge_html=badge, subtitle="Signed onboarding form")}
Quick contact
Reply directly to the owner or call them back:
Call {data.phone}
Owner details
{_detail_row("Name", data.fullName)} {_detail_row("Email", str(data.email))} {_detail_row("Phone", data.phone)} {_detail_row("Address", data.address)}
Dog and service details
{_detail_row("Dog", data.dogName)} {_detail_row("Breed", data.dogBreed)} {_detail_row("Age", data.dogAge or "—")} {_detail_row("Service", services_text)} {dog_notes_block} {medical_block} {access_block}
Safety details
{_detail_row("Vet clinic", data.vetName)} {_detail_row("Vet phone", data.vetPhone)} {_detail_row("Emergency contact", data.emergencyContactName)} {_detail_row("Emergency phone", data.emergencyContactPhone)} {_detail_row("Council registration", "Confirmed")} {_detail_row("Vaccinations", "Confirmed")} {_detail_row("Emergency consent", "Confirmed")} {_detail_row("Declaration", "Signed")}
{signature_block}
Session info
{_meta_row("IP address", ip)} {_meta_row("Browser", browser)} {visit_time_row} {page_time_row} {active_time_row} {form_time_row} {referrer_row} {page_row}
""" # ── Sending with retries ───────────────────────────────────────────────────── async def _send_email(payload: dict, label: str, request_id: str) -> dict: if DEV_MODE: to = payload.get("to", []) subject = payload.get("subject", "(no subject)") logger.warning("[DEV] skipping email send — label=%s to=%s subject=%r", label, to, subject) return {"id": "dev-mode"} 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 def _build_startup_test_submission() -> BookingSubmission: now_ms = int(time.time() * 1000) sample = BookingSubmission( enquiryType="booking", fullName="Sarah Thompson", email="sarah.thompson@example.com", phone="021 555 0142", petName="Milo", location="Grey Lynn", message=( "Milo is a 2-year-old cavoodle with good recall and a friendly nature. " "He loves other dogs, is comfortable off lead in safe areas, and we are " "looking for regular weekday pack walks while we are at work." ), services=["Pack Walks", "Puppy Visits"], formStartedAt=now_ms - (6 * 60 * 1000 + 35 * 1000), visitStartedAt=now_ms - (14 * 60 * 1000 + 10 * 1000), pageEnteredAt=now_ms - (7 * 60 * 1000 + 5 * 1000), firstInteractionAt=now_ms - (5 * 60 * 1000 + 20 * 1000), sendClickedAt=now_ms, stepChanges=3, journey=["/", "/pack-walks", "/our-pricing", "/book"], referrer="https://www.google.com/search?q=goodwalk+auckland+dog+walking", page="https://www.goodwalk.co.nz/book?service=pack-walks", ) _normalize_submission(sample) return sample 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" sample = _build_startup_test_submission() payload = { "from": FROM_EMAIL, "to": [STARTUP_TEST_RECIPIENT], "reply_to": str(sample.email), "subject": f"Startup preview — New GoodWalk lead — {sample.fullName} ({sample.petName})", "html": owner_email(sample, "127.0.0.1", f"Startup Preview ({APP_VERSION})"), } 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"} def _auth_code_email(email: str, code: str) -> str: return f""" Your Goodwalk login code
Goodwalk
Your login code
{code}

Enter this code on the Goodwalk onboarding page.

This code expires in {AUTH_CODE_TTL_SECONDS // 60} minutes. If you didn’t request this, you can safely ignore it.

Goodwalk · Auckland, New Zealand
""" _EMAIL_RE = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$') @app.post("/auth/request-code") async def auth_request_code(request: Request): request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) ip = _get_ip(request) body = await request.json() email = str(body.get("email", "")).strip().lower() async with _auth_lock: _check_ip_blocked(ip, request_id) if not email or not _EMAIL_RE.match(email): raise HTTPException(status_code=400, detail="Please enter a valid email address.") if email not in _allowed_emails: logger.info("[%s] auth: unknown email=%s ip=%s", request_id, email, ip) async with _auth_lock: _record_auth_failure(ip, request_id, "unknown_email") raise HTTPException( status_code=403, detail="We don’t have your email on file. Please use the address you used when enquiring with Goodwalk, or contact us at info@goodwalk.co.nz.", ) now = time.monotonic() async with _auth_lock: requests = _code_requests.setdefault(email, deque()) while requests and now - requests[0] > 3600: requests.popleft() if len(requests) >= AUTH_CODE_REQUESTS_PER_HOUR: raise HTTPException(status_code=429, detail="Too many code requests. Please wait before trying again.") requests.append(now) code = str(secrets.randbelow(900000) + 100000) _pending_codes[email] = {"code": code, "expires_at": time.time() + AUTH_CODE_TTL_SECONDS, "attempts": 0} logger.info("[%s] auth: code issued for email=%s", request_id, email) if DEV_MODE: logger.warning("[DEV] auth code for %s: %s", email, code) else: await _send_email( {"from": FROM_EMAIL, "to": [email], "subject": "Your Goodwalk login code", "html": _auth_code_email(email, code)}, label="auth_code_email", request_id=request_id, ) return {"ok": True} @app.post("/auth/verify-code") async def auth_verify_code(request: Request): request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) ip = _get_ip(request) body = await request.json() email = str(body.get("email", "")).strip().lower() code = str(body.get("code", "")).strip() async with _auth_lock: _check_ip_blocked(ip, request_id) pending = _pending_codes.get(email) if not pending: _record_auth_failure(ip, request_id, "no_pending_code") raise HTTPException(status_code=400, detail="No code found for this email. Please request a new one.") if time.time() > pending["expires_at"]: _pending_codes.pop(email, None) _record_auth_failure(ip, request_id, "expired_code") raise HTTPException(status_code=400, detail="Your code has expired. Please request a new one.") pending["attempts"] += 1 if pending["attempts"] > AUTH_CODE_MAX_ATTEMPTS: _pending_codes.pop(email, None) _record_auth_failure(ip, request_id, "max_attempts_exceeded") raise HTTPException(status_code=400, detail="Too many incorrect attempts. Please request a new code.") if pending["code"] != code: remaining = max(0, AUTH_CODE_MAX_ATTEMPTS - pending["attempts"]) _record_auth_failure(ip, request_id, "wrong_code") raise HTTPException(status_code=400, detail=f"Incorrect code. {remaining} attempt{'s' if remaining != 1 else ''} remaining.") _pending_codes.pop(email, None) token = secrets.token_urlsafe(32) _active_sessions[token] = {"email": email, "expires_at": time.time() + AUTH_SESSION_TTL_SECONDS} logger.info("[%s] auth: session created for email=%s", request_id, email) return {"ok": True, "token": token, "email": email} @app.get("/auth/verify") async def auth_verify(request: Request): auth_header = request.headers.get("Authorization", "") token = auth_header.removeprefix("Bearer ").strip() if not token: raise HTTPException(status_code=401, detail="No token provided.") async with _auth_lock: session = _active_sessions.get(token) if not session: raise HTTPException(status_code=401, detail="Invalid session.") if time.time() > session["expires_at"]: _active_sessions.pop(token, None) raise HTTPException(status_code=401, detail="Session expired. Please sign in again.") email = session["email"] profile = _client_profiles.get(email, {}) draft = _drafts.get(email, {}) return {"ok": True, "email": email, "profile": profile, "draft": draft} @app.post("/auth/logout") async def auth_logout(request: Request): auth_header = request.headers.get("Authorization", "") token = auth_header.removeprefix("Bearer ").strip() if token: async with _auth_lock: _active_sessions.pop(token, None) return {"ok": True} @app.post("/auth/save-draft") async def auth_save_draft(request: Request): auth_header = request.headers.get("Authorization", "") token = auth_header.removeprefix("Bearer ").strip() if not token: raise HTTPException(status_code=401, detail="No token provided.") async with _auth_lock: session = _active_sessions.get(token) if not session or time.time() > session["expires_at"]: raise HTTPException(status_code=401, detail="Invalid or expired session.") email = session["email"] body = await request.json() form = str(body.get("form", "")).strip() data = body.get("data", {}) if form not in ("onboarding", "contract"): raise HTTPException(status_code=400, detail="form must be 'onboarding' or 'contract'.") if not isinstance(data, dict): raise HTTPException(status_code=400, detail="data must be an object.") async with _auth_lock: user_drafts = _drafts.setdefault(email, {}) user_drafts[form] = data snapshot = dict(_drafts) await asyncio.to_thread(_save_drafts_sync, snapshot) logger.info("Draft saved: email=%s form=%s", email, form) return {"ok": True} @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", "")) await _enforce_submit_rate_limits(request_id, ip, str(data.email)) _enforce_form_timing(request_id, data) if _is_honeypot_triggered(data): logger.warning( "[%s] honeypot triggered for ip=%s email=%s page=%r", request_id, ip, data.email, data.page, ) return { "ok": True, "request_id": request_id, "ignored": True, } _validate_submission(request_id, data) _normalize_submission(data) name_parts = data.fullName.split() first_name = name_parts[0] if name_parts else "there" logger.info( "[%s] /submit: type=%s email=%s ip=%s browser=%r dog=%s services=%s page=%r", request_id, data.enquiryType, data.email, ip, browser, data.petName, data.services, data.page, ) logger.debug("[%s] full payload: %s", request_id, data.model_dump()) failures: list[dict] = [] client_payload = { "from": FROM_EMAIL, "to": [data.email], "reply_to": REPLY_TO, "subject": f"We received your {'general enquiry' if _is_general_enquiry(data) else 'enquiry'}, {first_name}! 🐾", "html": client_email(data), } if CLIENT_BCC: client_payload["bcc"] = [CLIENT_BCC] try: await _send_email( client_payload, 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), }) owner_payload = { "from": FROM_EMAIL, "to": [OWNER_EMAIL], "reply_to": data.email, "subject": ( f"New GoodWalk general enquiry — {data.fullName}" if _is_general_enquiry(data) else f"New GoodWalk lead — {data.fullName} ({data.petName})" ), "html": owner_email(data, ip, browser), } if OWNER_BCC: owner_payload["bcc"] = [OWNER_BCC] try: await _send_email( owner_payload, 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) await _register_email(str(data.email)) await _store_client_profile(str(data.email), { "fullName": data.fullName, "phone": data.phone, "dogName": data.petName, }) return { "ok": True, "request_id": request_id, "partial_failures": [f["label"] for f in failures], } def _validate_contract_submission(request_id: str, data: ContractSubmission) -> None: if not _trimmed(data.fullName): raise HTTPException(status_code=400, detail="Please enter your full name.") if not _trimmed(data.phone): raise HTTPException(status_code=400, detail="Please enter your phone number.") for field_name, message in { "address": "Please enter your address.", "dogName": "Please enter your dog's name.", "dogBreed": "Please enter your dog's breed.", "serviceType": "Please select a service type.", "startDate": "Please enter a start date.", }.items(): if not _trimmed(getattr(data, field_name)): logger.warning("[%s] contract rejected: missing %s", request_id, field_name) raise HTTPException(status_code=400, detail=message) if not all([data.agreeServiceTerms, data.agreeCancellation, data.agreePayment, data.agreeEmergency, data.agreeLiability, data.agreeAccuracy]): logger.warning("[%s] contract rejected: incomplete declarations", request_id) raise HTTPException(status_code=400, detail="Please confirm all declarations before signing.") signature = _trimmed(data.signatureDataUrl) if not signature.startswith("data:image/png;base64,") or len(signature) < 128: logger.warning("[%s] contract rejected: invalid signature payload", request_id) raise HTTPException(status_code=400, detail="Please add your signature before sending.") def _normalize_contract_submission(data: ContractSubmission) -> None: data.fullName = _trimmed(data.fullName) data.phone = _trimmed(data.phone) data.address = _trimmed(data.address) data.dogName = _trimmed(data.dogName) data.dogBreed = _trimmed(data.dogBreed) data.dogAge = _trimmed(data.dogAge) data.serviceType = _trimmed(data.serviceType) data.startDate = _trimmed(data.startDate) data.walkFrequency = _trimmed(data.walkFrequency) data.additionalNotes = _trimmed(data.additionalNotes) data.referrer = _trimmed(data.referrer) data.page = _trimmed(data.page) 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) def owner_contract_email(data: ContractSubmission, ip: str, browser: str) -> str: submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0") visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt)) form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt)) 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 "" notes_block = f"""
Additional notes
{data.additionalNotes}
""" if data.additionalNotes else "" signature_block = f"""
Captured signature
Client signature
""" badge = f"""
📜  New signed contract
Submitted {submitted_at}
""" return f""" New GoodWalk service contract
{_logo_header(badge_html=badge, subtitle="Signed service agreement")}
Quick contact
Call {data.phone}
Client details
{_detail_row("Name", data.fullName)} {_detail_row("Email", str(data.email))} {_detail_row("Phone", data.phone)} {_detail_row("Address", data.address)}
Service agreement
{_detail_row("Dog", data.dogName)} {_detail_row("Breed", data.dogBreed)} {_detail_row("Age", data.dogAge or "—")} {_detail_row("Service", data.serviceType)} {_detail_row("Start date", data.startDate)} {_detail_row("Frequency", data.walkFrequency or "—")} {notes_block}
Declarations confirmed
{_detail_row("Service terms", "Confirmed")} {_detail_row("Cancellation policy", "Confirmed")} {_detail_row("Payment terms", "Confirmed")} {_detail_row("Emergency consent", "Confirmed")} {_detail_row("Liability terms", "Confirmed")} {_detail_row("Accuracy declaration", "Confirmed")}
{signature_block}
Session info
{_meta_row("IP address", ip)} {_meta_row("Browser", browser)} {visit_time_row} {form_time_row} {referrer_row} {page_row}
""" @app.post("/onboarding-submit") async def submit_onboarding(data: OnboardingSubmission, 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", "")) await _enforce_submit_rate_limits(request_id, ip, str(data.email)) _enforce_form_timing(request_id, data) if _is_honeypot_triggered(data): logger.warning( "[%s] onboarding honeypot triggered for ip=%s email=%s page=%r", request_id, ip, data.email, data.page, ) return { "ok": True, "request_id": request_id, "ignored": True, } _validate_onboarding_submission(request_id, data) _normalize_onboarding_submission(data) logger.info( "[%s] /onboarding-submit: email=%s ip=%s browser=%r dog=%s services=%s page=%r", request_id, data.email, ip, browser, data.dogName, data.servicesNeeded, data.page, ) logger.debug("[%s] onboarding payload: %s", request_id, data.model_dump()) owner_payload = { "from": FROM_EMAIL, "to": [OWNER_EMAIL], "reply_to": data.email, "subject": f"New GoodWalk onboarding — {data.fullName} ({data.dogName})", "html": owner_onboarding_email(data, ip, browser), } if OWNER_BCC: owner_payload["bcc"] = [OWNER_BCC] try: await _send_email( owner_payload, label="owner_onboarding_email", request_id=request_id, ) except Exception as exc: logger.error("[%s] onboarding email failed after retries: %s", request_id, exc, exc_info=True) raise HTTPException( status_code=502, detail={ "request_id": request_id, "message": "The onboarding form could not be delivered. Please try again shortly.", "error_type": type(exc).__name__, }, ) await _register_email(str(data.email)) await _store_client_profile(str(data.email), { "fullName": data.fullName, "phone": data.phone, "address": data.address, "dogName": data.dogName, "dogBreed": data.dogBreed, "dogAge": data.dogAge, "onboardingCompleted": True, }) return { "ok": True, "request_id": request_id, } @app.post("/contract-submit") async def submit_contract(data: ContractSubmission, 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", "")) await _enforce_submit_rate_limits(request_id, ip, str(data.email)) _enforce_form_timing(request_id, data) if _is_honeypot_triggered(data): logger.warning( "[%s] contract honeypot triggered for ip=%s email=%s page=%r", request_id, ip, data.email, data.page, ) return {"ok": True, "request_id": request_id, "ignored": True} _validate_contract_submission(request_id, data) _normalize_contract_submission(data) logger.info( "[%s] /contract-submit: email=%s ip=%s browser=%r dog=%s service=%s page=%r", request_id, data.email, ip, browser, data.dogName, data.serviceType, data.page, ) owner_payload = { "from": FROM_EMAIL, "to": [OWNER_EMAIL], "reply_to": data.email, "subject": f"New GoodWalk contract — {data.fullName} ({data.dogName}, {data.serviceType})", "html": owner_contract_email(data, ip, browser), } if OWNER_BCC: owner_payload["bcc"] = [OWNER_BCC] try: await _send_email(owner_payload, label="owner_contract_email", request_id=request_id) except Exception as exc: logger.error("[%s] contract email failed after retries: %s", request_id, exc, exc_info=True) raise HTTPException( status_code=502, detail={ "request_id": request_id, "message": "The contract could not be delivered. Please try again shortly.", "error_type": type(exc).__name__, }, ) await _register_email(str(data.email)) await _store_client_profile(str(data.email), { "fullName": data.fullName, "phone": data.phone, "address": data.address, "dogName": data.dogName, "dogBreed": data.dogBreed, "dogAge": data.dogAge, "contractCompleted": True, }) return {"ok": True, "request_id": request_id}