2026-05-02 09:08:31 +12:00
|
|
|
import asyncio
|
2026-05-02 11:24:11 +12:00
|
|
|
from collections import deque
|
2026-05-02 08:26:18 +12:00
|
|
|
import logging
|
2026-05-02 09:08:31 +12:00
|
|
|
import logging.handlers
|
|
|
|
|
import os
|
|
|
|
|
import random
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
import uuid
|
2026-05-02 08:26:18 +12:00
|
|
|
from datetime import datetime
|
2026-05-02 09:08:31 +12:00
|
|
|
from pathlib import Path
|
2026-05-02 08:26:18 +12:00
|
|
|
|
|
|
|
|
import resend
|
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
from pydantic import BaseModel, EmailStr
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 09:08:31 +12:00
|
|
|
# ── 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",
|
2026-05-02 19:44:45 +12:00
|
|
|
datefmt="%d/%m/%Y %H:%M:%S %Z",
|
2026-05-02 09:08:31 +12:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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"))),
|
2026-05-02 11:24:11 +12:00
|
|
|
"form_min_seconds": max(1, int(os.environ.get("FORM_MIN_SECONDS", "4"))),
|
|
|
|
|
"form_max_seconds": max(60, int(os.environ.get("FORM_MAX_SECONDS", "7200"))),
|
|
|
|
|
"rate_limit_window_seconds": max(60, int(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", "900"))),
|
|
|
|
|
"rate_limit_max_per_ip": max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_IP", "5"))),
|
|
|
|
|
"rate_limit_max_per_email": max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_EMAIL", "3"))),
|
|
|
|
|
"rate_limit_min_interval_seconds": max(1, int(os.environ.get("RATE_LIMIT_MIN_INTERVAL_SECONDS", "20"))),
|
2026-05-02 09:08:31 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_config = _load_config()
|
2026-05-02 19:44:45 +12:00
|
|
|
APP_VERSION = os.environ.get("APP_VERSION", "unknown")
|
2026-05-02 09:08:31 +12:00
|
|
|
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"]
|
2026-05-02 11:24:11 +12:00
|
|
|
FORM_MIN_SECONDS = _config["form_min_seconds"]
|
|
|
|
|
FORM_MAX_SECONDS = _config["form_max_seconds"]
|
|
|
|
|
RATE_LIMIT_WINDOW_SECONDS = _config["rate_limit_window_seconds"]
|
|
|
|
|
RATE_LIMIT_MAX_PER_IP = _config["rate_limit_max_per_ip"]
|
|
|
|
|
RATE_LIMIT_MAX_PER_EMAIL = _config["rate_limit_max_per_email"]
|
|
|
|
|
RATE_LIMIT_MIN_INTERVAL_SECONDS = _config["rate_limit_min_interval_seconds"]
|
2026-05-02 09:08:31 +12:00
|
|
|
|
2026-05-02 12:39:55 +12:00
|
|
|
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
|
2026-05-02 09:08:31 +12:00
|
|
|
|
|
|
|
|
logger.info(
|
2026-05-02 19:44:45 +12:00
|
|
|
"Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss",
|
|
|
|
|
APP_VERSION,
|
|
|
|
|
os.environ.get("TZ", "system-default"),
|
2026-05-02 11:24:11 +12:00
|
|
|
FROM_EMAIL,
|
|
|
|
|
REPLY_TO,
|
|
|
|
|
OWNER_EMAIL,
|
|
|
|
|
MAX_SEND_ATTEMPTS,
|
|
|
|
|
FORM_MIN_SECONDS,
|
|
|
|
|
FORM_MAX_SECONDS,
|
|
|
|
|
RATE_LIMIT_WINDOW_SECONDS,
|
|
|
|
|
RATE_LIMIT_MAX_PER_IP,
|
|
|
|
|
RATE_LIMIT_MAX_PER_EMAIL,
|
|
|
|
|
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
2026-05-02 09:08:31 +12:00
|
|
|
)
|
2026-05-02 08:26:18 +12:00
|
|
|
|
2026-05-02 09:08:31 +12:00
|
|
|
app = FastAPI(title="GoodWalk Mail API")
|
2026-05-02 08:26:18 +12:00
|
|
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
allow_origins=["*"],
|
2026-05-02 09:08:31 +12:00
|
|
|
allow_methods=["POST", "GET"],
|
2026-05-02 08:26:18 +12:00
|
|
|
allow_headers=["*"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 09:08:31 +12:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 08:26:18 +12:00
|
|
|
class BookingSubmission(BaseModel):
|
|
|
|
|
fullName: str
|
|
|
|
|
email: EmailStr
|
|
|
|
|
phone: str
|
|
|
|
|
petName: str
|
|
|
|
|
location: str
|
|
|
|
|
message: str = ""
|
|
|
|
|
services: list[str] = []
|
2026-05-02 11:24:11 +12:00
|
|
|
website: str = ""
|
|
|
|
|
formStartedAt: int | None = None
|
2026-05-02 08:26:18 +12:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 11:24:11 +12:00
|
|
|
_submit_attempts_by_ip: dict[str, deque[float]] = {}
|
|
|
|
|
_submit_attempts_by_email: dict[str, deque[float]] = {}
|
|
|
|
|
_submit_rate_limit_lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _trimmed(value: str) -> str:
|
|
|
|
|
return value.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _prune_attempts(attempts: deque[float], now: float, window_seconds: int) -> None:
|
|
|
|
|
while attempts and now - attempts[0] > window_seconds:
|
|
|
|
|
attempts.popleft()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seconds_until_allowed(last_attempt_at: float, now: float, min_interval_seconds: int) -> int:
|
|
|
|
|
retry_after = max(1, int(min_interval_seconds - (now - last_attempt_at)))
|
|
|
|
|
return retry_after
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _enforce_submit_rate_limits(request_id: str, ip: str, email: str) -> None:
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
normalized_email = email.strip().lower()
|
|
|
|
|
|
|
|
|
|
async with _submit_rate_limit_lock:
|
|
|
|
|
ip_attempts = _submit_attempts_by_ip.setdefault(ip, deque())
|
|
|
|
|
email_attempts = _submit_attempts_by_email.setdefault(normalized_email, deque())
|
|
|
|
|
|
|
|
|
|
_prune_attempts(ip_attempts, now, RATE_LIMIT_WINDOW_SECONDS)
|
|
|
|
|
_prune_attempts(email_attempts, now, RATE_LIMIT_WINDOW_SECONDS)
|
|
|
|
|
|
|
|
|
|
if ip_attempts and now - ip_attempts[-1] < RATE_LIMIT_MIN_INTERVAL_SECONDS:
|
|
|
|
|
retry_after = _seconds_until_allowed(ip_attempts[-1], now, RATE_LIMIT_MIN_INTERVAL_SECONDS)
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[%s] rate limited: ip=%s submitted again after %.1fs (minimum %ss)",
|
|
|
|
|
request_id,
|
|
|
|
|
ip,
|
|
|
|
|
now - ip_attempts[-1],
|
|
|
|
|
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=429,
|
|
|
|
|
detail=f"Please wait about {retry_after} seconds before trying again.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if len(ip_attempts) >= RATE_LIMIT_MAX_PER_IP:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[%s] rate limited: ip=%s exceeded %d submissions in %ss",
|
|
|
|
|
request_id,
|
|
|
|
|
ip,
|
|
|
|
|
RATE_LIMIT_MAX_PER_IP,
|
|
|
|
|
RATE_LIMIT_WINDOW_SECONDS,
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=429,
|
|
|
|
|
detail="Too many enquiries from this connection. Please try again a little later.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if len(email_attempts) >= RATE_LIMIT_MAX_PER_EMAIL:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[%s] rate limited: email=%s exceeded %d submissions in %ss",
|
|
|
|
|
request_id,
|
|
|
|
|
normalized_email,
|
|
|
|
|
RATE_LIMIT_MAX_PER_EMAIL,
|
|
|
|
|
RATE_LIMIT_WINDOW_SECONDS,
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=429,
|
|
|
|
|
detail="That email address has reached the enquiry limit for now. Please try again later.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ip_attempts.append(now)
|
|
|
|
|
email_attempts.append(now)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _enforce_form_timing(request_id: str, data: BookingSubmission) -> None:
|
|
|
|
|
if data.formStartedAt is None or data.formStartedAt <= 0:
|
|
|
|
|
logger.warning("[%s] rejected: missing or invalid formStartedAt", request_id)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Please refresh the page and try again.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
elapsed_seconds = (time.time() * 1000 - data.formStartedAt) / 1000
|
|
|
|
|
|
|
|
|
|
if elapsed_seconds < FORM_MIN_SECONDS:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[%s] rejected: form submitted too quickly (%.2fs < %ss)",
|
|
|
|
|
request_id,
|
|
|
|
|
elapsed_seconds,
|
|
|
|
|
FORM_MIN_SECONDS,
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Please take a moment to fill in the form before sending it.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if elapsed_seconds > FORM_MAX_SECONDS:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[%s] rejected: stale form submission (%.0fs > %ss)",
|
|
|
|
|
request_id,
|
|
|
|
|
elapsed_seconds,
|
|
|
|
|
FORM_MAX_SECONDS,
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="This form has been open for too long. Please refresh the page and try again.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_honeypot_triggered(data: BookingSubmission) -> bool:
|
|
|
|
|
return bool(_trimmed(data.website))
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 08:26:18 +12:00
|
|
|
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"""
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:8px 0;color:#888;font-size:13px;white-space:nowrap;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;width:130px;">{label}</td>
|
|
|
|
|
<td style="padding:8px 0 8px 16px;color:#213021;font-size:14px;font-weight:500;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">{value}</td>
|
|
|
|
|
</tr>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _meta_row(label: str, value: str) -> str:
|
|
|
|
|
if not value:
|
|
|
|
|
return ""
|
|
|
|
|
return f"""
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:5px 0;color:#aaa;font-size:12px;white-space:nowrap;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;width:100px;">{label}</td>
|
|
|
|
|
<td style="padding:5px 0 5px 16px;color:#666;font-size:12px;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;word-break:break-all;">{value}</td>
|
|
|
|
|
</tr>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Email templates ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
|
|
|
|
|
badge = f'<div style="margin-top:20px;">{badge_html}</div>' if badge_html else ""
|
|
|
|
|
sub = f"""<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:13px;color:#7aaa7a;letter-spacing:0.04em;margin-top:8px;">
|
|
|
|
|
{subtitle}</div>""" if subtitle else ""
|
|
|
|
|
return f"""
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="background:#213021;padding:36px 48px 32px;text-align:center;">
|
|
|
|
|
<img src="{LOGO_URL}" width="161" height="32" alt="GoodWalk"
|
|
|
|
|
style="display:inline-block;max-width:161px;height:auto;border:0;">
|
|
|
|
|
{sub}
|
|
|
|
|
{badge}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"""<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
|
|
|
<title>We received your enquiry</title>
|
|
|
|
|
</head>
|
|
|
|
|
<body style="margin:0;padding:0;background:#f2f2f0;">
|
|
|
|
|
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="background:#f2f2f0;padding:40px 16px;">
|
|
|
|
|
<tr><td align="center">
|
|
|
|
|
|
|
|
|
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
|
|
|
|
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
|
|
|
|
|
2026-05-03 11:16:53 +12:00
|
|
|
{_logo_header(subtitle="Professional dog walking services")}
|
2026-05-02 08:26:18 +12:00
|
|
|
|
|
|
|
|
<!-- Body -->
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="background:#ffffff;padding:48px 48px 40px;">
|
|
|
|
|
|
|
|
|
|
<h1 style="margin:0 0 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:26px;font-weight:700;color:#213021;line-height:1.2;">
|
|
|
|
|
Thanks, {data.fullName.split()[0]}! 🐾
|
|
|
|
|
</h1>
|
|
|
|
|
<p style="margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:16px;color:#555;line-height:1.65;">
|
2026-05-04 16:30:05 +12:00
|
|
|
We’ve received your enquiry and we will be in touch shortly to arrange
|
2026-05-02 08:26:18 +12:00
|
|
|
a <strong style="color:#213021;">Meet & Greet</strong> with you and
|
|
|
|
|
{data.petName}.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<!-- Details card -->
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="background:#f8f7f4;border-radius:12px;margin-bottom:36px;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:28px 32px;">
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:11px;font-weight:700;letter-spacing:0.1em;
|
|
|
|
|
color:#888;text-transform:uppercase;margin-bottom:20px;">
|
|
|
|
|
Your enquiry summary
|
|
|
|
|
</div>
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
|
|
|
{_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}
|
|
|
|
|
</table>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<!-- What's next -->
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="border-left:3px solid #FFD100;margin-bottom:36px;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:4px 0 4px 20px;">
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:13px;font-weight:700;color:#213021;margin-bottom:6px;">
|
|
|
|
|
What happens next?
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:14px;color:#666;line-height:1.6;">
|
2026-05-04 16:30:05 +12:00
|
|
|
We will review your details and reach out within 1 business days
|
2026-05-02 08:26:18 +12:00
|
|
|
to schedule a free Meet & Greet. No commitment required — just a
|
|
|
|
|
chance for {data.petName} to make a new best friend.
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:14px;color:#888;line-height:1.6;">
|
2026-05-04 16:30:05 +12:00
|
|
|
Questions? Just reply to this email or reach us at 022 642 1011.
|
2026-05-02 08:26:18 +12:00
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
<!-- Footer -->
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="background:#213021;padding:24px 48px;text-align:center;">
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:12px;color:#5a8a5a;line-height:1.6;">
|
|
|
|
|
GoodWalk · Auckland, New Zealand<br>
|
|
|
|
|
<a href="https://www.goodwalk.co.nz" style="color:#7aaa7a;text-decoration:none;">
|
|
|
|
|
goodwalk.co.nz
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
</table>
|
|
|
|
|
</td></tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
</body>
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
2026-05-02 19:44:45 +12:00
|
|
|
first_name = data.fullName.split()[0] if data.fullName.strip() else "them"
|
2026-05-02 08:26:18 +12:00
|
|
|
|
|
|
|
|
message_block = f"""
|
|
|
|
|
<tr>
|
|
|
|
|
<td colspan="2" style="padding:16px 0 0;">
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
|
|
|
|
text-transform:uppercase;margin-bottom:8px;">About the dog</div>
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
|
|
|
|
border-radius:8px;padding:14px 16px;">{data.message}</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>""" if data.message else ""
|
|
|
|
|
|
|
|
|
|
badge = """<div style="display:inline-block;background:#FFD100;border-radius:100px;
|
|
|
|
|
padding:10px 28px;">
|
|
|
|
|
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:18px;font-weight:700;color:#213021;">
|
|
|
|
|
📩 New lead!
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:12px;color:#5a8a5a;margin-top:12px;">
|
|
|
|
|
Submitted {submitted_at}
|
|
|
|
|
</div>""".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"""<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
|
|
|
<title>New GoodWalk Lead</title>
|
|
|
|
|
</head>
|
|
|
|
|
<body style="margin:0;padding:0;background:#f2f2f0;">
|
|
|
|
|
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="background:#f2f2f0;padding:40px 16px;">
|
|
|
|
|
<tr><td align="center">
|
|
|
|
|
|
|
|
|
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
|
|
|
|
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
|
|
|
|
|
|
|
|
|
{_logo_header(badge_html=badge)}
|
|
|
|
|
|
|
|
|
|
<!-- Body -->
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="background:#ffffff;padding:40px 48px 36px;">
|
|
|
|
|
|
2026-05-02 19:44:45 +12:00
|
|
|
<!-- Quick contact -->
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="background:#213021;border-radius:12px;margin-bottom:28px;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:22px 24px;">
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;
|
|
|
|
|
text-transform:uppercase;margin-bottom:10px;">
|
|
|
|
|
Quick contact
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:14px;color:#d8e6d8;line-height:1.6;margin-bottom:10px;">
|
|
|
|
|
Email {first_name} directly:
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-family:Menlo,Consolas,'SFMono-Regular',monospace;
|
|
|
|
|
font-size:20px;font-weight:700;color:#ffffff;line-height:1.4;
|
|
|
|
|
word-break:break-all;margin-bottom:12px;">
|
|
|
|
|
{data.email}
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:12px;color:#b7cbb7;line-height:1.6;">
|
|
|
|
|
Tap and hold the address to copy on iPhone, or tap below to open a new email.
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
2026-05-02 08:26:18 +12:00
|
|
|
<!-- Owner details -->
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
|
|
|
|
text-transform:uppercase;margin-bottom:16px;">Owner details</div>
|
|
|
|
|
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
|
|
|
|
<tr><td style="padding:24px 28px;">
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">Name</td>
|
|
|
|
|
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
|
|
|
|
|
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
|
|
|
|
|
'Segoe UI',sans-serif;vertical-align:top;">{data.fullName}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:6px 0;font-size:13px;color:#888;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">Email</td>
|
|
|
|
|
<td style="padding:6px 0 6px 16px;font-size:14px;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">
|
|
|
|
|
<a href="mailto:{data.email}" style="color:#213021;font-weight:500;
|
|
|
|
|
text-decoration:none;">{data.email}</a>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:6px 0;font-size:13px;color:#888;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">Phone</td>
|
|
|
|
|
<td style="padding:6px 0 6px 16px;font-size:14px;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">
|
|
|
|
|
<a href="tel:{data.phone}" style="color:#213021;font-weight:500;
|
|
|
|
|
text-decoration:none;">{data.phone}</a>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
</td></tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<!-- Dog & service details -->
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
|
|
|
|
text-transform:uppercase;margin-bottom:16px;">Dog & services</div>
|
|
|
|
|
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
|
|
|
|
<tr><td style="padding:24px 28px;">
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">Dog</td>
|
|
|
|
|
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
|
|
|
|
|
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
|
|
|
|
|
'Segoe UI',sans-serif;vertical-align:top;">{data.petName}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:6px 0;font-size:13px;color:#888;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">Location</td>
|
|
|
|
|
<td style="padding:6px 0 6px 16px;font-size:14px;font-weight:500;color:#213021;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">{data.location}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding:6px 0;font-size:13px;color:#888;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">Services</td>
|
|
|
|
|
<td style="padding:6px 0 6px 16px;font-size:14px;color:#444;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
vertical-align:top;">{services_text}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{message_block}
|
|
|
|
|
</table>
|
|
|
|
|
</td></tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<!-- CTA buttons -->
|
|
|
|
|
<table cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:32px;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding-right:12px;">
|
|
|
|
|
<a href="mailto:{data.email}"
|
|
|
|
|
style="display:inline-block;background:#213021;color:#FFD100;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:14px;font-weight:600;text-decoration:none;
|
|
|
|
|
border-radius:8px;padding:12px 24px;">
|
2026-05-02 19:44:45 +12:00
|
|
|
Email {first_name}
|
2026-05-02 08:26:18 +12:00
|
|
|
</a>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<a href="tel:{data.phone}"
|
|
|
|
|
style="display:inline-block;background:#f8f7f4;color:#213021;
|
|
|
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:14px;font-weight:600;text-decoration:none;
|
|
|
|
|
border-radius:8px;padding:12px 24px;border:1px solid #e0e0d8;">
|
|
|
|
|
Call {data.phone}
|
|
|
|
|
</a>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<!-- Session info -->
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
|
|
|
|
style="border-top:1px solid #eeeee8;padding-top:20px;">
|
|
|
|
|
<tr><td>
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#ccc;
|
|
|
|
|
text-transform:uppercase;margin-bottom:12px;">Session info</div>
|
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
|
|
|
|
{_meta_row("IP address", ip)}
|
|
|
|
|
{_meta_row("Browser", browser)}
|
|
|
|
|
{referrer_row}
|
|
|
|
|
{page_row}
|
|
|
|
|
</table>
|
|
|
|
|
</td></tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
<!-- Footer -->
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="background:#f8f7f4;padding:18px 48px;text-align:center;
|
|
|
|
|
border-top:1px solid #e8e8e4;">
|
|
|
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
|
|
|
font-size:12px;color:#bbb;">
|
|
|
|
|
Sent automatically by GoodWalk booking form
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
</table>
|
|
|
|
|
</td></tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
</body>
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 09:08:31 +12:00
|
|
|
# ── 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"}
|
|
|
|
|
|
2026-05-02 08:26:18 +12:00
|
|
|
|
|
|
|
|
@app.post("/submit")
|
|
|
|
|
async def submit_booking(data: BookingSubmission, request: Request):
|
2026-05-02 09:08:31 +12:00
|
|
|
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
2026-05-02 08:26:18 +12:00
|
|
|
ip = _get_ip(request)
|
|
|
|
|
browser = _parse_ua(request.headers.get("user-agent", ""))
|
|
|
|
|
|
2026-05-02 09:08:31 +12:00
|
|
|
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())
|
|
|
|
|
|
2026-05-02 11:24:11 +12:00
|
|
|
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
|
|
|
|
_enforce_form_timing(request_id, data)
|
|
|
|
|
|
|
|
|
|
if _is_honeypot_triggered(data):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[%s] honeypot triggered for ip=%s email=%s page=%r",
|
|
|
|
|
request_id,
|
|
|
|
|
ip,
|
|
|
|
|
data.email,
|
|
|
|
|
data.page,
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"request_id": request_id,
|
|
|
|
|
"ignored": True,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 09:08:31 +12:00
|
|
|
failures: list[dict] = []
|
2026-05-02 08:26:18 +12:00
|
|
|
|
|
|
|
|
try:
|
2026-05-02 09:08:31 +12:00
|
|
|
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,
|
|
|
|
|
)
|
2026-05-02 08:26:18 +12:00
|
|
|
except Exception as exc:
|
2026-05-02 09:08:31 +12:00
|
|
|
failures.append({
|
|
|
|
|
"label": "client_email",
|
|
|
|
|
"error_type": type(exc).__name__,
|
|
|
|
|
"error": str(exc),
|
|
|
|
|
"status": getattr(exc, "status_code", None) or getattr(exc, "code", None),
|
|
|
|
|
})
|
2026-05-02 08:26:18 +12:00
|
|
|
|
|
|
|
|
try:
|
2026-05-02 09:08:31 +12:00
|
|
|
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,
|
|
|
|
|
)
|
2026-05-02 08:26:18 +12:00
|
|
|
except Exception as exc:
|
2026-05-02 09:08:31 +12:00
|
|
|
failures.append({
|
|
|
|
|
"label": "owner_email",
|
|
|
|
|
"error_type": type(exc).__name__,
|
|
|
|
|
"error": str(exc),
|
|
|
|
|
"status": getattr(exc, "status_code", None) or getattr(exc, "code", None),
|
|
|
|
|
})
|
2026-05-02 08:26:18 +12:00
|
|
|
|
2026-05-02 09:08:31 +12:00
|
|
|
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],
|
|
|
|
|
}
|