mail api improvements
This commit is contained in:
@@ -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']
|
||||
+227
-27
@@ -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({
|
||||
await _send_email(
|
||||
{
|
||||
"from": FROM_EMAIL,
|
||||
"to": [data.email],
|
||||
"reply_to": REPLY_TO,
|
||||
"subject": f"We received your enquiry, {data.fullName.split()[0]}! 🐾",
|
||||
"subject": f"We received your enquiry, {first_name}! 🐾",
|
||||
"html": client_email(data),
|
||||
})
|
||||
logger.info("Client confirmation sent to %s", data.email)
|
||||
},
|
||||
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({
|
||||
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),
|
||||
})
|
||||
logger.info("Owner notification sent to %s", OWNER_EMAIL)
|
||||
},
|
||||
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],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user