From 8f31a3fea49ce1378eb8329f638eddeea1182dc9 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sat, 2 May 2026 09:08:31 +1200 Subject: [PATCH] mail api improvements --- mail-api/logs/mail-api.log | 4 + mail-api/main.py | 272 ++++++++++++++++++++++++++++++++----- 2 files changed, 240 insertions(+), 36 deletions(-) create mode 100644 mail-api/logs/mail-api.log diff --git a/mail-api/logs/mail-api.log b/mail-api/logs/mail-api.log new file mode 100644 index 0000000..cc42dd1 --- /dev/null +++ b/mail-api/logs/mail-api.log @@ -0,0 +1,4 @@ +2026-05-02 09:07:05 INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +2026-05-02 09:07:05 CRITICAL mail-api: Required environment variable RESEND_API_KEY is not set +2026-05-02 09:07:45 INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +2026-05-02 09:07:45 CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL'] diff --git a/mail-api/main.py b/mail-api/main.py index 1f470b7..286cad4 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -1,33 +1,144 @@ -import os +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.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) -resend.api_key = os.environ["RESEND_API_KEY"] +# ── Logging ────────────────────────────────────────────────────────────────── -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") +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" -LOGO_URL = "https://www.goodwalk.co.nz/wp-content/uploads/2022/06/logo-v6.png" + fmt = logging.Formatter( + "%(asctime)s %(levelname)-8s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) -app = FastAPI() + 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"], + 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 @@ -417,43 +528,132 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str: """ -# ── Route ──────────────────────────────────────────────────────────────────── +# ── 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", "")) - logger.info("Booking from %s (%s, %s) for dog %s", data.email, ip, browser, data.petName) - errors = [] + 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: - resend.Emails.send({ - "from": FROM_EMAIL, - "to": [data.email], - "reply_to": REPLY_TO, - "subject": f"We received your enquiry, {data.fullName.split()[0]}! 🐾", - "html": client_email(data), - }) - logger.info("Client confirmation sent to %s", data.email) + 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: - logger.error("Failed to send client email: %s", exc) - errors.append("client_email") + 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: - resend.Emails.send({ - "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), - }) - logger.info("Owner notification sent to %s", OWNER_EMAIL) + 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: - logger.error("Failed to send owner email: %s", exc) - errors.append("owner_email") + 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 "client_email" in errors and "owner_email" in errors: - raise HTTPException(status_code=500, detail="Failed to send emails. Please try again.") + 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, + }, + ) - return {"ok": True} + 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], + }