Files
gw-svelte/mail-api/main.py
T
2026-05-26 23:30:22 +12:00

4246 lines
173 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import base64
from collections import deque
from contextlib import asynccontextmanager
import json
import os
import random
import re
import secrets
import time
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import resend
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send
import db as admin_db
from mail_api.config import (
ALLOWED_EMAILS_FILE as _ALLOWED_EMAILS_FILE,
APP_VERSION,
AUTH_CODE_MAX_ATTEMPTS,
AUTH_CODE_REQUESTS_PER_HOUR,
AUTH_CODE_TTL_SECONDS,
AUTH_IP_BLOCK_DURATION,
AUTH_IP_FAILURE_WINDOW,
AUTH_IP_MAX_FAILURES,
AUTH_SESSION_TTL_SECONDS,
BIRTHDAY_CHECK_INTERVAL_SECONDS,
CLIENT_BCC,
CLIENT_PROFILES_FILE as _CLIENT_PROFILES_FILE,
CORS_ALLOWED_ORIGINS,
CP_ADMIN_EMAILS,
DEPLOY_SMOKE_SECRET,
DEV_MODE,
DRAFTS_FILE as _DRAFTS_FILE,
EMAIL_SEND_TIMEOUT_SECONDS,
ENABLE_GENERAL_ENQUIRIES,
FORM_MAX_SECONDS,
FORM_MIN_SECONDS,
FROM_EMAIL,
LEGACY_SEED_FILE as _LEGACY_SEED_FILE,
LOGO_URL,
MAX_REQUEST_BODY_BYTES,
MAX_SEND_ATTEMPTS,
OWNER_BCC,
OWNER_EMAIL,
RATE_LIMIT_MAX_PER_EMAIL,
RATE_LIMIT_MAX_PER_IP,
RATE_LIMIT_MIN_INTERVAL_SECONDS,
RATE_LIMIT_WINDOW_SECONDS,
REPLY_TO,
STARTUP_TEST_RECIPIENT,
TRUSTED_HOSTS,
logger,
)
from mail_api.models import (
BaseSubmission,
BirthdayAutoSendRequest,
BirthdayEmailRequest,
BookingSubmission,
ClientStatusUpdate,
ContractSubmission,
OnboardingSubmission,
RenderMessageRequest,
SendMessageRequest,
WelcomePackEmailRequest,
)
@asynccontextmanager
async def _lifespan(app: FastAPI):
await _startup_mail_check()
try:
yield
finally:
await _shutdown_background_tasks()
app = FastAPI(title="GoodWalk Mail API", lifespan=_lifespan)
# ── Auth state ───────────────────────────────────────────────────────────────
def _write_pii_json(path: Path, payload: object) -> None:
"""Atomically write a JSON file and chmod it owner-only (0600).
The chmod is best-effort: it is a no-op on Windows, but on the Linux
Docker host it ensures the file with PII is unreadable by other users.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
def _load_allowed_emails_from_file() -> 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_file(emails: set[str]) -> None:
try:
_write_pii_json(_ALLOWED_EMAILS_FILE, {"emails": sorted(emails)})
except Exception as exc:
logger.warning("Could not save allowed_emails file: %s", exc)
def _load_client_profiles_from_file() -> 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_file(profiles: dict) -> None:
try:
_write_pii_json(_CLIENT_PROFILES_FILE, profiles)
except Exception as exc:
logger.warning("Could not save client_profiles file: %s", exc)
def _load_drafts_from_file() -> 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_file(drafts: dict) -> None:
try:
_write_pii_json(_DRAFTS_FILE, drafts)
except Exception as exc:
logger.warning("Could not save drafts file: %s", exc)
async def _save_active_sessions_async() -> None:
"""Persist live sessions to admin_kv so they survive container restarts.
Snapshot filters out expired entries before writing. Best-effort —
failure is logged but does not block the auth flow (memory remains
authoritative for the current process).
"""
now = time.time()
snapshot = {tok: s for tok, s in _active_sessions.items() if s.get("expires_at", 0) > now}
try:
await admin_db.set_kv("active_sessions", snapshot)
except Exception as exc:
logger.warning("Could not persist active_sessions: %s", exc)
async def _load_active_sessions_async() -> dict[str, dict]:
if not admin_db.is_enabled():
return {}
try:
data = await admin_db.get_kv("active_sessions")
if not isinstance(data, dict):
return {}
now = time.time()
return {
tok: s
for tok, s in data.items()
if isinstance(s, dict) and isinstance(s.get("expires_at"), (int, float)) and s["expires_at"] > now
}
except Exception as exc:
logger.warning("Could not load active_sessions from admin_kv: %s", exc)
return {}
async def _persist_admin_state(key: str, value: Any) -> None:
"""Write a single admin_kv blob to postgres when the database is available."""
try:
await admin_db.set_kv(key, value)
except Exception as exc:
logger.warning("Postgres persist (%s) failed; JSON copy is still authoritative: %s", key, exc)
async def _seed_admin_state_from_json_if_needed() -> None:
"""Seed admin_kv from the JSON files on disk.
Controlled by ADMIN_DATA_SEED_FROM_JSON:
- "never": do nothing
- "auto": seed only when admin_kv has no rows yet (default, safe on every boot)
- "force": overwrite postgres with whatever the JSON files currently hold
The deployer exposes -SeedAdminData which sets this to "force" for one boot.
"""
mode = (os.environ.get("ADMIN_DATA_SEED_FROM_JSON", "auto") or "auto").strip().lower()
if mode == "never":
return
if not admin_db.is_enabled():
return
try:
if mode == "auto" and await admin_db.has_any_value():
return
seed_clients = _load_client_profiles_from_file()
seed_emails = sorted(_load_allowed_emails_from_file())
seed_drafts = _load_drafts_from_file()
if not seed_clients and not seed_emails and not seed_drafts:
return
if seed_clients:
await admin_db.set_kv("client_profiles", seed_clients)
if seed_emails:
await admin_db.set_kv("allowed_emails", {"emails": seed_emails})
if seed_drafts:
await admin_db.set_kv("drafts", seed_drafts)
logger.info(
"Seeded admin_kv from JSON (mode=%s): clients=%d emails=%d drafts=%d",
mode, len(seed_clients), len(seed_emails), len(seed_drafts),
)
except Exception as exc:
logger.warning("Admin seed from JSON failed: %s", exc)
async def _merge_legacy_seed_if_present() -> None:
"""Merge the shipped legacy-clients-seed.json into _client_profiles.
Add-only: never overwrites an email that already exists in the live data.
Idempotent: re-running on every boot is a no-op once the entries are in.
Writes the updated profiles back to the JSON file + admin_kv so the merged
state survives container restarts.
"""
global _client_profiles
seed_path = _LEGACY_SEED_FILE
if not seed_path.exists():
return
try:
seed = json.loads(seed_path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("Legacy seed file unreadable (%s): %s", seed_path, exc)
return
if not isinstance(seed, dict) or not seed:
return
added: list[str] = []
skipped_existing = 0
for raw_email, profile in seed.items():
if not isinstance(raw_email, str) or not isinstance(profile, dict):
continue
email = raw_email.strip().lower()
if not email:
continue
if email == OWNER_EMAIL.strip().lower():
continue
if email in _client_profiles:
skipped_existing += 1
continue
_client_profiles[email] = profile
added.append(email)
if not added:
logger.info(
"Legacy seed already merged (existing=%d, candidates=%d).",
skipped_existing, len(seed),
)
return
snapshot = dict(_client_profiles)
try:
await asyncio.to_thread(_save_client_profiles_file, snapshot)
except Exception as exc:
logger.warning("Could not save client_profiles after legacy merge: %s", exc)
try:
await _persist_admin_state("client_profiles", snapshot)
except Exception as exc:
logger.warning("Could not persist client_profiles to postgres after legacy merge: %s", exc)
logger.info(
"Legacy seed merged: added=%d skipped_existing=%d total_after=%d",
len(added), skipped_existing, len(_client_profiles),
)
async def _load_allowed_emails_async() -> set[str]:
if admin_db.is_enabled():
data = await admin_db.get_kv("allowed_emails")
if isinstance(data, dict):
emails = data.get("emails", [])
if isinstance(emails, list):
seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()}
seed.update(e.lower() for e in emails if isinstance(e, str))
return seed
return _load_allowed_emails_from_file()
async def _load_client_profiles_async() -> dict[str, dict]:
if admin_db.is_enabled():
data = await admin_db.get_kv("client_profiles")
if isinstance(data, dict):
return data
return _load_client_profiles_from_file()
async def _load_drafts_async() -> dict:
if admin_db.is_enabled():
data = await admin_db.get_kv("drafts")
if isinstance(data, dict):
return data
return _load_drafts_from_file()
_allowed_emails: set[str] = _load_allowed_emails_from_file()
if OWNER_EMAIL:
_allowed_emails.add(OWNER_EMAIL.strip().lower())
_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_from_file()
_drafts: dict[str, dict] = _load_drafts_from_file() # 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()
_birthday_auto_task: asyncio.Task | None = None
logger.info("Auth: loaded %d allowed email(s)", len(_allowed_emails))
async def _require_session_email(request: Request) -> str:
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.")
return session["email"]
async def _require_owner_email(request: Request) -> str:
email = await _require_session_email(request)
if email not in CP_ADMIN_EMAILS:
raise HTTPException(status_code=403, detail="Owner access required.")
return email
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)
snapshot = sorted(_allowed_emails)
await asyncio.to_thread(_save_allowed_emails_file, set(_allowed_emails))
await _persist_admin_state("allowed_emails", {"emails": snapshot})
logger.info("Auth: registered new allowed email: %s", normalized)
def _client_is_reachable(profile: dict) -> bool:
"""True if outreach (welcome pack, birthday email, etc.) should still target
this client. Excludes lifecycle states that mean the relationship has ended.
"""
lifecycle = profile.get("lifecycle")
if not isinstance(lifecycle, dict):
return True
return lifecycle.get("status") not in {"cancelled", "archived"}
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 is not None and not (isinstance(v, str) and v == "")
}
if merged != existing:
_client_profiles[normalized] = merged
snapshot = dict(_client_profiles)
await asyncio.to_thread(_save_client_profiles_file, snapshot)
await _persist_admin_state("client_profiles", snapshot)
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),
)
class _BodySizeLimitMiddleware:
"""Reject requests whose Content-Length exceeds MAX_REQUEST_BODY_BYTES.
Defence-in-depth alongside nginx ``client_max_body_size``. Streaming
requests without a Content-Length header are tracked byte-by-byte and
short-circuited if they overflow the cap.
"""
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
declared = headers.get("content-length")
if declared is not None:
try:
if int(declared) > self.max_bytes:
await _send_413(send)
return
except ValueError:
pass
received = 0
overflowed = False
async def _wrapped_receive():
nonlocal received, overflowed
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > self.max_bytes:
overflowed = True
return {"type": "http.disconnect"}
return message
if overflowed:
await _send_413(send)
return
await self.app(scope, _wrapped_receive, send)
async def _send_413(send: Send) -> None:
await send({
"type": "http.response.start",
"status": 413,
"headers": [(b"content-type", b"application/json")],
})
await send({
"type": "http.response.body",
"body": b'{"detail":"Request body too large."}',
})
app.add_middleware(_BodySizeLimitMiddleware, max_bytes=MAX_REQUEST_BODY_BYTES)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(TRUSTED_HOSTS))
app.add_middleware(
CORSMiddleware,
allow_origins=list(CORS_ALLOWED_ORIGINS),
allow_methods=["POST", "GET"],
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
allow_credentials=False,
max_age=600,
)
@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
# ── 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"
def _is_deploy_smoke(request: Request) -> bool:
"""True when the request carries a matching X-Deploy-Smoke header.
Used by the deploy script to verify the form endpoints are reachable and
parse a valid payload, without producing a real submission. Disabled
entirely when DEPLOY_SMOKE_SECRET is unset.
"""
if not DEPLOY_SMOKE_SECRET:
return False
presented = request.headers.get("x-deploy-smoke") or ""
if not presented:
return False
return secrets.compare_digest(presented, DEPLOY_SMOKE_SECRET)
_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&rsquo;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&rsquo;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&rsquo;ve received your enquiry and we will be in touch shortly to arrange "
"a <strong style=\"color:#213021;\">Meet &amp; 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 &amp; Greet. No commitment required &mdash; 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]}! &#x1F43E;
</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 &middot; 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;">
&#x1F4E9;&nbsp; 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 &amp; 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;">
&#x270D;&nbsp; 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>"""
def _birthday_ics_attachment(dog_name: str, dog_birth_date: str, owner_name: str, request_id: str) -> dict | None:
dog_name_clean = _trimmed(dog_name)
birth_date_clean = _trimmed(dog_birth_date)
owner_name_clean = _trimmed(owner_name)
if not dog_name_clean or not birth_date_clean:
return None
try:
starts_on = datetime.strptime(birth_date_clean, "%Y-%m-%d").date()
except ValueError:
logger.warning("[%s] onboarding birthday calendar skipped: invalid dogAge=%r", request_id, dog_birth_date)
return None
ends_on = starts_on + timedelta(days=1)
safe_name = re.sub(r"[^a-z0-9]+", "-", dog_name_clean.lower()).strip("-") or "dog"
summary = f"{dog_name_clean}'s Birthday"
description = f"GoodWalk reminder: {dog_name_clean}'s birthday."
calendar_name = summary if not owner_name_clean else f"{summary} for {owner_name_clean}"
ics_body = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//GoodWalk//Dog Birthday Reminder//EN\r\n"
"CALSCALE:GREGORIAN\r\n"
"METHOD:PUBLISH\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{uuid.uuid4()}@goodwalk.co.nz\r\n"
f"DTSTAMP:{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTSTART;VALUE=DATE:{starts_on.strftime('%Y%m%d')}\r\n"
f"DTEND;VALUE=DATE:{ends_on.strftime('%Y%m%d')}\r\n"
"RRULE:FREQ=YEARLY\r\n"
f"SUMMARY:{summary}\r\n"
f"DESCRIPTION:{description}\r\n"
f"X-WR-CALNAME:{calendar_name}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
return {
"filename": f"goodwalk-{safe_name}-birthday.ics",
"content": base64.b64encode(ics_body.encode("utf-8")).decode("ascii"),
}
def _pdf_escape(value: Any) -> str:
if value is None:
return ""
text = str(value)
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "<br>")
)
def owner_onboarding_pdf_html(data: OnboardingSubmission) -> str:
"""Clean, full-width, black-and-white, Arial HTML for the printable onboarding PDF."""
submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0")
snapshot = data.submissionSnapshot or {}
sections = snapshot.get("sections") if isinstance(snapshot, dict) else None
def render_value(value: Any) -> str:
if isinstance(value, list):
items = [_pdf_escape(item) for item in value if str(item).strip()]
return ", ".join(items) if items else "&mdash;"
text = _pdf_escape(value).strip()
return text if text else "&mdash;"
sections_html_parts: list[str] = []
if isinstance(sections, list) and sections:
for section in sections:
if not isinstance(section, dict):
continue
title = _pdf_escape(section.get("title", ""))
fields = section.get("fields") or []
rows_html = ""
for field in fields:
if not isinstance(field, dict):
continue
label = _pdf_escape(field.get("label", ""))
rows_html += (
"<tr>"
f"<th>{label}</th>"
f"<td>{render_value(field.get('value'))}</td>"
"</tr>"
)
if rows_html:
sections_html_parts.append(
f"<section class='pdf-section'>"
f"<h2>{title}</h2>"
f"<table class='pdf-table'><tbody>{rows_html}</tbody></table>"
f"</section>"
)
else:
# Fallback if snapshot is missing — render the core fields directly.
def row(label: str, value: Any) -> str:
return f"<tr><th>{_pdf_escape(label)}</th><td>{render_value(value)}</td></tr>"
owner_rows = (
row("Name", data.fullName)
+ row("Email", str(data.email))
+ row("Phone", data.phone)
+ row("Address", data.address)
)
dog_rows = (
row("Dog", data.dogName)
+ row("Breed", data.dogBreed)
+ row("Date of birth", data.dogAge or "")
+ row("Services", data.servicesNeeded)
+ row("Temperament / routine", data.temperament)
+ row("Medical notes", data.medicalNotes)
+ row("Home access", data.accessInstructions)
)
safety_rows = (
row("Vet clinic", data.vetName)
+ row("Vet phone", data.vetPhone)
+ row("Emergency contact", data.emergencyContactName)
+ row("Emergency phone", data.emergencyContactPhone)
+ row("Council registration", "Confirmed" if data.councilRegistrationConfirmed else "Not confirmed")
+ row("Vaccinations", "Confirmed" if data.vaccinationsConfirmed else "Not confirmed")
+ row("Emergency vet consent", "Confirmed" if data.emergencyVetConsent else "Not confirmed")
+ row("Declaration", "Signed" if data.termsAccepted else "Not signed")
)
sections_html_parts.append(
f"<section class='pdf-section'><h2>Owner Details</h2><table class='pdf-table'><tbody>{owner_rows}</tbody></table></section>"
f"<section class='pdf-section'><h2>Dog Details</h2><table class='pdf-table'><tbody>{dog_rows}</tbody></table></section>"
f"<section class='pdf-section'><h2>Safety</h2><table class='pdf-table'><tbody>{safety_rows}</tbody></table></section>"
)
signature_html = ""
if data.signatureDataUrl:
signature_html = (
"<section class='pdf-section pdf-signature'>"
"<h2>Signature</h2>"
f"<img src='{data.signatureDataUrl}' alt='Client signature'>"
f"<div class='pdf-signed-line'>Signed by {_pdf_escape(data.fullName)} on {_pdf_escape(submitted_at)}</div>"
"</section>"
)
body_html = "".join(sections_html_parts) + signature_html
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Goodwalk onboarding form &mdash; {_pdf_escape(data.fullName)}</title>
<style>
@page {{
size: A4;
margin: 14mm 12mm 14mm 12mm;
}}
* {{ box-sizing: border-box; }}
html, body {{
margin: 0;
padding: 0;
background: #ffffff;
color: #000000;
font-family: Arial, Helvetica, sans-serif;
font-size: 10.5pt;
line-height: 1.4;
}}
.pdf-doc {{ width: 100%; }}
.pdf-header {{
width: 100%;
border-bottom: 1.5pt solid #000;
padding-bottom: 8pt;
margin-bottom: 14pt;
}}
.pdf-header h1 {{
font-size: 18pt;
font-weight: 700;
margin: 0 0 4pt 0;
letter-spacing: 0.5pt;
text-transform: uppercase;
}}
.pdf-header .pdf-meta {{
font-size: 9.5pt;
color: #000;
}}
.pdf-section {{
width: 100%;
margin: 0 0 14pt 0;
page-break-inside: avoid;
}}
.pdf-section h2 {{
font-size: 11pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6pt;
margin: 0 0 6pt 0;
padding: 0 0 3pt 0;
border-bottom: 0.75pt solid #000;
}}
table.pdf-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
table.pdf-table th,
table.pdf-table td {{
text-align: left;
vertical-align: top;
padding: 5pt 8pt;
border-bottom: 0.4pt solid #000;
word-wrap: break-word;
overflow-wrap: break-word;
}}
table.pdf-table th {{
width: 34%;
font-weight: 700;
background: #ffffff;
}}
table.pdf-table td {{
width: 66%;
font-weight: 400;
}}
.pdf-signature img {{
display: block;
max-width: 70%;
max-height: 60mm;
height: auto;
border: 0.5pt solid #000;
padding: 4pt;
margin: 4pt 0 6pt 0;
background: #fff;
}}
.pdf-signed-line {{
font-size: 9.5pt;
border-top: 0.4pt solid #000;
padding-top: 4pt;
margin-top: 4pt;
}}
</style>
</head>
<body>
<div class="pdf-doc">
<header class="pdf-header">
<h1>Goodwalk Onboarding Form</h1>
<div class="pdf-meta">
<strong>{_pdf_escape(data.fullName)}</strong> &middot; {_pdf_escape(data.dogName)} &middot; Submitted {_pdf_escape(submitted_at)}
</div>
</header>
{body_html}
</div>
</body>
</html>"""
def _render_pdf_sync(html: str) -> bytes:
from weasyprint import HTML # imported lazily so unit tests don't require the native libs
return HTML(string=html).write_pdf()
# Feature flags — flip to True to attach a PDF copy of the signed form to the owner email.
# Kept as in-code booleans (not env vars) so the contract path stays off until explicitly enabled.
CONTRACT_PDF_ATTACHMENT_ENABLED = False
ONBOARDING_PDF_ATTACHMENT_ENABLED = True
async def _signed_form_pdf_attachment(html: str, full_name: str, kind: str, request_id: str) -> dict | None:
safe_name = re.sub(r"[^a-z0-9]+", "-", _trimmed(full_name).lower()).strip("-") or "client"
try:
pdf_bytes = await asyncio.to_thread(_render_pdf_sync, html)
except Exception as exc:
logger.error("[%s] %s PDF generation failed: %s", request_id, kind, exc, exc_info=True)
return None
logger.info("[%s] %s PDF generated: %d bytes", request_id, kind, len(pdf_bytes))
return {
"filename": f"goodwalk-{kind}-{safe_name}.pdf",
"content": base64.b64encode(pdf_bytes).decode("ascii"),
}
# ── 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.wait_for(
asyncio.to_thread(resend.Emails.send, payload),
timeout=EMAIL_SEND_TIMEOUT_SECONDS,
)
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 ───────────────────────────────────────────────────────────────────
async def _startup_smoke_pdf() -> None:
"""Import WeasyPrint and run a trivial render to surface native-lib issues
(libpango/cairo/etc.) at boot rather than on the first PDF request."""
try:
await asyncio.to_thread(_render_pdf_sync, "<html><body>ok</body></html>")
logger.info("Startup smoke: WeasyPrint OK — PDF attachments available")
except Exception as exc:
logger.error("Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (%s)", exc)
async def _startup_verify_schema() -> None:
"""Force schema creation at boot and verify the new tables exist so the
activity log isn't silently empty if CREATE permission is missing."""
if not admin_db.is_enabled():
logger.warning("Startup smoke: postgres disabled — activity/submissions will NOT be recorded")
return
try:
pool = await admin_db.get_pool()
if pool is None:
logger.warning("Startup smoke: postgres pool unavailable — activity/submissions will NOT be recorded")
return
await admin_db._ensure_schema() # idempotent
async with pool.acquire() as conn:
row = await conn.fetchrow(
"select to_regclass('public.events') as ev, to_regclass('public.submissions') as sub"
)
if row and row["ev"] and row["sub"]:
logger.info("Startup smoke: pg tables OK — events + submissions ready")
else:
logger.error("Startup smoke: pg tables MISSING (events=%s submissions=%s) — check CREATE perms",
row["ev"] if row else None, row["sub"] if row else None)
except Exception as exc:
logger.error("Startup smoke: pg schema verify FAILED (%s)", exc)
async def _startup_mail_check() -> None:
global _birthday_auto_task, _allowed_emails, _client_profiles, _drafts
# 0. Boot-time smoke tests so silent failures surface immediately.
await _startup_smoke_pdf()
await _startup_verify_schema()
# 1. Seed postgres from JSON if admin_kv is empty (one-time migration).
await _seed_admin_state_from_json_if_needed()
# 2. Refresh the in-memory caches from postgres so the app reads the
# canonical dataset even after restarts.
if admin_db.is_enabled():
try:
db_clients = await _load_client_profiles_async()
if isinstance(db_clients, dict):
_client_profiles = db_clients
db_emails = await _load_allowed_emails_async()
if isinstance(db_emails, set):
_allowed_emails = db_emails
if OWNER_EMAIL:
_allowed_emails.add(OWNER_EMAIL.strip().lower())
db_drafts = await _load_drafts_async()
if isinstance(db_drafts, dict):
_drafts = db_drafts
db_sessions = await _load_active_sessions_async()
if db_sessions:
_active_sessions.update(db_sessions)
logger.info(
"Admin state refreshed from postgres: clients=%d emails=%d drafts=%d sessions=%d",
len(_client_profiles), len(_allowed_emails), len(_drafts), len(_active_sessions),
)
except Exception:
logger.exception("Admin state refresh from postgres failed; using JSON snapshot")
# 3. Merge any shipped legacy seed (add-only — never clobbers live entries).
await _merge_legacy_seed_if_present()
try:
await _send_startup_test_email()
except Exception:
logger.exception("Startup test email failed")
if _birthday_auto_task is None or _birthday_auto_task.done():
_birthday_auto_task = asyncio.create_task(_birthday_auto_sender_loop())
async def _shutdown_background_tasks() -> None:
global _birthday_auto_task
if _birthday_auto_task is not None:
_birthday_auto_task.cancel()
try:
await _birthday_auto_task
except asyncio.CancelledError:
pass
_birthday_auto_task = None
@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&rsquo;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 &middot; Auckland, New Zealand
</div>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>"""
def _format_date_label(value: str) -> str:
raw = _trimmed(value)
if not raw:
return "To be confirmed"
try:
parsed = datetime.fromisoformat(raw)
return f"{parsed.day} {parsed.strftime('%b %Y')}"
except ValueError:
return raw
def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str, price_details: str, start_date: str) -> str:
first_name = client_name.split()[0] if client_name.strip() else "there"
dog_line = f" for {dog_name}" if dog_name.strip() else ""
formatted_start_date = _format_date_label(start_date)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Welcome to the pack</title>
</head>
<body style="margin:0;padding:0;background:#f2f2f0;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:28px 12px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" role="presentation" style="max-width:560px;width:100%;border-radius:22px;overflow:hidden;box-shadow:0 10px 32px rgba(17,20,24,0.08);">
<tr>
<td style="background:#213021;padding:28px 24px;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:#fbfaf7;padding:34px 24px 30px;">
<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">
Welcome to the pack
</div>
<h1 style="margin:18px 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
Hi {first_name}, we&rsquo;d love to get {dog_name or 'your dog'} started with Goodwalk.
</h1>
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
We&rsquo;ve set aside the details below{dog_line}. When you&rsquo;re ready, complete your onboarding form and we&rsquo;ll take it from there.
</p>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
<tr><td style="padding:22px 20px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{_detail_row("Service", service_type)}
{_detail_row("Price", price_details)}
{_detail_row("Start date", formatted_start_date)}
</table>
</td></tr>
</table>
<a href="https://onboarding.goodwalk.co.nz/" style="display:inline-block;background:#213021;color:#ffffff;text-decoration:none;border-radius:999px;padding:14px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;font-weight:700;">
Complete onboarding
</a>
<p style="margin:18px 0 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.7;color:#657365;">
Use the same email address you originally used with Goodwalk. We&rsquo;ll send you a one-time code when you sign in.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>"""
def _onboarding_confirmation_email_html(data: OnboardingSubmission) -> str:
first_name = data.fullName.split()[0] if data.fullName.strip() else "there"
dog_name = _trimmed(data.dogName)
service_names = [service.strip() for service in data.servicesNeeded if isinstance(service, str) and service.strip()]
service_summary = ", ".join(service_names[:2]) if service_names else "your selected service"
if len(service_names) > 2:
service_summary += f" + {len(service_names) - 2} more"
onboarding_url = "https://clients.goodwalk.co.nz/"
badge_html = (
'<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;'
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;"
"font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;\">Submitted</div>"
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Your onboarding has been submitted</title>
</head>
<body style="margin:0;padding:0;background:#f2f2f0;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:28px 12px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" role="presentation" style="max-width:560px;width:100%;border-radius:22px;overflow:hidden;box-shadow:0 10px 32px rgba(17,20,24,0.08);">
{_logo_header(badge_html=badge_html, subtitle="Your onboarding details are safely with us")}
<tr>
<td style="background:#fbfaf7;padding:34px 24px 30px;">
<h1 style="margin:0 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
Thanks, {first_name}. Your onboarding is complete.
</h1>
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
We&rsquo;ve received your details{f" for {dog_name}" if dog_name else ""} and they&rsquo;re now on file with Goodwalk.
You can sign back in any time to review what you submitted.
</p>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
<tr><td style="padding:22px 20px;">
<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;">
Snapshot
</div>
<div style="display:grid;gap:14px;">
<div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#7b867b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;">Owner</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:18px;font-weight:700;color:#171b20;">{data.fullName}</div>
</div>
<div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#7b867b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;">Dog</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:18px;font-weight:700;color:#171b20;">{dog_name or 'Details submitted'}</div>
</div>
<div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#7b867b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;">Services</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:16px;line-height:1.5;color:#425042;">{service_summary}</div>
</div>
</div>
</td></tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-left:3px solid #ffd100;margin-bottom:24px;">
<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.65;">
We&rsquo;ll review your submission and come back to you if we need anything clarified.
If you need to check your details again, use the button below to sign back in with a one-time code.
</div>
</td>
</tr>
</table>
<a href="{onboarding_url}" style="display:inline-block;background:#213021;color:#ffffff;text-decoration:none;border-radius:999px;padding:14px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;font-weight:700;">
Review your submission
</a>
<p style="margin:18px 0 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.7;color:#657365;">
Your submitted form is read-only after completion. If anything needs changing, just reply to this email or contact us directly.
</p>
</td>
</tr>
<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 &middot; Auckland, New Zealand
</div>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>"""
def _birthday_email_html(client_name: str, dog_name: str) -> str:
first_name = client_name.split()[0] if client_name.strip() else "there"
dog_name_clean = dog_name.strip() or "your dog"
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Happy birthday from Goodwalk</title>
</head>
<body style="margin:0;padding:0;background:#f2f2f0;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:28px 12px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" role="presentation" style="max-width:560px;width:100%;border-radius:22px;overflow:hidden;box-shadow:0 10px 32px rgba(17,20,24,0.08);">
<tr>
<td style="background:#213021;padding:28px 24px;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:#fbfaf7;padding:34px 24px 30px;">
<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">
Happy birthday
</div>
<h1 style="margin:18px 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
Happy birthday to {dog_name_clean}.
</h1>
<p style="margin:0 0 18px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
Hi {first_name}, sending a little birthday love from all of us at Goodwalk. We hope {dog_name_clean} has a very good day.
</p>
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
Aless and the Goodwalk pack
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>"""
def _upcoming_birthday_date(dog_birth_date: str, today: datetime | None = None):
raw = _trimmed(dog_birth_date)
if not raw:
return None
try:
birth_date = datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return None
today_date = (today or datetime.now()).date()
target_year = today_date.year
while True:
try:
candidate = birth_date.replace(year=target_year)
break
except ValueError:
# Handle 29 Feb birthdays by moving them to 28 Feb on non-leap years.
candidate = birth_date.replace(year=target_year, month=2, day=28)
break
if candidate < today_date:
target_year += 1
try:
candidate = birth_date.replace(year=target_year)
except ValueError:
candidate = birth_date.replace(year=target_year, month=2, day=28)
return candidate
async def _send_birthday_email_for_profile(email: str, profile: dict, request_id: str, mark_auto_year: int | None = None, preview: bool = False) -> None:
client_name = str(profile.get("fullName", "")).strip()
dog_name = str(profile.get("dogName", "")).strip()
recipient = OWNER_EMAIL.strip().lower() if preview else email
subject = f"Happy birthday {dog_name or 'from Goodwalk'}"
if preview:
subject = f"[PREVIEW for {client_name or email}] {subject}"
payload = {
"from": FROM_EMAIL,
"to": [recipient],
"reply_to": REPLY_TO,
"subject": subject,
"html": _birthday_email_html(client_name, dog_name),
}
if CLIENT_BCC and not preview:
payload["bcc"] = [CLIENT_BCC]
await _send_email(payload, label="birthday_email_preview" if preview else "birthday_email", request_id=request_id)
if preview:
return
profile_update = {
"birthdayEmailLastSentAt": datetime.now().isoformat(timespec="seconds"),
}
if mark_auto_year is not None:
profile_update["birthdayEmailLastSentYear"] = str(mark_auto_year)
await _store_client_profile(email, profile_update)
async def _run_birthday_auto_sender_once() -> None:
today = datetime.now().date()
today_month_day = (today.month, today.day)
for email, profile in list(_client_profiles.items()):
if not profile.get("onboardingCompleted"):
continue
if not profile.get("birthdayAutoSend"):
continue
upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")))
if not upcoming or (upcoming.month, upcoming.day) != today_month_day:
continue
last_sent_year = str(profile.get("birthdayEmailLastSentYear", "")).strip()
if last_sent_year == str(today.year):
continue
request_id = f"birthday-auto-{uuid.uuid4().hex[:6]}"
try:
await _send_birthday_email_for_profile(email, profile, request_id, mark_auto_year=today.year)
logger.info("[%s] auto birthday email sent: email=%s", request_id, email)
except Exception as exc:
logger.error("[%s] auto birthday email failed: %s", request_id, exc, exc_info=True)
async def _birthday_auto_sender_loop() -> None:
while True:
try:
await _run_birthday_auto_sender_once()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Birthday auto sender loop failed")
await asyncio.sleep(BIRTHDAY_CHECK_INTERVAL_SECONDS)
_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 dont 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,
)
await admin_db.record_event(
event_type="auth_code_requested",
request_id=request_id, actor_email=email, ip=ip, status="ok",
)
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}
await _save_active_sessions_async()
logger.info("[%s] auth: session created for email=%s", request_id, email)
await admin_db.record_event(
event_type="auth_login",
request_id=request_id, actor_email=email, ip=ip, status="ok",
)
return {"ok": True, "token": token, "email": email}
@app.get("/auth/verify")
async def auth_verify(request: Request):
email = await _require_session_email(request)
profile = _client_profiles.get(email, {})
draft = _drafts.get(email, {})
return {
"ok": True,
"email": email,
"profile": profile,
"draft": draft,
"cpAdmin": email in CP_ADMIN_EMAILS,
"ownerEmail": OWNER_EMAIL,
}
@app.post("/auth/logout")
async def auth_logout(request: Request):
auth_header = request.headers.get("Authorization", "")
token = auth_header.removeprefix("Bearer ").strip()
logged_out_email = None
if token:
async with _auth_lock:
existing = _active_sessions.pop(token, None)
logged_out_email = existing.get("email") if isinstance(existing, dict) else None
await _save_active_sessions_async()
await admin_db.record_event(
event_type="auth_logout",
actor_email=logged_out_email, ip=_get_ip(request), status="ok",
)
return {"ok": True}
@app.post("/auth/save-draft")
async def auth_save_draft(request: Request):
email = await _require_session_email(request)
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_file, snapshot)
await _persist_admin_state("drafts", snapshot)
logger.info("Draft saved: email=%s form=%s", email, form)
return {"ok": True}
MESSAGE_TEMPLATES: dict[str, dict[str, str]] = {
"general": {
"id": "general",
"name": "General update",
"description": "Clean Goodwalk branding for everyday news and updates.",
"kicker": "From Goodwalk",
"banner_emoji": "🐾",
"accent": "#ffd100",
"accent_text": "#213021",
"page_bg": "#f3f0e5",
"card_bg": "#fbfaf7",
"heading_color": "#171b20",
"body_color": "#4b584b",
"muted_color": "#6b766b",
"band_bg": "#213021",
"band_text": "#ffd100",
"band_decoration": "🐾 · 🐾 · 🐾 · 🐾 · 🐾",
"footer_bg": "#213021",
"footer_text": "#fbfaf7",
"highlight_bg": "#fff8d6",
"highlight_border": "#ffd100",
"highlight_text": "#213021",
"ornament_top": "",
"ornament_bottom": "",
"default_subject": "A note from Goodwalk",
"default_heading": "Hello from the pack",
"default_sub_heading": "A quick update from your dog walking team.",
"default_body": "Thank you for being part of our community. Every wag and woof matters to us, and we have a small update to share.\n\nWe're always here if you need to chat about walks, training, or anything else dog-related.",
"default_highlight": "",
"default_sign_off": "Aless & the Goodwalk pack",
"default_footer_note": "goodwalk.co.nz · Auckland, NZ",
},
"christmas": {
"id": "christmas",
"name": "Christmas",
"description": "Deep green and red festive styling with snow accents.",
"kicker": "Season's greetings",
"banner_emoji": "🎄",
"accent": "#c0392b",
"accent_text": "#ffffff",
"page_bg": "#e8dccb",
"card_bg": "#fbf6ec",
"heading_color": "#0d3b1e",
"body_color": "#3a4a3a",
"muted_color": "#6b766b",
"band_bg": "#0d4d2a",
"band_text": "#ffffff",
"band_decoration": "❄ · 🎄 · ❄ · 🎁 · ❄ · 🦌 · ❄ · ⭐ · ❄",
"footer_bg": "#0d4d2a",
"footer_text": "#ffe8d6",
"highlight_bg": "#fff0ea",
"highlight_border": "#c0392b",
"highlight_text": "#7a1d12",
"ornament_top": "❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄",
"ornament_bottom": "🎄 ⭐ 🎁 🦌 ❄ 🎁 ⭐ 🎄",
"default_subject": "Merry Christmas from the Goodwalk pack 🎄",
"default_heading": "Wishing you a very woofy Christmas",
"default_sub_heading": "From our pack to yours — thank you for an incredible year.",
"default_body": "It's been a year full of muddy paws, sunny walks, and very good dogs. From all of us at Goodwalk, we wish you and your pup a warm, joyful Christmas.\n\nWe'll be taking a short break over the holidays and will be back in full swing for the new year. Looking forward to many more adventures in 2026.",
"default_highlight": "🎁 Holiday schedule: walks pause from 24 Dec, resuming 6 Jan.",
"default_sign_off": "Aless & the Goodwalk pack",
"default_footer_note": "Wishing you a warm and joyful Christmas",
},
"easter": {
"id": "easter",
"name": "Easter",
"description": "Soft pastel styling with floral and bunny accents.",
"kicker": "Happy Easter",
"banner_emoji": "🐰",
"accent": "#d8a8de",
"accent_text": "#3a2a4a",
"page_bg": "#fdf3f8",
"card_bg": "#ffffff",
"heading_color": "#3a2a4a",
"body_color": "#5a4a5a",
"muted_color": "#8a7a8a",
"band_bg": "#f5d6e5",
"band_text": "#5a2a6b",
"band_decoration": "🌷 · 🐰 · 🌸 · 🥚 · 🐣 · 🌷 · 🌸",
"footer_bg": "#e7c9f0",
"footer_text": "#3a2a4a",
"highlight_bg": "#fff0fa",
"highlight_border": "#d8a8de",
"highlight_text": "#5a2a6b",
"ornament_top": "🌷 🌸 🌷 🌸 🌷 🌸 🌷 🌸 🌷 🌸",
"ornament_bottom": "🥚 🐰 🌸 🐣 🥚 🐰",
"default_subject": "Hop on into Easter with Goodwalk 🐰",
"default_heading": "A happy, hoppy Easter to you",
"default_sub_heading": "Spring is in the air and tails are wagging.",
"default_body": "Wishing you and your pup a beautiful Easter weekend. May your walks be sunny, your eggs uneaten by curious snouts, and your treats plentiful.\n\nA little reminder: chocolate is not for dogs, no matter how sweetly they ask. We'll be sticking to the good stuff on our walks.",
"default_highlight": "🐣 Keep chocolate well out of reach — even small amounts can be harmful to dogs.",
"default_sign_off": "Aless & the Goodwalk pack",
"default_footer_note": "Happy Easter from all of us",
},
"halloween": {
"id": "halloween",
"name": "Halloween",
"description": "Dark purple and orange spooky styling.",
"kicker": "Trick or treat",
"banner_emoji": "🎃",
"accent": "#ff7518",
"accent_text": "#1a0d1f",
"page_bg": "#1a0d1f",
"card_bg": "#2b1838",
"heading_color": "#ffe8d0",
"body_color": "#d8c8d8",
"muted_color": "#9a8aaa",
"band_bg": "#0a0410",
"band_text": "#ff7518",
"band_decoration": "🎃 · 👻 · 🕷 · 🦇 · 🌙 · 🕸 · 🎃 · 👻",
"footer_bg": "#0a0410",
"footer_text": "#ff7518",
"highlight_bg": "#4a2b66",
"highlight_border": "#ff7518",
"highlight_text": "#ffe8d0",
"ornament_top": "🦇 🕸 🦇 🕸 🦇 🕸 🦇 🕸 🦇 🕸",
"ornament_bottom": "🎃 👻 🕷 🌙 🦇 🕸 🎃",
"default_subject": "Spooky season at Goodwalk 🎃",
"default_heading": "It's Howl-oween",
"default_sub_heading": "Costumes optional. Treats mandatory.",
"default_body": "Spooky season is upon us. We'll be out walking with extra vigilance — fireworks, doorbell mayhem, and rogue chocolate are all on our radar.\n\nIf your pup is nervous around fireworks or doorbells, let us know and we'll factor it into walks this week.",
"default_highlight": "🍫 Reminder: chocolate, raisins, and xylitol are all toxic to dogs. Keep the treat bowl high.",
"default_sign_off": "Aless & the Goodwalk pack",
"default_footer_note": "Stay spooky out there",
},
"promo": {
"id": "promo",
"name": "Sale / promotional offer",
"description": "Bright yellow promotional styling with a clear discount callout.",
"kicker": "Limited offer",
"banner_emoji": "🦴",
"accent": "#ffd100",
"accent_text": "#213021",
"page_bg": "#fffaeb",
"card_bg": "#fffdf5",
"heading_color": "#171b20",
"body_color": "#3a4a3a",
"muted_color": "#6b766b",
"band_bg": "#213021",
"band_text": "#ffd100",
"band_decoration": "★ · SPECIAL OFFER · ★ · LIMITED TIME · ★",
"footer_bg": "#213021",
"footer_text": "#ffd100",
"highlight_bg": "#fff3a0",
"highlight_border": "#ffd100",
"highlight_text": "#213021",
"ornament_top": "★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★",
"ornament_bottom": "🦴 ★ 🐾 ★ 🦴 ★ 🐾",
"default_subject": "A little something from Goodwalk 🦴",
"default_heading": "A special offer for our pack",
"default_sub_heading": "Because regulars are family.",
"default_body": "We're running a small thank-you offer for our existing clients. As a regular, you're first in line.\n\nReply to this email or hit the button below to take it up. Offer is limited and won't be around long.",
"default_highlight": "20% off your next week of walks · Use code PACKLOVE at booking",
"default_sign_off": "Aless & the Goodwalk pack",
"default_footer_note": "Limited time — be quick",
},
}
MESSAGE_FONTS: dict[str, dict[str, str]] = {
"system": {
"id": "system",
"name": "System (clean sans-serif)",
"stack": "-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
"link": "",
"heading_stack": "Georgia,'Times New Roman',serif",
},
"lora": {
"id": "lora",
"name": "Lora (warm serif)",
"stack": "'Lora',Georgia,'Times New Roman',serif",
"link": "https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400&display=swap",
"heading_stack": "'Lora',Georgia,'Times New Roman',serif",
},
"playfair": {
"id": "playfair",
"name": "Playfair Display (editorial serif)",
"stack": "Georgia,'Times New Roman',serif",
"link": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Source+Sans+3:wght@400;600&display=swap",
"heading_stack": "'Playfair Display',Georgia,'Times New Roman',serif",
},
"merriweather": {
"id": "merriweather",
"name": "Merriweather (readable serif)",
"stack": "'Merriweather',Georgia,'Times New Roman',serif",
"link": "https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap",
"heading_stack": "'Merriweather',Georgia,'Times New Roman',serif",
},
"crimson": {
"id": "crimson",
"name": "Crimson Text (classic serif)",
"stack": "'Crimson Text',Georgia,'Times New Roman',serif",
"link": "https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&display=swap",
"heading_stack": "'Crimson Text',Georgia,'Times New Roman',serif",
},
"inter": {
"id": "inter",
"name": "Inter (modern sans)",
"stack": "'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
"link": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
"heading_stack": "'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
},
"montserrat": {
"id": "montserrat",
"name": "Montserrat (geometric sans)",
"stack": "'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
"link": "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap",
"heading_stack": "'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
},
"opensans": {
"id": "opensans",
"name": "Open Sans (friendly sans)",
"stack": "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
"link": "https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,600;0,700;1,400&display=swap",
"heading_stack": "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
},
}
def _style_body_html(body_html: str, font_stack: str, body_color: str, accent_color: str) -> str:
"""Apply email-safe inline styles to common HTML tags in user-provided body content."""
import re
base_p_style = f"margin:0 0 16px;font-family:{font_stack};font-size:16px;line-height:1.7;color:{body_color};"
base_li_style = f"margin:0 0 6px;font-family:{font_stack};font-size:16px;line-height:1.7;color:{body_color};"
base_ul_style = f"margin:0 0 16px 0;padding:0 0 0 22px;font-family:{font_stack};color:{body_color};"
base_ol_style = base_ul_style
a_style = f"color:{accent_color};text-decoration:underline;"
# Strip <div> wrappers (contenteditable often wraps in divs); convert to <p>
s = body_html
s = re.sub(r"<div\b[^>]*>", "<p>", s)
s = s.replace("</div>", "</p>")
s = s.replace("<br>", "<br/>").replace("<br />", "<br/>")
# Apply inline styles by replacing opening tags (only if no style attribute already)
def _inject(tag: str, style: str, text: str) -> str:
return re.sub(
rf"<{tag}(\s[^>]*)?>",
lambda m: f"<{tag}{m.group(1) or ''} style=\"{style}\">",
text,
flags=re.IGNORECASE,
)
s = _inject("p", base_p_style, s)
s = _inject("ul", base_ul_style, s)
s = _inject("ol", base_ol_style, s)
s = _inject("li", base_li_style, s)
s = re.sub(
r"<a(\s[^>]*?)>",
lambda m: f"<a{m.group(1)} style=\"{a_style}\">" if "style=" not in m.group(1).lower() else m.group(0),
s,
flags=re.IGNORECASE,
)
return s
def _body_to_html(body_text: str, font_stack: str, body_color: str, accent_color: str) -> str:
"""Convert user body input to email-safe HTML.
If the input already looks like HTML (contains a tag), we treat it as HTML and inline-style it.
Otherwise we split on blank lines and wrap each paragraph in a <p>.
"""
if not body_text or not body_text.strip():
return ""
if "<" in body_text and ">" in body_text:
return _style_body_html(body_text, font_stack, body_color, accent_color)
parts = [p.strip() for p in body_text.split("\n\n") if p.strip()]
return "".join(
f'<p style="margin:0 0 16px;font-family:{font_stack};font-size:16px;line-height:1.7;color:{body_color};">{para}</p>'
for para in parts
)
def _escape_attr(value: str) -> str:
return (value or "").replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
def _bulletproof_button(label: str, url: str, bg: str, text_color: str, font_stack: str = "-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif") -> str:
if not label.strip() or not url.strip():
return ""
safe_url = _escape_attr(url.strip())
safe_label = (label.strip()
.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
return f"""
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:22px 0 6px;">
<tr><td align="left">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{safe_url}" style="height:48px;v-text-anchor:middle;width:240px;" arcsize="50%" stroke="f" fillcolor="{bg}">
<w:anchorlock/>
<center style="color:{text_color};font-family:Arial,sans-serif;font-size:14px;font-weight:bold;letter-spacing:0.04em;">{safe_label}</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-- -->
<a href="{safe_url}" style="display:inline-block;padding:14px 28px;border-radius:999px;background:{bg};color:{text_color};font-family:{font_stack};font-size:14px;font-weight:700;text-decoration:none;letter-spacing:0.04em;mso-hide:all;">{safe_label}</a>
<!--<![endif]-->
</td></tr>
</table>"""
def _render_message_html(
template_id: str,
heading: str,
body: str,
cta_label: str,
cta_url: str,
sub_heading: str = "",
highlight_text: str = "",
sign_off: str = "",
footer_note: str = "",
font_id: str = "system",
) -> str:
tmpl = MESSAGE_TEMPLATES.get(template_id, MESSAGE_TEMPLATES["general"])
font = MESSAGE_FONTS.get(font_id, MESSAGE_FONTS["system"])
font_stack = font["stack"]
heading_font_stack = font["heading_stack"]
font_link = font["link"]
accent = tmpl["accent"]
accent_text = tmpl["accent_text"]
page_bg = tmpl["page_bg"]
card_bg = tmpl["card_bg"]
heading_color = tmpl["heading_color"]
body_color = tmpl["body_color"]
muted_color = tmpl["muted_color"]
band_bg = tmpl["band_bg"]
band_text = tmpl["band_text"]
band_decoration = tmpl["band_decoration"]
footer_bg = tmpl["footer_bg"]
footer_text_color = tmpl["footer_text"]
highlight_bg = tmpl["highlight_bg"]
highlight_border = tmpl["highlight_border"]
highlight_text_color = tmpl["highlight_text"]
ornament_top = tmpl["ornament_top"]
ornament_bottom = tmpl["ornament_bottom"]
kicker = tmpl["kicker"]
emoji = tmpl["banner_emoji"]
h = (heading or tmpl["default_heading"]).strip()
sh = (sub_heading or tmpl["default_sub_heading"]).strip()
so = (sign_off or tmpl.get("default_sign_off", "")).strip()
fn = (footer_note or tmpl["default_footer_note"]).strip()
hl = (highlight_text or tmpl["default_highlight"]).strip()
body_text = (body or tmpl["default_body"]).strip()
body_html_inner = _body_to_html(body_text, font_stack, body_color, accent)
body_html = (
f'<div style="font-family:{font_stack};color:{body_color};font-size:16px;line-height:1.7;">'
f'{body_html_inner}'
f'</div>'
)
highlight_html = ""
if hl:
highlight_html = f"""
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:8px 0 18px;">
<tr><td style="background:{highlight_bg};border-left:4px solid {highlight_border};padding:14px 18px;border-radius:8px;">
<p style="margin:0;font-family:{font_stack};font-size:14px;line-height:1.55;color:{highlight_text_color};font-weight:600;">
{hl}
</p>
</td></tr>
</table>"""
cta_html = _bulletproof_button(cta_label, cta_url, accent, accent_text, font_stack)
sub_heading_html = ""
if sh:
sub_heading_html = f"""
<p style="margin:0 0 22px;font-family:{heading_font_stack};font-style:italic;font-size:16px;line-height:1.5;color:{muted_color};">
{sh}
</p>"""
ornament_top_html = ""
if ornament_top:
ornament_top_html = f"""
<tr>
<td align="center" style="background:{card_bg};padding:18px 24px 0;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.4em;color:{accent};line-height:1;">
{ornament_top}
</td>
</tr>"""
ornament_bottom_html = ""
if ornament_bottom:
ornament_bottom_html = f"""
<tr>
<td align="center" style="background:{card_bg};padding:0 24px 22px;font-family:Arial,sans-serif;font-size:15px;letter-spacing:0.4em;color:{accent};line-height:1;">
{ornament_bottom}
</td>
</tr>"""
kicker_html = f"""
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px;">
<tr><td style="background:{accent};border-radius:999px;padding:8px 16px;font-family:{font_stack};font-size:11px;font-weight:700;color:{accent_text};letter-spacing:0.12em;text-transform:uppercase;">
{(emoji + ' &nbsp; ') if emoji else ''}{kicker}
</td></tr>
</table>"""
font_link_html = ""
if font_link:
font_link_html = (
f'<!--[if !mso]><!--><link href="{font_link}" rel="stylesheet" type="text/css"><!--<![endif]-->'
)
return f"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no">
{font_link_html}
<!--[if mso]>
<xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml>
<![endif]-->
<title>{h}</title>
</head>
<body style="margin:0;padding:0;background:{page_bg};width:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;font-size:1px;line-height:1px;color:{page_bg};opacity:0;">{sh or h}</div>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:{page_bg};">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;background:{card_bg};border-radius:18px;overflow:hidden;">
<tr>
<td align="center" style="background:{band_bg};padding:14px 20px;font-family:{font_stack};font-size:13px;letter-spacing:0.18em;color:{band_text};font-weight:700;">
{band_decoration}
</td>
</tr>
<tr>
<td align="center" style="background:#213021;padding:22px 24px;">
<img src="{LOGO_URL}" width="161" height="32" alt="Goodwalk" style="display:block;max-width:161px;height:auto;border:0;outline:none;text-decoration:none;">
</td>
</tr>
{ornament_top_html}
<tr>
<td style="background:{card_bg};padding:30px 30px 8px;">
{kicker_html}
<h1 style="margin:0 0 14px;font-family:{heading_font_stack};font-size:32px;line-height:1.1;letter-spacing:-0.02em;color:{heading_color};font-weight:700;">
{h}
</h1>
{sub_heading_html}
{body_html}
{highlight_html}
{cta_html}
{('<p style="margin:24px 0 0;font-family:' + font_stack + ';font-size:14px;line-height:1.6;color:' + muted_color + ';">With love,<br><strong style="color:' + heading_color + ';">' + so + '</strong></p>') if so else ''}
</td>
</tr>
{ornament_bottom_html}
<tr>
<td align="center" style="background:{footer_bg};padding:22px 24px 18px;font-family:{font_stack};font-size:12px;line-height:1.6;color:{footer_text_color};">
<div style="font-size:14px;letter-spacing:0.3em;margin-bottom:8px;color:{footer_text_color};">{ornament_bottom or '🐾 · 🐾 · 🐾'}</div>
{('<div style="font-weight:600;">' + fn + '</div>') if fn else ''}
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>"""
@app.get("/owner/message-templates")
async def owner_message_templates(request: Request):
await _require_owner_email(request)
templates = [
{
"id": t["id"],
"name": t["name"],
"description": t["description"],
"accent": t["accent"],
"bannerEmoji": t["banner_emoji"],
"defaultSubject": t["default_subject"],
"defaultHeading": t["default_heading"],
"defaultSubHeading": t["default_sub_heading"],
"defaultBody": t["default_body"],
"defaultHighlight": t["default_highlight"],
"defaultSignOff": t.get("default_sign_off", ""),
"defaultFooterNote": t["default_footer_note"],
}
for t in MESSAGE_TEMPLATES.values()
]
fonts = [
{"id": f["id"], "name": f["name"], "link": f["link"], "stack": f["stack"]}
for f in MESSAGE_FONTS.values()
]
return {"ok": True, "templates": templates, "fonts": fonts}
@app.post("/owner/render-message")
async def owner_render_message(data: RenderMessageRequest, request: Request):
await _require_owner_email(request)
if data.templateId not in MESSAGE_TEMPLATES:
raise HTTPException(status_code=400, detail="Unknown template.")
html = _render_message_html(
data.templateId,
data.heading,
data.body,
data.ctaLabel,
data.ctaUrl,
sub_heading=data.subHeading,
highlight_text=data.highlightText,
sign_off=data.signOff,
footer_note=data.footerNote,
font_id=data.fontId,
)
return {"ok": True, "html": html}
@app.post("/owner/render-welcome-pack")
async def owner_render_welcome_pack(data: WelcomePackEmailRequest, request: Request):
"""Render the welcome pack email as HTML for in-modal preview."""
await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email, {})
owner_name = str(profile.get("fullName", "")).strip()
dog_name = str(profile.get("dogName", "")).strip()
html = _welcome_pack_email_html(
owner_name,
dog_name,
_trimmed(data.serviceType),
_trimmed(data.priceDetails),
_trimmed(data.startDate),
)
return {"ok": True, "html": html}
@app.post("/owner/render-birthday-email")
async def owner_render_birthday_email(data: BirthdayEmailRequest, request: Request):
"""Render the birthday email as HTML for in-modal preview."""
await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email, {})
if not profile:
raise HTTPException(status_code=404, detail="Client profile not found.")
owner_name = str(profile.get("fullName", "")).strip()
dog_name = str(profile.get("dogName", "")).strip()
html = _birthday_email_html(owner_name, dog_name)
return {"ok": True, "html": html}
@app.post("/owner/send-message")
async def owner_send_message(data: SendMessageRequest, request: Request):
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
owner_email = await _require_owner_email(request)
if data.templateId not in MESSAGE_TEMPLATES:
raise HTTPException(status_code=400, detail="Unknown template.")
subject = _trimmed(data.subject)
if not subject:
raise HTTPException(status_code=400, detail="Please enter a subject.")
is_preview = bool(data.preview)
recipient_emails = [str(e).strip().lower() for e in (data.recipients or []) if str(e).strip()]
if not is_preview and not recipient_emails:
raise HTTPException(status_code=400, detail="Please choose at least one recipient.")
html = _render_message_html(
data.templateId,
data.heading,
data.body,
data.ctaLabel,
data.ctaUrl,
sub_heading=data.subHeading,
highlight_text=data.highlightText,
sign_off=data.signOff,
footer_note=data.footerNote,
font_id=data.fontId,
)
owner_addr = OWNER_EMAIL.strip().lower()
if is_preview:
payload = {
"from": FROM_EMAIL,
"to": [owner_addr],
"reply_to": REPLY_TO,
"subject": f"[PREVIEW] {subject}",
"html": html,
}
try:
await _send_email(payload, label="bulk_message_preview", request_id=request_id)
except Exception as exc:
logger.error("[%s] bulk message preview failed: %s", request_id, exc, exc_info=True)
raise HTTPException(status_code=502, detail={"request_id": request_id, "message": "The preview could not be sent."})
return {"ok": True, "preview": True}
# Real send — always BCC, To: owner. Each recipient sees only owner in To.
payload = {
"from": FROM_EMAIL,
"to": [owner_addr],
"bcc": recipient_emails,
"reply_to": REPLY_TO,
"subject": subject,
"html": html,
}
try:
await _send_email(payload, label="bulk_message", request_id=request_id)
except Exception as exc:
logger.error("[%s] bulk message failed: %s", request_id, exc, exc_info=True)
raise HTTPException(status_code=502, detail={"request_id": request_id, "message": "The message could not be sent."})
logger.info("[%s] bulk message sent: template=%s recipients=%d", request_id, data.templateId, len(recipient_emails))
await admin_db.record_event(
event_type="owner_message_sent",
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
detail={
"templateId": data.templateId,
"subject": subject,
"recipientCount": len(recipient_emails),
"recipients": recipient_emails,
},
)
return {"ok": True, "recipientCount": len(recipient_emails)}
@app.get("/owner/client-enquiry")
async def owner_client_enquiry(request: Request):
await _require_owner_email(request)
email = (request.query_params.get("email") or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email is required.")
profile = _client_profiles.get(email)
if not profile:
raise HTTPException(status_code=404, detail="Client not found.")
enquiry = profile.get("lastEnquiry") if isinstance(profile.get("lastEnquiry"), dict) else None
if not enquiry:
# Fall back to legacy profile fields if no enquiry snapshot was stored
enquiry = {
"submittedAt": profile.get("lastEnquiryAt", ""),
"enquiryType": profile.get("enquiryType", ""),
"fullName": profile.get("fullName", ""),
"email": email,
"phone": profile.get("phone", ""),
"petName": profile.get("dogName", ""),
"location": profile.get("location", ""),
"services": profile.get("services", []) if isinstance(profile.get("services"), list) else [],
"message": "",
"referrer": "",
"page": "",
}
# Journey is populated by the SvelteKit /api/track/promote endpoint when
# the visitor submits the booking form. None means we never recorded a
# journey for this email (legacy submission, ad-blocker that also blocked
# /api/track, or DB-less local dev).
journey = await admin_db.get_submission_journey(email)
return {"ok": True, "enquiry": enquiry, "journey": journey}
@app.get("/owner/activity")
async def owner_activity(request: Request):
await _require_owner_email(request)
qp = request.query_params
try:
limit = int(qp.get("limit", "100"))
except ValueError:
limit = 100
before_id = qp.get("beforeId") or qp.get("before_id")
try:
before_id_int = int(before_id) if before_id else None
except ValueError:
before_id_int = None
event_type = _trimmed(qp.get("eventType", "")) or None
actor_email = _trimmed(qp.get("actorEmail", "")) or None
events = await admin_db.list_events(
limit=limit,
before_id=before_id_int,
event_type=event_type,
actor_email=actor_email,
)
return {"ok": True, "events": events}
@app.get("/owner/submissions")
async def owner_submissions(request: Request):
await _require_owner_email(request)
qp = request.query_params
try:
limit = int(qp.get("limit", "100"))
except ValueError:
limit = 100
before_id = qp.get("beforeId") or qp.get("before_id")
try:
before_id_int = int(before_id) if before_id else None
except ValueError:
before_id_int = None
kind = _trimmed(qp.get("kind", "")) or None
email_filter = _trimmed(qp.get("email", "")) or None
rows = await admin_db.list_submissions(
limit=limit,
before_id=before_id_int,
kind=kind,
email=email_filter,
)
return {"ok": True, "submissions": rows}
@app.get("/owner/pending-onboarding")
async def owner_pending_onboarding(request: Request):
await _require_owner_email(request)
def _sort_timestamp(value: Any) -> float:
if not isinstance(value, str) or not value:
return 0
try:
return datetime.fromisoformat(value).timestamp()
except ValueError:
return 0
pending_clients: list[dict[str, Any]] = []
for email, profile in _client_profiles.items():
if email == OWNER_EMAIL.strip().lower():
continue
if profile.get("onboardingCompleted"):
continue
if not _client_is_reachable(profile):
continue
pending_clients.append({
"email": email,
"fullName": profile.get("fullName", ""),
"phone": profile.get("phone", ""),
"dogName": profile.get("dogName", ""),
"dogBreed": profile.get("dogBreed", ""),
"services": profile.get("services", []) if isinstance(profile.get("services"), list) else [],
"lastEnquiryAt": profile.get("lastEnquiryAt", ""),
"welcomePackSentAt": profile.get("welcomePackSentAt", ""),
"welcomePackOffer": profile.get("welcomePackOffer", {}) if isinstance(profile.get("welcomePackOffer"), dict) else {},
})
pending_clients.sort(
key=lambda item: (
item.get("welcomePackSentAt", "") != "",
-_sort_timestamp(item.get("lastEnquiryAt")),
item.get("fullName", "").lower(),
),
)
return {"ok": True, "clients": pending_clients}
@app.get("/owner/completed-onboarding")
async def owner_completed_onboarding(request: Request):
await _require_owner_email(request)
def _sort_timestamp(value: Any) -> float:
if not isinstance(value, str) or not value:
return 0
try:
return datetime.fromisoformat(value).timestamp()
except ValueError:
return 0
try:
page = max(1, int(request.query_params.get("page", "1")))
except ValueError:
page = 1
try:
page_size = min(24, max(1, int(request.query_params.get("page_size", "10"))))
except ValueError:
page_size = 10
completed_clients: list[dict[str, Any]] = []
for email, profile in _client_profiles.items():
if email == OWNER_EMAIL.strip().lower():
continue
if not profile.get("onboardingCompleted"):
continue
if not _client_is_reachable(profile):
continue
completed_clients.append({
"email": email,
"fullName": profile.get("fullName", ""),
"phone": profile.get("phone", ""),
"address": profile.get("address", ""),
"dogName": profile.get("dogName", ""),
"dogBreed": profile.get("dogBreed", ""),
"dogAge": profile.get("dogAge", ""),
"onboardingSubmittedAt": profile.get("onboardingSubmittedAt", ""),
"hasBirthdayInvite": bool(_trimmed(str(profile.get("dogAge", "")))),
})
completed_clients.sort(
key=lambda item: (
-_sort_timestamp(item.get("onboardingSubmittedAt")),
item.get("fullName", "").lower(),
),
)
total = len(completed_clients)
total_pages = max(1, (total + page_size - 1) // page_size)
page = min(page, total_pages)
start = (page - 1) * page_size
end = start + page_size
return {
"ok": True,
"clients": completed_clients[start:end],
"pagination": {
"page": page,
"pageSize": page_size,
"total": total,
"totalPages": total_pages,
},
}
@app.get("/owner/all-clients")
async def owner_all_clients(request: Request):
await _require_owner_email(request)
def _sort_timestamp(value: Any) -> float:
if not isinstance(value, str) or not value:
return 0
try:
return datetime.fromisoformat(value).timestamp()
except ValueError:
return 0
try:
page = max(1, int(request.query_params.get("page", "1")))
except ValueError:
page = 1
try:
page_size = min(30, max(1, int(request.query_params.get("page_size", "12"))))
except ValueError:
page_size = 12
clients: list[dict[str, Any]] = []
for email, profile in _client_profiles.items():
if email == OWNER_EMAIL.strip().lower():
continue
lifecycle = profile.get("lifecycle") if isinstance(profile.get("lifecycle"), dict) else None
clients.append({
"email": email,
"fullName": profile.get("fullName", ""),
"phone": profile.get("phone", ""),
"dogName": profile.get("dogName", ""),
"dogBreed": profile.get("dogBreed", ""),
"status": "completed" if profile.get("onboardingCompleted") else "pending",
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
"lastActivityAt": profile.get("onboardingSubmittedAt", "") or profile.get("lastEnquiryAt", "") or profile.get("welcomePackSentAt", ""),
"welcomePackSentAt": profile.get("welcomePackSentAt", ""),
})
clients.sort(
key=lambda item: (
item.get("status") != "pending",
-_sort_timestamp(item.get("lastActivityAt")),
item.get("fullName", "").lower(),
),
)
total = len(clients)
total_pages = max(1, (total + page_size - 1) // page_size)
page = min(page, total_pages)
start = (page - 1) * page_size
end = start + page_size
return {
"ok": True,
"clients": clients[start:end],
"pagination": {
"page": page,
"pageSize": page_size,
"total": total,
"totalPages": total_pages,
},
}
@app.get("/owner/birthdays")
async def owner_birthdays(request: Request):
await _require_owner_email(request)
try:
page = max(1, int(request.query_params.get("page", "1")))
except ValueError:
page = 1
try:
page_size = min(30, max(1, int(request.query_params.get("page_size", "12"))))
except ValueError:
page_size = 12
today = datetime.now()
birthdays: list[dict[str, Any]] = []
for email, profile in _client_profiles.items():
if email == OWNER_EMAIL.strip().lower():
continue
if not profile.get("onboardingCompleted"):
continue
if not _client_is_reachable(profile):
continue
upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")), today)
if not upcoming:
continue
birthdays.append({
"email": email,
"fullName": profile.get("fullName", ""),
"dogName": profile.get("dogName", ""),
"dogBreed": profile.get("dogBreed", ""),
"dogAge": profile.get("dogAge", ""),
"birthdayLabel": upcoming.isoformat(),
"daysUntil": (upcoming - today.date()).days,
"birthdayAutoSend": bool(profile.get("birthdayAutoSend")),
"birthdayEmailLastSentAt": profile.get("birthdayEmailLastSentAt", ""),
})
birthdays.sort(
key=lambda item: (
item.get("daysUntil", 10**9),
item.get("dogName", "").lower(),
item.get("fullName", "").lower(),
),
)
total = len(birthdays)
total_pages = max(1, (total + page_size - 1) // page_size)
page = min(page, total_pages)
start = (page - 1) * page_size
end = start + page_size
return {
"ok": True,
"clients": birthdays[start:end],
"pagination": {
"page": page,
"pageSize": page_size,
"total": total,
"totalPages": total_pages,
},
}
@app.get("/owner/birthday-ics")
async def owner_birthday_ics(request: Request):
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
await _require_owner_email(request)
email = _trimmed(request.query_params.get("email", "")).lower()
if not email:
raise HTTPException(status_code=400, detail="Email is required.")
profile = _client_profiles.get(email, {})
if not profile or not profile.get("onboardingCompleted"):
raise HTTPException(status_code=404, detail="Completed client not found.")
attachment = _birthday_ics_attachment(
str(profile.get("dogName", "")),
str(profile.get("dogAge", "")),
str(profile.get("fullName", "")),
request_id,
)
if not attachment:
raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.")
content = base64.b64decode(attachment["content"])
return Response(
content=content,
media_type="text/calendar; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{attachment["filename"]}"'
},
)
@app.post("/owner/send-welcome-pack")
async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Request):
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
owner_email = await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email, {})
if not profile:
raise HTTPException(status_code=404, detail="Client profile not found.")
if profile.get("onboardingCompleted"):
raise HTTPException(status_code=400, detail="This client has already completed onboarding.")
if not _trimmed(data.serviceType):
raise HTTPException(status_code=400, detail="Please enter a service.")
if not _trimmed(data.priceDetails):
raise HTTPException(status_code=400, detail="Please enter the price details.")
if not _trimmed(data.startDate):
raise HTTPException(status_code=400, detail="Please enter a start date.")
owner_name = str(profile.get("fullName", "")).strip()
dog_name = str(profile.get("dogName", "")).strip()
sent_at = datetime.now().isoformat(timespec="seconds")
is_preview = bool(data.preview)
recipient = OWNER_EMAIL.strip().lower() if is_preview else email
subject = "Welcome to the pack | Goodwalk"
if is_preview:
subject = f"[PREVIEW for {owner_name or email}] {subject}"
payload = {
"from": FROM_EMAIL,
"to": [recipient],
"reply_to": REPLY_TO,
"subject": subject,
"html": _welcome_pack_email_html(owner_name, dog_name, _trimmed(data.serviceType), _trimmed(data.priceDetails), _trimmed(data.startDate)),
}
if CLIENT_BCC and not is_preview:
payload["bcc"] = [CLIENT_BCC]
try:
await _send_email(payload, label="welcome_pack_email_preview" if is_preview else "welcome_pack_email", request_id=request_id)
except Exception as exc:
logger.error("[%s] welcome pack email failed: %s", request_id, exc, exc_info=True)
raise HTTPException(
status_code=502,
detail={
"request_id": request_id,
"message": "The welcome email could not be sent. Please try again shortly.",
"error_type": type(exc).__name__,
},
)
if is_preview:
logger.info("[%s] welcome pack PREVIEW sent: original_recipient=%s -> owner", request_id, email)
return {"ok": True, "sentAt": sent_at, "preview": True}
await _store_client_profile(email, {
"welcomePackSentAt": sent_at,
"welcomePackOffer": {
"serviceType": _trimmed(data.serviceType),
"priceDetails": _trimmed(data.priceDetails),
"startDate": _trimmed(data.startDate),
"sentAt": sent_at,
},
})
logger.info("[%s] welcome pack sent: email=%s service=%s start=%s", request_id, email, data.serviceType, data.startDate)
await admin_db.record_event(
event_type="owner_welcome_pack_sent",
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
detail={
"recipient": email,
"serviceType": _trimmed(data.serviceType),
"startDate": _trimmed(data.startDate),
},
)
return {"ok": True, "sentAt": sent_at}
@app.post("/owner/send-birthday-email")
async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request):
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
owner_email = await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email, {})
if not profile or not profile.get("onboardingCompleted"):
raise HTTPException(status_code=404, detail="Completed client not found.")
if not _upcoming_birthday_date(str(profile.get("dogAge", ""))):
raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.")
try:
await _send_birthday_email_for_profile(email, profile, request_id, preview=bool(data.preview))
except Exception as exc:
logger.error("[%s] birthday email failed: %s", request_id, exc, exc_info=True)
raise HTTPException(
status_code=502,
detail={
"request_id": request_id,
"message": "The birthday email could not be sent. Please try again shortly.",
"error_type": type(exc).__name__,
},
)
await admin_db.record_event(
event_type="owner_birthday_email_sent",
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
detail={"recipient": email, "preview": bool(data.preview)},
)
return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": bool(data.preview)}
@app.post("/owner/client-status")
async def owner_client_status(data: ClientStatusUpdate, request: Request):
"""Set a client's lifecycle status (active / paused / cancelled / archived).
Soft-delete only: no client record is ever removed. Each change is recorded
in the profile's lifecycleHistory list and the global activity feed.
"""
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
owner_email = await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email)
if not profile:
raise HTTPException(status_code=404, detail="Client not found.")
reason = (data.reason or "").strip()[:500]
now_iso = datetime.now().isoformat(timespec="seconds")
existing_history = profile.get("lifecycleHistory")
history: list[dict[str, Any]] = list(existing_history) if isinstance(existing_history, list) else []
history.append({
"status": data.status,
"reason": reason,
"changedAt": now_iso,
"changedBy": owner_email,
})
# Cap history to a sensible size so the JSON file doesn't grow unbounded.
history = history[-50:]
lifecycle = {
"status": data.status,
"reason": reason,
"changedAt": now_iso,
"changedBy": owner_email,
}
await _store_client_profile(email, {
"lifecycle": lifecycle,
"lifecycleHistory": history,
})
await admin_db.record_event(
event_type="owner_client_status_changed",
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
detail={"clientEmail": email, "status": data.status, "reason": reason},
)
logger.info("[%s] owner: %s set %s -> %s", request_id, owner_email, email, data.status)
return {"ok": True, "email": email, "lifecycle": lifecycle}
@app.post("/owner/birthday-auto-send")
async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Request):
owner_email = await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email, {})
if not profile or not profile.get("onboardingCompleted"):
raise HTTPException(status_code=404, detail="Completed client not found.")
if not _upcoming_birthday_date(str(profile.get("dogAge", ""))):
raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.")
await _store_client_profile(email, {
"birthdayAutoSend": data.enabled,
})
await admin_db.record_event(
event_type="owner_birthday_auto_toggled",
actor_email=owner_email, ip=_get_ip(request), status="ok",
detail={"clientEmail": email, "enabled": bool(data.enabled)},
)
return {"ok": True, "enabled": data.enabled}
@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", ""))
if _is_deploy_smoke(request):
logger.info("[%s] /submit deploy-smoke bypass (no email, no db write)", request_id)
return {"ok": True, "request_id": request_id, "smoke": True}
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,
)
await admin_db.record_event(
event_type="booking_honeypot",
request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored",
detail={"page": 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,
)
# PII intentionally NOT logged here — payload contains submitter contact details.
logger.debug("[%s] booking payload keys=%s", request_id, sorted(data.model_dump().keys()))
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))
enquiry_at = datetime.now().isoformat(timespec="seconds")
await _store_client_profile(str(data.email), {
"fullName": data.fullName,
"phone": data.phone,
"dogName": data.petName,
"services": data.services,
"location": data.location,
"enquiryType": data.enquiryType,
"lastEnquiryAt": enquiry_at,
"lastEnquiry": {
"submittedAt": enquiry_at,
"enquiryType": data.enquiryType,
"fullName": data.fullName,
"email": str(data.email),
"phone": data.phone,
"petName": data.petName,
"location": data.location,
"services": data.services,
"message": data.message,
"referrer": data.referrer,
"page": data.page,
},
})
await admin_db.record_submission(
kind="booking",
email=str(data.email), full_name=data.fullName, phone=data.phone,
ip=ip, request_id=request_id, payload=data.model_dump(),
)
await admin_db.record_event(
event_type="booking_submitted",
request_id=request_id, actor_email=str(data.email), ip=ip,
status="partial" if failures else "ok",
detail={
"enquiryType": data.enquiryType,
"dog": data.petName,
"services": data.services,
"failures": [f["label"] for f in failures],
},
)
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;">
&#x1F4DC;&nbsp; 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", ""))
if _is_deploy_smoke(request):
logger.info("[%s] /onboarding-submit deploy-smoke bypass (no email, no db write)", request_id)
return {"ok": True, "request_id": request_id, "smoke": True}
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,
)
await admin_db.record_event(
event_type="onboarding_honeypot",
request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored",
detail={"page": 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,
)
# PII intentionally NOT logged here — payload contains address, vet, medical notes, signature.
logger.debug("[%s] onboarding payload keys=%s", request_id, sorted(data.model_dump().keys()))
owner_html = owner_onboarding_email(data, ip, browser)
owner_payload = {
"from": FROM_EMAIL,
"to": [OWNER_EMAIL],
"reply_to": data.email,
"subject": f"New GoodWalk onboarding — {data.fullName} ({data.dogName})",
"html": owner_html,
}
attachments: list[dict] = []
birthday_attachment = _birthday_ics_attachment(data.dogName, data.dogAge, data.fullName, request_id)
if birthday_attachment:
attachments.append(birthday_attachment)
if ONBOARDING_PDF_ATTACHMENT_ENABLED:
pdf_html = owner_onboarding_pdf_html(data)
pdf_attachment = await _signed_form_pdf_attachment(pdf_html, data.fullName, "onboarding", request_id)
if pdf_attachment:
attachments.append(pdf_attachment)
if attachments:
owner_payload["attachments"] = attachments
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,
"onboardingSubmittedAt": datetime.now().isoformat(timespec="seconds"),
"onboardingSubmission": data.submissionSnapshot,
})
client_payload = {
"from": FROM_EMAIL,
"to": [str(data.email)],
"reply_to": REPLY_TO,
"subject": f"Your Goodwalk onboarding is complete, {data.fullName.split()[0]}",
"html": _onboarding_confirmation_email_html(data),
}
if CLIENT_BCC:
client_payload["bcc"] = [CLIENT_BCC]
try:
await _send_email(
client_payload,
label="client_onboarding_confirmation_email",
request_id=request_id,
)
except Exception as exc:
logger.error(
"[%s] client onboarding confirmation email failed: %s",
request_id,
exc,
exc_info=True,
)
await admin_db.record_submission(
kind="onboarding",
email=str(data.email), full_name=data.fullName, phone=data.phone,
ip=ip, request_id=request_id, payload=data.model_dump(),
)
await admin_db.record_event(
event_type="onboarding_submitted",
request_id=request_id, actor_email=str(data.email), ip=ip, status="ok",
detail={"dog": data.dogName, "services": data.servicesNeeded},
)
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", ""))
if _is_deploy_smoke(request):
logger.info("[%s] /contract-submit deploy-smoke bypass (no email, no db write)", request_id)
return {"ok": True, "request_id": request_id, "smoke": True}
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,
)
await admin_db.record_event(
event_type="contract_honeypot",
request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored",
detail={"page": 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_html = owner_contract_email(data, ip, browser)
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_html,
}
if CONTRACT_PDF_ATTACHMENT_ENABLED:
pdf_attachment = await _signed_form_pdf_attachment(owner_html, data.fullName, "contract", request_id)
if pdf_attachment:
owner_payload["attachments"] = [pdf_attachment]
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,
})
await admin_db.record_submission(
kind="contract",
email=str(data.email), full_name=data.fullName, phone=data.phone,
ip=ip, request_id=request_id, payload=data.model_dump(),
)
await admin_db.record_event(
event_type="contract_submitted",
request_id=request_id, actor_email=str(data.email), ip=ip, status="ok",
detail={"dog": data.dogName, "service": data.serviceType, "startDate": data.startDate},
)
return {"ok": True, "request_id": request_id}