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']
|
||||||
+236
-36
@@ -1,33 +1,144 @@
|
|||||||
import os
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import resend
|
import resend
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel, EmailStr
|
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"]
|
def _setup_logging() -> logging.Logger:
|
||||||
FROM_EMAIL = os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>")
|
log_dir = Path(os.environ.get("LOG_DIR", "logs"))
|
||||||
REPLY_TO = os.environ.get("REPLY_TO", "aless@goodwalk.co.nz")
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_methods=["POST"],
|
allow_methods=["POST", "GET"],
|
||||||
allow_headers=["*"],
|
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):
|
class BookingSubmission(BaseModel):
|
||||||
fullName: str
|
fullName: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
@@ -417,43 +528,132 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
|||||||
</html>"""
|
</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")
|
@app.post("/submit")
|
||||||
async def submit_booking(data: BookingSubmission, request: Request):
|
async def submit_booking(data: BookingSubmission, request: Request):
|
||||||
|
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||||||
ip = _get_ip(request)
|
ip = _get_ip(request)
|
||||||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
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:
|
try:
|
||||||
resend.Emails.send({
|
await _send_email(
|
||||||
"from": FROM_EMAIL,
|
{
|
||||||
"to": [data.email],
|
"from": FROM_EMAIL,
|
||||||
"reply_to": REPLY_TO,
|
"to": [data.email],
|
||||||
"subject": f"We received your enquiry, {data.fullName.split()[0]}! 🐾",
|
"reply_to": REPLY_TO,
|
||||||
"html": client_email(data),
|
"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:
|
except Exception as exc:
|
||||||
logger.error("Failed to send client email: %s", exc)
|
failures.append({
|
||||||
errors.append("client_email")
|
"label": "client_email",
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"error": str(exc),
|
||||||
|
"status": getattr(exc, "status_code", None) or getattr(exc, "code", None),
|
||||||
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resend.Emails.send({
|
await _send_email(
|
||||||
"from": FROM_EMAIL,
|
{
|
||||||
"to": [OWNER_EMAIL],
|
"from": FROM_EMAIL,
|
||||||
"reply_to": data.email,
|
"to": [OWNER_EMAIL],
|
||||||
"subject": f"New GoodWalk lead — {data.fullName} ({data.petName})",
|
"reply_to": data.email,
|
||||||
"html": owner_email(data, ip, browser),
|
"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:
|
except Exception as exc:
|
||||||
logger.error("Failed to send owner email: %s", exc)
|
failures.append({
|
||||||
errors.append("owner_email")
|
"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:
|
if len(failures) == 2:
|
||||||
raise HTTPException(status_code=500, detail="Failed to send emails. Please try again.")
|
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