2126 lines
89 KiB
Python
2126 lines
89 KiB
Python
import asyncio
|
||
from collections import deque
|
||
import json
|
||
import logging
|
||
import logging.handlers
|
||
import os
|
||
import random
|
||
import re
|
||
import secrets
|
||
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 ──────────────────────────────────────────────────────────────────
|
||
|
||
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",
|
||
datefmt="%d/%m/%Y %H:%M:%S %Z",
|
||
)
|
||
|
||
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 ────────────────────────────────────────────────────────────
|
||
|
||
DEV_MODE = os.environ.get("DEV_MODE", "").strip().lower() in {"1", "true", "yes"}
|
||
|
||
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:
|
||
if DEV_MODE:
|
||
return {
|
||
"resend_api_key": os.environ.get("RESEND_API_KEY", "dev"),
|
||
"owner_email": os.environ.get("OWNER_EMAIL", "dev@localhost"),
|
||
"from_email": os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>"),
|
||
"reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"),
|
||
"owner_bcc": "",
|
||
"client_bcc": "",
|
||
"enable_general_enquiries": False,
|
||
"max_attempts": 3,
|
||
"form_min_seconds": 1,
|
||
"form_max_seconds": 7200,
|
||
"rate_limit_window_seconds": 900,
|
||
"rate_limit_max_per_ip": 50,
|
||
"rate_limit_max_per_email": 50,
|
||
"rate_limit_min_interval_seconds": 1,
|
||
}
|
||
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"),
|
||
"owner_bcc": os.environ.get("OWNER_BCC", "example@example.com").strip(),
|
||
"client_bcc": os.environ.get("CLIENT_BCC", "").strip(),
|
||
"enable_general_enquiries": os.environ.get("ENABLE_GENERAL_ENQUIRIES", "false").strip().lower() in {"1", "true", "yes", "on", "enabled"},
|
||
"max_attempts": max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))),
|
||
"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"))),
|
||
}
|
||
|
||
|
||
_config = _load_config()
|
||
APP_VERSION = os.environ.get("APP_VERSION", "unknown")
|
||
AUTH_CODE_TTL_SECONDS = max(60, int(os.environ.get("AUTH_CODE_TTL_SECONDS", "600")))
|
||
AUTH_SESSION_TTL_SECONDS = max(3600, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(7 * 24 * 3600))))
|
||
AUTH_CODE_MAX_ATTEMPTS = 5
|
||
AUTH_CODE_REQUESTS_PER_HOUR = 5
|
||
AUTH_IP_MAX_FAILURES = max(3, int(os.environ.get("AUTH_IP_MAX_FAILURES", "10")))
|
||
AUTH_IP_FAILURE_WINDOW = max(60, int(os.environ.get("AUTH_IP_FAILURE_WINDOW", "600")))
|
||
AUTH_IP_BLOCK_DURATION = max(60, int(os.environ.get("AUTH_IP_BLOCK_DURATION", "3600")))
|
||
_ALLOWED_EMAILS_FILE = Path(os.environ.get("DATA_DIR", "data")) / "allowed_emails.json"
|
||
_CLIENT_PROFILES_FILE = Path(os.environ.get("DATA_DIR", "data")) / "client_profiles.json"
|
||
_DRAFTS_FILE = Path(os.environ.get("DATA_DIR", "data")) / "drafts.json"
|
||
resend.api_key = _config["resend_api_key"]
|
||
OWNER_EMAIL = _config["owner_email"]
|
||
OWNER_BCC = _config["owner_bcc"]
|
||
CLIENT_BCC = _config["client_bcc"]
|
||
FROM_EMAIL = _config["from_email"]
|
||
REPLY_TO = _config["reply_to"]
|
||
ENABLE_GENERAL_ENQUIRIES = _config["enable_general_enquiries"]
|
||
MAX_SEND_ATTEMPTS = _config["max_attempts"]
|
||
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"]
|
||
|
||
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
|
||
|
||
logger.info(
|
||
"Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r owner_bcc=%r client_bcc=%r general_enquiries=%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"),
|
||
FROM_EMAIL,
|
||
REPLY_TO,
|
||
OWNER_EMAIL,
|
||
OWNER_BCC,
|
||
CLIENT_BCC,
|
||
ENABLE_GENERAL_ENQUIRIES,
|
||
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,
|
||
)
|
||
|
||
app = FastAPI(title="GoodWalk Mail API")
|
||
STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else ""
|
||
|
||
# ── Auth state ───────────────────────────────────────────────────────────────
|
||
|
||
def _load_allowed_emails() -> set[str]:
|
||
seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()}
|
||
try:
|
||
if _ALLOWED_EMAILS_FILE.exists():
|
||
data = json.loads(_ALLOWED_EMAILS_FILE.read_text(encoding="utf-8"))
|
||
seed.update(e.lower() for e in data.get("emails", []) if isinstance(e, str))
|
||
except Exception as exc:
|
||
logger.warning("Could not load allowed_emails file: %s", exc)
|
||
return seed
|
||
|
||
def _save_allowed_emails_sync(emails: set[str]) -> None:
|
||
try:
|
||
_ALLOWED_EMAILS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
_ALLOWED_EMAILS_FILE.write_text(
|
||
json.dumps({"emails": sorted(emails)}, indent=2), encoding="utf-8"
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Could not save allowed_emails: %s", exc)
|
||
|
||
|
||
def _load_client_profiles() -> dict[str, dict]:
|
||
try:
|
||
if _CLIENT_PROFILES_FILE.exists():
|
||
return json.loads(_CLIENT_PROFILES_FILE.read_text(encoding="utf-8"))
|
||
except Exception as exc:
|
||
logger.warning("Could not load client_profiles file: %s", exc)
|
||
return {}
|
||
|
||
|
||
def _save_client_profiles_sync(profiles: dict) -> None:
|
||
try:
|
||
_CLIENT_PROFILES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
_CLIENT_PROFILES_FILE.write_text(json.dumps(profiles, indent=2), encoding="utf-8")
|
||
except Exception as exc:
|
||
logger.warning("Could not save client_profiles: %s", exc)
|
||
|
||
|
||
def _load_drafts() -> dict:
|
||
try:
|
||
if _DRAFTS_FILE.exists():
|
||
return json.loads(_DRAFTS_FILE.read_text(encoding="utf-8"))
|
||
except Exception as exc:
|
||
logger.warning("Could not load drafts file: %s", exc)
|
||
return {}
|
||
|
||
|
||
def _save_drafts_sync(drafts: dict) -> None:
|
||
try:
|
||
_DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
_DRAFTS_FILE.write_text(json.dumps(drafts, indent=2), encoding="utf-8")
|
||
except Exception as exc:
|
||
logger.warning("Could not save drafts: %s", exc)
|
||
|
||
|
||
_allowed_emails: set[str] = _load_allowed_emails()
|
||
_pending_codes: dict[str, dict] = {} # email -> {code, expires_at, attempts}
|
||
_active_sessions: dict[str, dict] = {} # token -> {email, expires_at}
|
||
_code_requests: dict[str, deque] = {} # email -> deque of monotonic timestamps
|
||
_client_profiles: dict[str, dict] = _load_client_profiles()
|
||
_drafts: dict[str, dict] = _load_drafts() # email -> {onboarding: {...}, contract: {...}}
|
||
_auth_failures_by_ip: dict[str, deque] = {} # ip -> deque of failure timestamps
|
||
_blocked_ips: dict[str, float] = {} # ip -> unblock_at (monotonic)
|
||
_auth_lock = asyncio.Lock()
|
||
|
||
logger.info("Auth: loaded %d allowed email(s)", len(_allowed_emails))
|
||
|
||
|
||
async def _register_email(email: str) -> None:
|
||
normalized = email.strip().lower()
|
||
if not normalized:
|
||
return
|
||
async with _auth_lock:
|
||
if normalized not in _allowed_emails:
|
||
_allowed_emails.add(normalized)
|
||
await asyncio.to_thread(_save_allowed_emails_sync, set(_allowed_emails))
|
||
logger.info("Auth: registered new allowed email: %s", normalized)
|
||
|
||
|
||
async def _store_client_profile(email: str, profile: dict) -> None:
|
||
normalized = email.strip().lower()
|
||
if not normalized:
|
||
return
|
||
async with _auth_lock:
|
||
existing = _client_profiles.get(normalized, {})
|
||
merged = {k: v for k, v in {**existing, **profile}.items() if v}
|
||
if merged != existing:
|
||
_client_profiles[normalized] = merged
|
||
await asyncio.to_thread(_save_client_profiles_sync, dict(_client_profiles))
|
||
|
||
def _check_ip_blocked(ip: str, request_id: str) -> None:
|
||
now = time.monotonic()
|
||
unblock_at = _blocked_ips.get(ip)
|
||
if unblock_at is not None:
|
||
if now < unblock_at:
|
||
remaining = int(unblock_at - now)
|
||
logger.warning("[%s] auth: blocked ip=%s (%ds remaining)", request_id, ip, remaining)
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=f"Too many failed attempts. Try again in {remaining // 60 + 1} minute(s).",
|
||
headers={"Retry-After": str(remaining)},
|
||
)
|
||
else:
|
||
del _blocked_ips[ip]
|
||
|
||
|
||
def _record_auth_failure(ip: str, request_id: str, reason: str) -> None:
|
||
now = time.monotonic()
|
||
failures = _auth_failures_by_ip.setdefault(ip, deque())
|
||
while failures and now - failures[0] > AUTH_IP_FAILURE_WINDOW:
|
||
failures.popleft()
|
||
failures.append(now)
|
||
logger.warning("[%s] auth: failure ip=%s reason=%r total_in_window=%d", request_id, ip, reason, len(failures))
|
||
if len(failures) >= AUTH_IP_MAX_FAILURES:
|
||
_blocked_ips[ip] = now + AUTH_IP_BLOCK_DURATION
|
||
logger.warning(
|
||
"[%s] auth: ip=%s BLOCKED for %ds after %d failures",
|
||
request_id, ip, AUTH_IP_BLOCK_DURATION, len(failures),
|
||
)
|
||
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
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 BaseSubmission(BaseModel):
|
||
fullName: str
|
||
email: EmailStr
|
||
phone: str
|
||
website: str = ""
|
||
formStartedAt: int | None = None
|
||
visitStartedAt: int | None = None
|
||
pageEnteredAt: int | None = None
|
||
firstInteractionAt: int | None = None
|
||
sendClickedAt: int | None = None
|
||
referrer: str = ""
|
||
page: str = ""
|
||
|
||
|
||
class BookingSubmission(BaseSubmission):
|
||
enquiryType: str = "booking"
|
||
petName: str = ""
|
||
location: str = ""
|
||
message: str = ""
|
||
services: list[str] = []
|
||
stepChanges: int = 0
|
||
journey: list[str] = []
|
||
|
||
|
||
class OnboardingSubmission(BaseSubmission):
|
||
address: str
|
||
dogName: str
|
||
dogBreed: str
|
||
dogAge: str = ""
|
||
servicesNeeded: list[str] = []
|
||
temperament: str = ""
|
||
medicalNotes: str = ""
|
||
accessInstructions: str = ""
|
||
vetName: str
|
||
vetPhone: str
|
||
emergencyContactName: str
|
||
emergencyContactPhone: str
|
||
councilRegistrationConfirmed: bool = False
|
||
vaccinationsConfirmed: bool = False
|
||
emergencyVetConsent: bool = False
|
||
termsAccepted: bool = False
|
||
signatureDataUrl: str
|
||
|
||
|
||
class ContractSubmission(BaseSubmission):
|
||
address: str
|
||
dogName: str
|
||
dogBreed: str
|
||
dogAge: str = ""
|
||
serviceType: str
|
||
startDate: str
|
||
walkFrequency: str = ""
|
||
additionalNotes: str = ""
|
||
agreeServiceTerms: bool = False
|
||
agreeCancellation: bool = False
|
||
agreePayment: bool = False
|
||
agreeEmergency: bool = False
|
||
agreeLiability: bool = False
|
||
agreeAccuracy: bool = False
|
||
signatureDataUrl: 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"
|
||
|
||
|
||
_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: BaseSubmission) -> 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: BaseSubmission) -> bool:
|
||
return bool(_trimmed(data.website))
|
||
|
||
|
||
def _is_general_enquiry(data: BookingSubmission) -> bool:
|
||
return _trimmed(data.enquiryType).lower() == "general"
|
||
|
||
|
||
def _enquiry_type_label(data: BookingSubmission) -> str:
|
||
return "General enquiry" if _is_general_enquiry(data) else "Booking enquiry"
|
||
|
||
|
||
def _validate_submission(request_id: str, data: BookingSubmission) -> None:
|
||
enquiry_type = _trimmed(data.enquiryType).lower()
|
||
|
||
if enquiry_type not in {"booking", "general"}:
|
||
logger.warning("[%s] rejected: invalid enquiryType=%r", request_id, data.enquiryType)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please choose a valid enquiry type and try again.",
|
||
)
|
||
|
||
if not _trimmed(data.fullName):
|
||
logger.warning("[%s] rejected: missing full name", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please enter your full name.",
|
||
)
|
||
|
||
if not _trimmed(data.phone):
|
||
logger.warning("[%s] rejected: missing phone number", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please enter your contact number.",
|
||
)
|
||
|
||
if _is_general_enquiry(data):
|
||
if not ENABLE_GENERAL_ENQUIRIES:
|
||
logger.warning("[%s] rejected: general enquiries are disabled", request_id)
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="General enquiries are currently unavailable through this form.",
|
||
)
|
||
if not _trimmed(data.message):
|
||
logger.warning("[%s] rejected: missing general enquiry message", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please tell us how we can help.",
|
||
)
|
||
return
|
||
|
||
if not _trimmed(data.petName):
|
||
logger.warning("[%s] rejected: missing pet name", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please enter your dog's name.",
|
||
)
|
||
|
||
if not _trimmed(data.location):
|
||
logger.warning("[%s] rejected: missing location", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please enter your location.",
|
||
)
|
||
|
||
|
||
def _normalize_submission(data: BookingSubmission) -> None:
|
||
data.enquiryType = "general" if _is_general_enquiry(data) else "booking"
|
||
data.fullName = _trimmed(data.fullName)
|
||
data.phone = _trimmed(data.phone)
|
||
data.petName = _trimmed(data.petName)
|
||
data.location = _trimmed(data.location)
|
||
data.message = _trimmed(data.message)
|
||
data.referrer = _trimmed(data.referrer)
|
||
data.page = _trimmed(data.page)
|
||
data.services = [_trimmed(service) for service in data.services if _trimmed(service)]
|
||
data.journey = [_trimmed(step) for step in data.journey if _trimmed(step)][:12]
|
||
data.stepChanges = max(0, data.stepChanges)
|
||
|
||
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
|
||
value = getattr(data, field_name)
|
||
if value is None or value <= 0:
|
||
setattr(data, field_name, None)
|
||
|
||
if _is_general_enquiry(data):
|
||
data.petName = ""
|
||
data.location = ""
|
||
data.services = []
|
||
|
||
|
||
def _validate_onboarding_submission(request_id: str, data: OnboardingSubmission) -> None:
|
||
if not _trimmed(data.fullName):
|
||
logger.warning("[%s] onboarding rejected: missing full name", request_id)
|
||
raise HTTPException(status_code=400, detail="Please enter your full name.")
|
||
|
||
if not _trimmed(data.phone):
|
||
logger.warning("[%s] onboarding rejected: missing phone", request_id)
|
||
raise HTTPException(status_code=400, detail="Please enter your phone number.")
|
||
|
||
required_fields = {
|
||
"address": "Please enter your address.",
|
||
"dogName": "Please enter your dog's name.",
|
||
"dogBreed": "Please enter your dog's breed.",
|
||
"vetName": "Please enter your vet clinic name.",
|
||
"vetPhone": "Please enter your vet phone number.",
|
||
"emergencyContactName": "Please enter an emergency contact name.",
|
||
"emergencyContactPhone": "Please enter an emergency contact phone number.",
|
||
}
|
||
|
||
for field_name, message in required_fields.items():
|
||
if not _trimmed(getattr(data, field_name)):
|
||
logger.warning("[%s] onboarding rejected: missing %s", request_id, field_name)
|
||
raise HTTPException(status_code=400, detail=message)
|
||
|
||
if not data.servicesNeeded:
|
||
logger.warning("[%s] onboarding rejected: missing services", request_id)
|
||
raise HTTPException(status_code=400, detail="Please choose at least one service.")
|
||
|
||
if not data.councilRegistrationConfirmed:
|
||
raise HTTPException(status_code=400, detail="Please confirm council registration.")
|
||
|
||
if not data.vaccinationsConfirmed:
|
||
raise HTTPException(status_code=400, detail="Please confirm vaccinations are current.")
|
||
|
||
if not data.emergencyVetConsent:
|
||
raise HTTPException(status_code=400, detail="Please confirm emergency veterinary consent.")
|
||
|
||
if not data.termsAccepted:
|
||
raise HTTPException(status_code=400, detail="Please confirm the onboarding declaration.")
|
||
|
||
signature = _trimmed(data.signatureDataUrl)
|
||
if not signature.startswith("data:image/png;base64,") or len(signature) < 128:
|
||
logger.warning("[%s] onboarding rejected: invalid signature payload", request_id)
|
||
raise HTTPException(status_code=400, detail="Please add your signature before sending.")
|
||
|
||
|
||
def _normalize_onboarding_submission(data: OnboardingSubmission) -> None:
|
||
data.fullName = _trimmed(data.fullName)
|
||
data.phone = _trimmed(data.phone)
|
||
data.address = _trimmed(data.address)
|
||
data.dogName = _trimmed(data.dogName)
|
||
data.dogBreed = _trimmed(data.dogBreed)
|
||
data.dogAge = _trimmed(data.dogAge)
|
||
data.temperament = _trimmed(data.temperament)
|
||
data.medicalNotes = _trimmed(data.medicalNotes)
|
||
data.accessInstructions = _trimmed(data.accessInstructions)
|
||
data.vetName = _trimmed(data.vetName)
|
||
data.vetPhone = _trimmed(data.vetPhone)
|
||
data.emergencyContactName = _trimmed(data.emergencyContactName)
|
||
data.emergencyContactPhone = _trimmed(data.emergencyContactPhone)
|
||
data.referrer = _trimmed(data.referrer)
|
||
data.page = _trimmed(data.page)
|
||
data.servicesNeeded = [_trimmed(service) for service in data.servicesNeeded if _trimmed(service)][:8]
|
||
|
||
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
|
||
value = getattr(data, field_name)
|
||
if value is None or value <= 0:
|
||
setattr(data, field_name, None)
|
||
|
||
|
||
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>"""
|
||
|
||
|
||
def _format_duration_ms(duration_ms: int | None) -> str:
|
||
if duration_ms is None or duration_ms < 0:
|
||
return ""
|
||
|
||
total_seconds = int(round(duration_ms / 1000))
|
||
minutes, seconds = divmod(total_seconds, 60)
|
||
hours, minutes = divmod(minutes, 60)
|
||
|
||
if hours > 0:
|
||
return f"{hours}h {minutes}m"
|
||
if minutes > 0:
|
||
return f"{minutes}m {seconds}s"
|
||
return f"{seconds}s"
|
||
|
||
|
||
def _duration_between(start_ms: int | None, end_ms: int | None) -> str:
|
||
if start_ms is None or end_ms is None or end_ms < start_ms:
|
||
return ""
|
||
return _format_duration_ms(end_ms - start_ms)
|
||
|
||
|
||
def _journey_text(journey: list[str]) -> str:
|
||
if not journey:
|
||
return ""
|
||
return " -> ".join(journey)
|
||
|
||
|
||
# ── 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:
|
||
is_general = _is_general_enquiry(data)
|
||
services_text = ", ".join(data.services) if data.services else "Not specified"
|
||
enquiry_summary_rows = [
|
||
_detail_row("Your name", data.fullName),
|
||
_detail_row("Email", str(data.email)),
|
||
_detail_row("Phone", data.phone),
|
||
_detail_row("Type", _enquiry_type_label(data)),
|
||
]
|
||
|
||
if is_general:
|
||
if data.message:
|
||
enquiry_summary_rows.append(_detail_row("Message", data.message))
|
||
intro_html = (
|
||
"We’ve received your message and we will be in touch shortly."
|
||
)
|
||
next_steps_html = (
|
||
"We will review your message and reply within 1 business day."
|
||
)
|
||
logo_subtitle = "General enquiries and dog walking support"
|
||
else:
|
||
enquiry_summary_rows.extend(
|
||
[
|
||
_detail_row("Dog’s name", data.petName),
|
||
_detail_row("Location", data.location),
|
||
_detail_row("Services", services_text),
|
||
]
|
||
)
|
||
if data.message:
|
||
enquiry_summary_rows.append(_detail_row("About the dog", data.message))
|
||
intro_html = (
|
||
"We’ve received your enquiry and we will be in touch shortly to arrange "
|
||
"a <strong style=\"color:#213021;\">Meet & Greet</strong> with you and "
|
||
f"{data.petName}."
|
||
)
|
||
next_steps_html = (
|
||
"We will review your details and reach out within 1 business day "
|
||
"to schedule a free Meet & Greet. No commitment required — just a "
|
||
f"chance for {data.petName} to make a new best friend."
|
||
)
|
||
logo_subtitle = "Professional dog walking services"
|
||
|
||
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);">
|
||
|
||
{_logo_header(subtitle=logo_subtitle)}
|
||
|
||
<!-- 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;">
|
||
{intro_html}
|
||
</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">
|
||
{"".join(enquiry_summary_rows)}
|
||
</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;">
|
||
{next_steps_html}
|
||
</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;">
|
||
Questions? Just reply to this email or reach us at 022 642 1011.
|
||
</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:
|
||
is_general = _is_general_enquiry(data)
|
||
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")
|
||
first_name = data.fullName.split()[0] if data.fullName.strip() else "them"
|
||
email_title = "New GoodWalk Enquiry" if is_general else "New GoodWalk Lead"
|
||
|
||
message_label = "Message" if is_general else "About the dog"
|
||
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;">{message_label}</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 enquiry!
|
||
</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 ""
|
||
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
|
||
page_time_row = _meta_row("Time on page", _duration_between(data.pageEnteredAt, data.sendClickedAt))
|
||
active_time_row = _meta_row("Active form time", _duration_between(data.firstInteractionAt, data.sendClickedAt))
|
||
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
|
||
step_changes_row = _meta_row("Step changes", str(data.stepChanges)) if data.stepChanges else ""
|
||
journey_row = _meta_row("Journey", _journey_text(data.journey))
|
||
detail_heading = "Enquiry details" if is_general else "Dog & services"
|
||
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
|
||
|
||
if is_general:
|
||
if data.petName:
|
||
detail_rows.append(_detail_row("Dog", data.petName))
|
||
if data.location:
|
||
detail_rows.append(_detail_row("Location", data.location))
|
||
else:
|
||
detail_rows.extend(
|
||
[
|
||
_detail_row("Dog", data.petName),
|
||
_detail_row("Location", data.location),
|
||
_detail_row("Services", services_text),
|
||
]
|
||
)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta name="color-scheme" content="light only">
|
||
<meta name="supported-color-schemes" content="light">
|
||
<title>{email_title}</title>
|
||
<style>
|
||
:root {{
|
||
color-scheme: light only;
|
||
supported-color-schemes: light;
|
||
}}
|
||
|
||
body,
|
||
table,
|
||
td,
|
||
div,
|
||
p,
|
||
span,
|
||
a {{
|
||
forced-color-adjust: none !important;
|
||
-webkit-text-size-adjust: 100%;
|
||
}}
|
||
|
||
.gw-owner-body {{
|
||
background: #f2f2f0 !important;
|
||
color: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-shell {{
|
||
background: #ffffff !important;
|
||
}}
|
||
|
||
.gw-owner-dark-panel {{
|
||
background: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-email-chip {{
|
||
display: inline-block;
|
||
background: #ffffff !important;
|
||
color: #213021 !important;
|
||
border-radius: 10px;
|
||
padding: 12px 14px;
|
||
border: 1px solid #d9dfd9;
|
||
text-decoration: none !important;
|
||
}}
|
||
|
||
.gw-owner-email-chip,
|
||
.gw-owner-email-chip a,
|
||
a.gw-owner-email-chip {{
|
||
color: #213021 !important;
|
||
}}
|
||
|
||
@media (prefers-color-scheme: dark) {{
|
||
html,
|
||
body,
|
||
.gw-owner-body {{
|
||
background: #f2f2f0 !important;
|
||
color: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-shell,
|
||
.gw-owner-shell td {{
|
||
background: #ffffff !important;
|
||
color: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-dark-panel,
|
||
.gw-owner-dark-panel td {{
|
||
background: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-email-chip,
|
||
.gw-owner-email-chip a,
|
||
a.gw-owner-email-chip {{
|
||
background: #ffffff !important;
|
||
color: #213021 !important;
|
||
}}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body class="gw-owner-body" style="margin:0;padding:0;background:#f2f2f0;color:#213021;">
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0"
|
||
style="background:#f2f2f0;padding:40px 16px;">
|
||
<tr><td align="center">
|
||
|
||
<table class="gw-owner-shell" width="600" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
|
||
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
||
box-shadow:0 4px 24px rgba(0,0,0,0.08);background:#ffffff;">
|
||
|
||
{_logo_header(badge_html=badge)}
|
||
|
||
<!-- Body -->
|
||
<tr>
|
||
<td bgcolor="#ffffff" style="background:#ffffff;padding:40px 48px 36px;color:#213021;">
|
||
|
||
<!-- Quick contact -->
|
||
<table class="gw-owner-dark-panel" width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#213021"
|
||
style="background:#213021;border-radius:12px;margin-bottom:28px;">
|
||
<tr>
|
||
<td bgcolor="#213021" style="padding:22px 24px;background:#213021;">
|
||
<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="margin-bottom:12px;">
|
||
<a href="mailto:{data.email}" class="gw-owner-email-chip"
|
||
style="display:inline-block;background:#ffffff;color:#213021 !important;
|
||
font-family:Menlo,Consolas,'SFMono-Regular',monospace;
|
||
font-size:20px;font-weight:700;line-height:1.4;word-break:break-all;
|
||
border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;
|
||
text-decoration:none;">
|
||
{data.email}
|
||
</a>
|
||
</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>
|
||
|
||
<!-- 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;">{detail_heading}</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">
|
||
{"".join(detail_rows)}
|
||
{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;">
|
||
Email {first_name}
|
||
</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)}
|
||
{visit_time_row}
|
||
{page_time_row}
|
||
{active_time_row}
|
||
{form_time_row}
|
||
{step_changes_row}
|
||
{referrer_row}
|
||
{page_row}
|
||
{journey_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 enquiry form
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def owner_onboarding_email(data: OnboardingSubmission, ip: str, browser: str) -> str:
|
||
submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0")
|
||
services_text = ", ".join(data.servicesNeeded)
|
||
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
|
||
page_time_row = _meta_row("Time on page", _duration_between(data.pageEnteredAt, data.sendClickedAt))
|
||
active_time_row = _meta_row("Active form time", _duration_between(data.firstInteractionAt, data.sendClickedAt))
|
||
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
|
||
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 ""
|
||
|
||
dog_notes_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;">Temperament and routine</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.temperament}</div>
|
||
</td>
|
||
</tr>""" if data.temperament else ""
|
||
|
||
medical_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;">Medical notes</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.medicalNotes}</div>
|
||
</td>
|
||
</tr>""" if data.medicalNotes else ""
|
||
|
||
access_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;">Home access instructions</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.accessInstructions}</div>
|
||
</td>
|
||
</tr>""" if data.accessInstructions else ""
|
||
|
||
signature_block = f"""
|
||
<div style="margin-top:16px;border-radius:16px;background:#ffffff;border:1px solid #e3e3db;padding:14px 14px 10px;">
|
||
<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:10px;">Captured signature</div>
|
||
<img src="{data.signatureDataUrl}" alt="Client signature" style="display:block;max-width:100%;height:auto;border-radius:10px;background:#fff;">
|
||
</div>"""
|
||
|
||
badge = f"""<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 onboarding form
|
||
</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>"""
|
||
|
||
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 onboarding form</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="680" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="max-width:680px;width:100%;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||
|
||
{_logo_header(badge_html=badge, subtitle="Signed onboarding form")}
|
||
|
||
<tr>
|
||
<td style="background:#ffffff;padding:38px 40px 34px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="background:#213021;border-radius:12px;margin-bottom:26px;">
|
||
<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;">
|
||
Reply directly to the owner or call them back:
|
||
</div>
|
||
<div style="margin-bottom:10px;">
|
||
<a href="mailto:{data.email}" style="display:inline-block;background:#ffffff;color:#213021;text-decoration:none;border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;font-family:Menlo,Consolas,'SFMono-Regular',monospace;font-size:18px;font-weight:700;">{data.email}</a>
|
||
</div>
|
||
<a href="tel:{data.phone}" style="display:inline-block;background:#ffd100;color:#213021;text-decoration:none;border-radius:999px;padding:10px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;font-weight:700;">Call {data.phone}</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<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:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Name", data.fullName)}
|
||
{_detail_row("Email", str(data.email))}
|
||
{_detail_row("Phone", data.phone)}
|
||
{_detail_row("Address", data.address)}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<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 and service details</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Dog", data.dogName)}
|
||
{_detail_row("Breed", data.dogBreed)}
|
||
{_detail_row("Age", data.dogAge or "—")}
|
||
{_detail_row("Service", services_text)}
|
||
{dog_notes_block}
|
||
{medical_block}
|
||
{access_block}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<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;">Safety details</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Vet clinic", data.vetName)}
|
||
{_detail_row("Vet phone", data.vetPhone)}
|
||
{_detail_row("Emergency contact", data.emergencyContactName)}
|
||
{_detail_row("Emergency phone", data.emergencyContactPhone)}
|
||
{_detail_row("Council registration", "Confirmed")}
|
||
{_detail_row("Vaccinations", "Confirmed")}
|
||
{_detail_row("Emergency consent", "Confirmed")}
|
||
{_detail_row("Declaration", "Signed")}
|
||
</table>
|
||
{signature_block}
|
||
</td></tr>
|
||
</table>
|
||
|
||
<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)}
|
||
{visit_time_row}
|
||
{page_time_row}
|
||
{active_time_row}
|
||
{form_time_row}
|
||
{referrer_row}
|
||
{page_row}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
# ── Sending with retries ─────────────────────────────────────────────────────
|
||
|
||
async def _send_email(payload: dict, label: str, request_id: str) -> dict:
|
||
if DEV_MODE:
|
||
to = payload.get("to", [])
|
||
subject = payload.get("subject", "(no subject)")
|
||
logger.warning("[DEV] skipping email send — label=%s to=%s subject=%r", label, to, subject)
|
||
return {"id": "dev-mode"}
|
||
|
||
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
|
||
|
||
|
||
def _build_startup_test_submission() -> BookingSubmission:
|
||
now_ms = int(time.time() * 1000)
|
||
|
||
sample = BookingSubmission(
|
||
enquiryType="booking",
|
||
fullName="Sarah Thompson",
|
||
email="sarah.thompson@example.com",
|
||
phone="021 555 0142",
|
||
petName="Milo",
|
||
location="Grey Lynn",
|
||
message=(
|
||
"Milo is a 2-year-old cavoodle with good recall and a friendly nature. "
|
||
"He loves other dogs, is comfortable off lead in safe areas, and we are "
|
||
"looking for regular weekday pack walks while we are at work."
|
||
),
|
||
services=["Pack Walks", "Puppy Visits"],
|
||
formStartedAt=now_ms - (6 * 60 * 1000 + 35 * 1000),
|
||
visitStartedAt=now_ms - (14 * 60 * 1000 + 10 * 1000),
|
||
pageEnteredAt=now_ms - (7 * 60 * 1000 + 5 * 1000),
|
||
firstInteractionAt=now_ms - (5 * 60 * 1000 + 20 * 1000),
|
||
sendClickedAt=now_ms,
|
||
stepChanges=3,
|
||
journey=["/", "/pack-walks", "/our-pricing", "/book"],
|
||
referrer="https://www.google.com/search?q=goodwalk+auckland+dog+walking",
|
||
page="https://www.goodwalk.co.nz/book?service=pack-walks",
|
||
)
|
||
_normalize_submission(sample)
|
||
return sample
|
||
|
||
|
||
async def _send_startup_test_email() -> None:
|
||
if not STARTUP_TEST_RECIPIENT:
|
||
logger.info("Startup test email skipped: OWNER_BCC is not set to a real address")
|
||
return
|
||
|
||
request_id = "startup-test"
|
||
sample = _build_startup_test_submission()
|
||
payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [STARTUP_TEST_RECIPIENT],
|
||
"reply_to": str(sample.email),
|
||
"subject": f"Startup preview — New GoodWalk lead — {sample.fullName} ({sample.petName})",
|
||
"html": owner_email(sample, "127.0.0.1", f"Startup Preview ({APP_VERSION})"),
|
||
}
|
||
|
||
await _send_email(payload, label="startup_test_email", request_id=request_id)
|
||
|
||
|
||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||
|
||
@app.on_event("startup")
|
||
async def _startup_mail_check() -> None:
|
||
try:
|
||
await _send_startup_test_email()
|
||
except Exception:
|
||
logger.exception("Startup test email failed")
|
||
|
||
@app.get("/health")
|
||
async def health() -> dict:
|
||
return {"status": "ok"}
|
||
|
||
|
||
def _auth_code_email(email: str, code: str) -> str:
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Your Goodwalk login code</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="480" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="max-width:480px;width:100%;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||
<tr>
|
||
<td style="background:#213021;padding:32px 40px;text-align:center;">
|
||
<img src="{LOGO_URL}" width="161" height="32" alt="Goodwalk" style="display:inline-block;max-width:161px;height:auto;border:0;">
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#ffffff;padding:40px 40px 36px;text-align:center;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#888;margin-bottom:16px;">Your login code</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:52px;font-weight:800;letter-spacing:0.18em;color:#213021;background:#f8f7f4;border-radius:14px;padding:20px 28px;display:inline-block;margin-bottom:24px;">{code}</div>
|
||
<p style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;color:#666;line-height:1.6;margin:0 0 8px;">
|
||
Enter this code on the Goodwalk onboarding page.
|
||
</p>
|
||
<p style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;color:#aaa;margin:0;">
|
||
This code expires in {AUTH_CODE_TTL_SECONDS // 60} minutes. If you didn’t request this, you can safely ignore it.
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#213021;padding:20px 40px;text-align:center;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;color:#5a8a5a;">
|
||
Goodwalk · Auckland, New Zealand
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
_EMAIL_RE = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$')
|
||
|
||
|
||
@app.post("/auth/request-code")
|
||
async def auth_request_code(request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
ip = _get_ip(request)
|
||
body = await request.json()
|
||
email = str(body.get("email", "")).strip().lower()
|
||
|
||
async with _auth_lock:
|
||
_check_ip_blocked(ip, request_id)
|
||
|
||
if not email or not _EMAIL_RE.match(email):
|
||
raise HTTPException(status_code=400, detail="Please enter a valid email address.")
|
||
|
||
if email not in _allowed_emails:
|
||
logger.info("[%s] auth: unknown email=%s ip=%s", request_id, email, ip)
|
||
async with _auth_lock:
|
||
_record_auth_failure(ip, request_id, "unknown_email")
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="We don’t have your email on file. Please use the address you used when enquiring with Goodwalk, or contact us at info@goodwalk.co.nz.",
|
||
)
|
||
|
||
now = time.monotonic()
|
||
async with _auth_lock:
|
||
requests = _code_requests.setdefault(email, deque())
|
||
while requests and now - requests[0] > 3600:
|
||
requests.popleft()
|
||
if len(requests) >= AUTH_CODE_REQUESTS_PER_HOUR:
|
||
raise HTTPException(status_code=429, detail="Too many code requests. Please wait before trying again.")
|
||
requests.append(now)
|
||
|
||
code = str(secrets.randbelow(900000) + 100000)
|
||
_pending_codes[email] = {"code": code, "expires_at": time.time() + AUTH_CODE_TTL_SECONDS, "attempts": 0}
|
||
|
||
logger.info("[%s] auth: code issued for email=%s", request_id, email)
|
||
|
||
if DEV_MODE:
|
||
logger.warning("[DEV] auth code for %s: %s", email, code)
|
||
else:
|
||
await _send_email(
|
||
{"from": FROM_EMAIL, "to": [email], "subject": "Your Goodwalk login code", "html": _auth_code_email(email, code)},
|
||
label="auth_code_email",
|
||
request_id=request_id,
|
||
)
|
||
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/auth/verify-code")
|
||
async def auth_verify_code(request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
ip = _get_ip(request)
|
||
body = await request.json()
|
||
email = str(body.get("email", "")).strip().lower()
|
||
code = str(body.get("code", "")).strip()
|
||
|
||
async with _auth_lock:
|
||
_check_ip_blocked(ip, request_id)
|
||
|
||
pending = _pending_codes.get(email)
|
||
|
||
if not pending:
|
||
_record_auth_failure(ip, request_id, "no_pending_code")
|
||
raise HTTPException(status_code=400, detail="No code found for this email. Please request a new one.")
|
||
|
||
if time.time() > pending["expires_at"]:
|
||
_pending_codes.pop(email, None)
|
||
_record_auth_failure(ip, request_id, "expired_code")
|
||
raise HTTPException(status_code=400, detail="Your code has expired. Please request a new one.")
|
||
|
||
pending["attempts"] += 1
|
||
if pending["attempts"] > AUTH_CODE_MAX_ATTEMPTS:
|
||
_pending_codes.pop(email, None)
|
||
_record_auth_failure(ip, request_id, "max_attempts_exceeded")
|
||
raise HTTPException(status_code=400, detail="Too many incorrect attempts. Please request a new code.")
|
||
|
||
if pending["code"] != code:
|
||
remaining = max(0, AUTH_CODE_MAX_ATTEMPTS - pending["attempts"])
|
||
_record_auth_failure(ip, request_id, "wrong_code")
|
||
raise HTTPException(status_code=400, detail=f"Incorrect code. {remaining} attempt{'s' if remaining != 1 else ''} remaining.")
|
||
|
||
_pending_codes.pop(email, None)
|
||
|
||
token = secrets.token_urlsafe(32)
|
||
_active_sessions[token] = {"email": email, "expires_at": time.time() + AUTH_SESSION_TTL_SECONDS}
|
||
|
||
logger.info("[%s] auth: session created for email=%s", request_id, email)
|
||
return {"ok": True, "token": token, "email": email}
|
||
|
||
|
||
@app.get("/auth/verify")
|
||
async def auth_verify(request: Request):
|
||
auth_header = request.headers.get("Authorization", "")
|
||
token = auth_header.removeprefix("Bearer ").strip()
|
||
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="No token provided.")
|
||
|
||
async with _auth_lock:
|
||
session = _active_sessions.get(token)
|
||
if not session:
|
||
raise HTTPException(status_code=401, detail="Invalid session.")
|
||
if time.time() > session["expires_at"]:
|
||
_active_sessions.pop(token, None)
|
||
raise HTTPException(status_code=401, detail="Session expired. Please sign in again.")
|
||
|
||
email = session["email"]
|
||
profile = _client_profiles.get(email, {})
|
||
draft = _drafts.get(email, {})
|
||
return {"ok": True, "email": email, "profile": profile, "draft": draft}
|
||
|
||
|
||
@app.post("/auth/logout")
|
||
async def auth_logout(request: Request):
|
||
auth_header = request.headers.get("Authorization", "")
|
||
token = auth_header.removeprefix("Bearer ").strip()
|
||
if token:
|
||
async with _auth_lock:
|
||
_active_sessions.pop(token, None)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/auth/save-draft")
|
||
async def auth_save_draft(request: Request):
|
||
auth_header = request.headers.get("Authorization", "")
|
||
token = auth_header.removeprefix("Bearer ").strip()
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="No token provided.")
|
||
|
||
async with _auth_lock:
|
||
session = _active_sessions.get(token)
|
||
if not session or time.time() > session["expires_at"]:
|
||
raise HTTPException(status_code=401, detail="Invalid or expired session.")
|
||
email = session["email"]
|
||
|
||
body = await request.json()
|
||
form = str(body.get("form", "")).strip()
|
||
data = body.get("data", {})
|
||
|
||
if form not in ("onboarding", "contract"):
|
||
raise HTTPException(status_code=400, detail="form must be 'onboarding' or 'contract'.")
|
||
if not isinstance(data, dict):
|
||
raise HTTPException(status_code=400, detail="data must be an object.")
|
||
|
||
async with _auth_lock:
|
||
user_drafts = _drafts.setdefault(email, {})
|
||
user_drafts[form] = data
|
||
snapshot = dict(_drafts)
|
||
|
||
await asyncio.to_thread(_save_drafts_sync, snapshot)
|
||
logger.info("Draft saved: email=%s form=%s", email, form)
|
||
return {"ok": True}
|
||
|
||
|
||
@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", ""))
|
||
|
||
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,
|
||
}
|
||
|
||
_validate_submission(request_id, data)
|
||
_normalize_submission(data)
|
||
|
||
name_parts = data.fullName.split()
|
||
first_name = name_parts[0] if name_parts else "there"
|
||
|
||
logger.info(
|
||
"[%s] /submit: type=%s email=%s ip=%s browser=%r dog=%s services=%s page=%r",
|
||
request_id, data.enquiryType, data.email, ip, browser, data.petName, data.services, data.page,
|
||
)
|
||
logger.debug("[%s] full payload: %s", request_id, data.model_dump())
|
||
|
||
failures: list[dict] = []
|
||
|
||
client_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [data.email],
|
||
"reply_to": REPLY_TO,
|
||
"subject": f"We received your {'general enquiry' if _is_general_enquiry(data) else 'enquiry'}, {first_name}! 🐾",
|
||
"html": client_email(data),
|
||
}
|
||
if CLIENT_BCC:
|
||
client_payload["bcc"] = [CLIENT_BCC]
|
||
|
||
try:
|
||
await _send_email(
|
||
client_payload,
|
||
label="client_email",
|
||
request_id=request_id,
|
||
)
|
||
except Exception as exc:
|
||
failures.append({
|
||
"label": "client_email",
|
||
"error_type": type(exc).__name__,
|
||
"error": str(exc),
|
||
"status": getattr(exc, "status_code", None) or getattr(exc, "code", None),
|
||
})
|
||
|
||
owner_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [OWNER_EMAIL],
|
||
"reply_to": data.email,
|
||
"subject": (
|
||
f"New GoodWalk general enquiry — {data.fullName}"
|
||
if _is_general_enquiry(data)
|
||
else f"New GoodWalk lead — {data.fullName} ({data.petName})"
|
||
),
|
||
"html": owner_email(data, ip, browser),
|
||
}
|
||
if OWNER_BCC:
|
||
owner_payload["bcc"] = [OWNER_BCC]
|
||
|
||
try:
|
||
await _send_email(
|
||
owner_payload,
|
||
label="owner_email",
|
||
request_id=request_id,
|
||
)
|
||
except Exception as exc:
|
||
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 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)
|
||
|
||
await _register_email(str(data.email))
|
||
await _store_client_profile(str(data.email), {
|
||
"fullName": data.fullName,
|
||
"phone": data.phone,
|
||
"dogName": data.petName,
|
||
})
|
||
|
||
return {
|
||
"ok": True,
|
||
"request_id": request_id,
|
||
"partial_failures": [f["label"] for f in failures],
|
||
}
|
||
|
||
|
||
def _validate_contract_submission(request_id: str, data: ContractSubmission) -> None:
|
||
if not _trimmed(data.fullName):
|
||
raise HTTPException(status_code=400, detail="Please enter your full name.")
|
||
if not _trimmed(data.phone):
|
||
raise HTTPException(status_code=400, detail="Please enter your phone number.")
|
||
for field_name, message in {
|
||
"address": "Please enter your address.",
|
||
"dogName": "Please enter your dog's name.",
|
||
"dogBreed": "Please enter your dog's breed.",
|
||
"serviceType": "Please select a service type.",
|
||
"startDate": "Please enter a start date.",
|
||
}.items():
|
||
if not _trimmed(getattr(data, field_name)):
|
||
logger.warning("[%s] contract rejected: missing %s", request_id, field_name)
|
||
raise HTTPException(status_code=400, detail=message)
|
||
|
||
if not all([data.agreeServiceTerms, data.agreeCancellation, data.agreePayment,
|
||
data.agreeEmergency, data.agreeLiability, data.agreeAccuracy]):
|
||
logger.warning("[%s] contract rejected: incomplete declarations", request_id)
|
||
raise HTTPException(status_code=400, detail="Please confirm all declarations before signing.")
|
||
|
||
signature = _trimmed(data.signatureDataUrl)
|
||
if not signature.startswith("data:image/png;base64,") or len(signature) < 128:
|
||
logger.warning("[%s] contract rejected: invalid signature payload", request_id)
|
||
raise HTTPException(status_code=400, detail="Please add your signature before sending.")
|
||
|
||
|
||
def _normalize_contract_submission(data: ContractSubmission) -> None:
|
||
data.fullName = _trimmed(data.fullName)
|
||
data.phone = _trimmed(data.phone)
|
||
data.address = _trimmed(data.address)
|
||
data.dogName = _trimmed(data.dogName)
|
||
data.dogBreed = _trimmed(data.dogBreed)
|
||
data.dogAge = _trimmed(data.dogAge)
|
||
data.serviceType = _trimmed(data.serviceType)
|
||
data.startDate = _trimmed(data.startDate)
|
||
data.walkFrequency = _trimmed(data.walkFrequency)
|
||
data.additionalNotes = _trimmed(data.additionalNotes)
|
||
data.referrer = _trimmed(data.referrer)
|
||
data.page = _trimmed(data.page)
|
||
|
||
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
|
||
value = getattr(data, field_name)
|
||
if value is None or value <= 0:
|
||
setattr(data, field_name, None)
|
||
|
||
|
||
def owner_contract_email(data: ContractSubmission, ip: str, browser: str) -> str:
|
||
submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0")
|
||
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
|
||
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
|
||
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 ""
|
||
|
||
notes_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;">Additional notes</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.additionalNotes}</div>
|
||
</td>
|
||
</tr>""" if data.additionalNotes else ""
|
||
|
||
signature_block = f"""
|
||
<div style="margin-top:16px;border-radius:16px;background:#ffffff;border:1px solid #e3e3db;padding:14px 14px 10px;">
|
||
<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:10px;">Captured signature</div>
|
||
<img src="{data.signatureDataUrl}" alt="Client signature" style="display:block;max-width:100%;height:auto;border-radius:10px;background:#fff;">
|
||
</div>"""
|
||
|
||
badge = f"""<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 signed contract
|
||
</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>"""
|
||
|
||
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 service contract</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="680" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="max-width:680px;width:100%;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||
|
||
{_logo_header(badge_html=badge, subtitle="Signed service agreement")}
|
||
|
||
<tr>
|
||
<td style="background:#ffffff;padding:38px 40px 34px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="background:#213021;border-radius:12px;margin-bottom:26px;">
|
||
<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="margin-bottom:10px;">
|
||
<a href="mailto:{data.email}" style="display:inline-block;background:#ffffff;color:#213021;text-decoration:none;border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;font-family:Menlo,Consolas,'SFMono-Regular',monospace;font-size:18px;font-weight:700;">{data.email}</a>
|
||
</div>
|
||
<a href="tel:{data.phone}" style="display:inline-block;background:#ffd100;color:#213021;text-decoration:none;border-radius:999px;padding:10px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;font-weight:700;">Call {data.phone}</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<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;">Client details</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Name", data.fullName)}
|
||
{_detail_row("Email", str(data.email))}
|
||
{_detail_row("Phone", data.phone)}
|
||
{_detail_row("Address", data.address)}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<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;">Service agreement</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Dog", data.dogName)}
|
||
{_detail_row("Breed", data.dogBreed)}
|
||
{_detail_row("Age", data.dogAge or "—")}
|
||
{_detail_row("Service", data.serviceType)}
|
||
{_detail_row("Start date", data.startDate)}
|
||
{_detail_row("Frequency", data.walkFrequency or "—")}
|
||
{notes_block}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<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;">Declarations confirmed</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Service terms", "Confirmed")}
|
||
{_detail_row("Cancellation policy", "Confirmed")}
|
||
{_detail_row("Payment terms", "Confirmed")}
|
||
{_detail_row("Emergency consent", "Confirmed")}
|
||
{_detail_row("Liability terms", "Confirmed")}
|
||
{_detail_row("Accuracy declaration", "Confirmed")}
|
||
</table>
|
||
{signature_block}
|
||
</td></tr>
|
||
</table>
|
||
|
||
<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)}
|
||
{visit_time_row}
|
||
{form_time_row}
|
||
{referrer_row}
|
||
{page_row}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
@app.post("/onboarding-submit")
|
||
async def submit_onboarding(data: OnboardingSubmission, 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", ""))
|
||
|
||
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] onboarding 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,
|
||
}
|
||
|
||
_validate_onboarding_submission(request_id, data)
|
||
_normalize_onboarding_submission(data)
|
||
|
||
logger.info(
|
||
"[%s] /onboarding-submit: email=%s ip=%s browser=%r dog=%s services=%s page=%r",
|
||
request_id, data.email, ip, browser, data.dogName, data.servicesNeeded, data.page,
|
||
)
|
||
logger.debug("[%s] onboarding payload: %s", request_id, data.model_dump())
|
||
|
||
owner_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [OWNER_EMAIL],
|
||
"reply_to": data.email,
|
||
"subject": f"New GoodWalk onboarding — {data.fullName} ({data.dogName})",
|
||
"html": owner_onboarding_email(data, ip, browser),
|
||
}
|
||
if OWNER_BCC:
|
||
owner_payload["bcc"] = [OWNER_BCC]
|
||
|
||
try:
|
||
await _send_email(
|
||
owner_payload,
|
||
label="owner_onboarding_email",
|
||
request_id=request_id,
|
||
)
|
||
except Exception as exc:
|
||
logger.error("[%s] onboarding email failed after retries: %s", request_id, exc, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={
|
||
"request_id": request_id,
|
||
"message": "The onboarding form could not be delivered. Please try again shortly.",
|
||
"error_type": type(exc).__name__,
|
||
},
|
||
)
|
||
|
||
await _register_email(str(data.email))
|
||
await _store_client_profile(str(data.email), {
|
||
"fullName": data.fullName,
|
||
"phone": data.phone,
|
||
"address": data.address,
|
||
"dogName": data.dogName,
|
||
"dogBreed": data.dogBreed,
|
||
"dogAge": data.dogAge,
|
||
"onboardingCompleted": True,
|
||
})
|
||
|
||
return {
|
||
"ok": True,
|
||
"request_id": request_id,
|
||
}
|
||
|
||
|
||
@app.post("/contract-submit")
|
||
async def submit_contract(data: ContractSubmission, 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", ""))
|
||
|
||
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] contract 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}
|
||
|
||
_validate_contract_submission(request_id, data)
|
||
_normalize_contract_submission(data)
|
||
|
||
logger.info(
|
||
"[%s] /contract-submit: email=%s ip=%s browser=%r dog=%s service=%s page=%r",
|
||
request_id, data.email, ip, browser, data.dogName, data.serviceType, data.page,
|
||
)
|
||
|
||
owner_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [OWNER_EMAIL],
|
||
"reply_to": data.email,
|
||
"subject": f"New GoodWalk contract — {data.fullName} ({data.dogName}, {data.serviceType})",
|
||
"html": owner_contract_email(data, ip, browser),
|
||
}
|
||
if OWNER_BCC:
|
||
owner_payload["bcc"] = [OWNER_BCC]
|
||
|
||
try:
|
||
await _send_email(owner_payload, label="owner_contract_email", request_id=request_id)
|
||
except Exception as exc:
|
||
logger.error("[%s] contract email failed after retries: %s", request_id, exc, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={
|
||
"request_id": request_id,
|
||
"message": "The contract could not be delivered. Please try again shortly.",
|
||
"error_type": type(exc).__name__,
|
||
},
|
||
)
|
||
|
||
await _register_email(str(data.email))
|
||
await _store_client_profile(str(data.email), {
|
||
"fullName": data.fullName,
|
||
"phone": data.phone,
|
||
"address": data.address,
|
||
"dogName": data.dogName,
|
||
"dogBreed": data.dogBreed,
|
||
"dogAge": data.dogAge,
|
||
"contractCompleted": True,
|
||
})
|
||
|
||
return {"ok": True, "request_id": request_id}
|