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 "), "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""" {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: 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""" We received your enquiry
{_logo_header(subtitle="Auckland’s favourite dog walking service")}

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

We’ve received your enquiry and Aless will be in touch shortly to arrange a Meet & Greet with you and {data.petName}.

Your enquiry summary
{_detail_row("Your name", data.fullName)} {_detail_row("Email", data.email)} {_detail_row("Phone", data.phone)} {_detail_row("Dog’s name", data.petName)} {_detail_row("Location", data.location)} {_detail_row("Services", services_text)} {message_row}
What happens next?
Aless will review your details and reach out within 1–2 business days to schedule a free Meet & Greet. No commitment required — just a chance for {data.petName} to make a new best friend.

Questions? Just reply to this email or reach us at {REPLY_TO}.

GoodWalk · Auckland, New Zealand
goodwalk.co.nz
""" 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"""
About the dog
{data.message}
""" if data.message else "" badge = """
📩  New lead!
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 "" return f""" New GoodWalk Lead
{_logo_header(badge_html=badge)}
Owner details
Name {data.fullName}
Email {data.email}
Phone {data.phone}
Dog & services
{message_block}
Dog {data.petName}
Location {data.location}
Services {services_text}
Reply to {data.fullName.split()[0]} Call {data.phone}
Session info
{_meta_row("IP address", ip)} {_meta_row("Browser", browser)} {referrer_row} {page_row}
Sent automatically by GoodWalk booking 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", "")) 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], }