mail api improvements

This commit is contained in:
2026-05-02 09:08:31 +12:00
parent 629f530099
commit 8f31a3fea4
2 changed files with 240 additions and 36 deletions
+4
View File
@@ -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']
+236 -36
View File
@@ -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 <bookings@goodwalk.co.nz>")
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 <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"],
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:
</html>"""
# ── 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],
}