import asyncio from collections import deque 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="%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 ──────────────────────────────────────────────────────────── 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 "), "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") 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") 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): enquiryType: str = "booking" fullName: str email: EmailStr phone: str petName: str = "" location: str = "" message: str = "" services: list[str] = [] website: str = "" formStartedAt: int | None = None 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" _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: BookingSubmission) -> 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: BookingSubmission) -> 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)] if _is_general_enquiry(data): data.petName = "" data.location = "" data.services = [] 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} """ # ── 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 "" 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)}
Quick contact
Email {first_name} directly:
{data.email}
Tap and hold the address to copy on iPhone, or tap below to open a new email.
Owner details
Name {data.fullName}
Email {data.email}
Phone {data.phone}
{detail_heading}
{"".join(detail_rows)} {message_block}
Email {first_name} Call {data.phone}
Session info
{_meta_row("IP address", ip)} {_meta_row("Browser", browser)} {referrer_row} {page_row}
Sent automatically by GoodWalk enquiry form
""" # ── 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", "")) 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) return { "ok": True, "request_id": request_id, "partial_failures": [f["label"] for f in failures], }