import asyncio import base64 from collections import deque from contextlib import asynccontextmanager import html from html.parser import HTMLParser 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 import myob 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, SCHEDULED_CHECK_INTERVAL_SECONDS, SCHEDULED_EMAILS_FILE as _SCHEDULED_EMAILS_FILE, STARTUP_TEST_RECIPIENT, TRUSTED_HOSTS, logger, ) from mail_api.models import ( BaseSubmission, BirthdayAutoSendRequest, BirthdayEmailRequest, ClientDogUpsertRequest, ClientProfileUpdate, BookingSubmission, ClientStatusUpdate, ContractSubmission, DeleteClientRequest, NewClientRequest, OnboardingSubmission, RenderMessageRequest, ResetOnboardingRequest, ScheduledEmailCancelRequest, ScheduledEmailRescheduleRequest, 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) def _load_scheduled_emails_from_file() -> dict[str, dict]: try: if _SCHEDULED_EMAILS_FILE.exists(): data = json.loads(_SCHEDULED_EMAILS_FILE.read_text(encoding="utf-8")) if isinstance(data, dict): return data except Exception as exc: logger.warning("Could not load scheduled_emails file: %s", exc) return {} def _save_scheduled_emails_file(scheduled: dict) -> None: try: _write_pii_json(_SCHEDULED_EMAILS_FILE, scheduled) except Exception as exc: logger.warning("Could not save scheduled_emails 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() async def _load_scheduled_emails_async() -> dict[str, dict]: if admin_db.is_enabled(): data = await admin_db.get_kv("scheduled_emails") if isinstance(data, dict): return data return _load_scheduled_emails_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 # Durable queue of owner-scheduled emails, keyed by id. Each entry is a dict # (see _enqueue_scheduled_welcome). Persisted to admin_kv / JSON via # _persist_scheduled_emails so queued sends survive a container restart. _scheduled_emails: dict[str, dict] = _load_scheduled_emails_from_file() _scheduled_task: asyncio.Task | None = None _scheduled_lock = asyncio.Lock() 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"} def _dog_slug(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", (value or "").strip().lower()).strip("-") return slug or f"dog-{uuid.uuid4().hex[:8]}" def _normalize_profile_dog_entry(raw: Any) -> dict[str, Any] | None: if not isinstance(raw, dict): return None name = str(raw.get("name") or raw.get("dogName") or "").strip() breed = str(raw.get("breed") or raw.get("dogBreed") or "").strip() birth_date = str(raw.get("birthDate") or raw.get("dogAge") or "").strip() if not name and not breed and not birth_date: return None dog_id = str(raw.get("id") or raw.get("dogId") or "").strip() or _dog_slug(name or breed or birth_date) return { "id": dog_id, "name": name, "breed": breed, "birthDate": birth_date, "birthdayAutoSend": bool(raw.get("birthdayAutoSend")), "birthdayEmailLastSentAt": str(raw.get("birthdayEmailLastSentAt") or "").strip(), "birthdayEmailLastSentYear": str(raw.get("birthdayEmailLastSentYear") or "").strip(), } def _get_profile_dogs(profile: dict[str, Any]) -> list[dict[str, Any]]: raw_dogs = profile.get("dogs") dogs: list[dict[str, Any]] = [] seen_ids: set[str] = set() if isinstance(raw_dogs, list): for raw in raw_dogs: dog = _normalize_profile_dog_entry(raw) if not dog or dog["id"] in seen_ids: continue seen_ids.add(dog["id"]) dogs.append(dog) if dogs: return dogs legacy = _normalize_profile_dog_entry( { "id": "primary", "name": profile.get("dogName", ""), "breed": profile.get("dogBreed", ""), "birthDate": profile.get("dogAge", ""), "birthdayAutoSend": profile.get("birthdayAutoSend", False), "birthdayEmailLastSentAt": profile.get("birthdayEmailLastSentAt", ""), "birthdayEmailLastSentYear": profile.get("birthdayEmailLastSentYear", ""), } ) return [legacy] if legacy else [] def _apply_profile_dogs(profile: dict[str, Any], dogs: list[dict[str, Any]]) -> dict[str, Any]: next_profile = dict(profile) if dogs: normalized_dogs = [] seen_ids: set[str] = set() for raw in dogs: dog = _normalize_profile_dog_entry(raw) if not dog or dog["id"] in seen_ids: continue seen_ids.add(dog["id"]) normalized_dogs.append(dog) dogs = normalized_dogs if dogs: primary = dogs[0] next_profile["dogs"] = dogs next_profile["dogName"] = primary.get("name", "") next_profile["dogBreed"] = primary.get("breed", "") next_profile["dogAge"] = primary.get("birthDate", "") next_profile["birthdayAutoSend"] = bool(primary.get("birthdayAutoSend")) next_profile["birthdayEmailLastSentAt"] = primary.get("birthdayEmailLastSentAt", "") next_profile["birthdayEmailLastSentYear"] = primary.get("birthdayEmailLastSentYear", "") else: for key in ("dogs", "dogName", "dogBreed", "dogAge", "birthdayAutoSend", "birthdayEmailLastSentAt", "birthdayEmailLastSentYear"): next_profile.pop(key, None) return next_profile def _merge_client_profile(existing: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]: merged = { k: v for k, v in {**existing, **updates}.items() if v is not None and not (isinstance(v, str) and v == "") } if "dogs" in updates: return _apply_profile_dogs(merged, updates.get("dogs") if isinstance(updates.get("dogs"), list) else []) dog_related_keys = {"dogName", "dogBreed", "dogAge", "birthdayAutoSend", "birthdayEmailLastSentAt", "birthdayEmailLastSentYear"} if dog_related_keys.intersection(updates.keys()): dogs = _get_profile_dogs(existing) primary = dict(dogs[0]) if dogs else {"id": "primary"} primary["name"] = str(merged.get("dogName", "")).strip() primary["breed"] = str(merged.get("dogBreed", "")).strip() primary["birthDate"] = str(merged.get("dogAge", "")).strip() primary["birthdayAutoSend"] = bool(merged.get("birthdayAutoSend")) primary["birthdayEmailLastSentAt"] = str(merged.get("birthdayEmailLastSentAt", "")).strip() primary["birthdayEmailLastSentYear"] = str(merged.get("birthdayEmailLastSentYear", "")).strip() next_dogs = [primary] + dogs[1:] if dogs else [primary] return _apply_profile_dogs(merged, next_dogs) return _apply_profile_dogs(merged, _get_profile_dogs(merged)) if _get_profile_dogs(merged) else merged def _get_selected_dog(profile: dict[str, Any], dog_id: str | None) -> dict[str, Any] | None: dogs = _get_profile_dogs(profile) if not dogs: return None if not dog_id: return dogs[0] for dog in dogs: if dog.get("id") == dog_id: return dog return None 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 = _merge_client_profile(existing, profile) 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) async def _persist_scheduled_emails() -> None: """Write the scheduled-email queue to the JSON file + postgres. Callers must hold _scheduled_lock so the snapshot is internally consistent.""" snapshot = dict(_scheduled_emails) await asyncio.to_thread(_save_scheduled_emails_file, snapshot) await _persist_admin_state("scheduled_emails", snapshot) def _parse_schedule_datetime(value: str) -> datetime: """Parse an ISO 8601 datetime from the owner UI (datetime-local sends 'YYYY-MM-DDTHH:MM'). Returns a naive local datetime to match the rest of the app, which compares against datetime.now(). Raises HTTPException(400) on anything unparseable.""" raw = (value or "").strip() if not raw: raise HTTPException(status_code=400, detail="Please choose a date and time to schedule the email.") try: parsed = datetime.fromisoformat(raw) except ValueError: raise HTTPException(status_code=400, detail="That schedule time could not be understood.") # Drop any timezone so comparisons against datetime.now() (naive) are valid. if parsed.tzinfo is not None: parsed = parsed.astimezone().replace(tzinfo=None) return parsed async def _update_client_profile( email: str, next_email: str, profile_updates: dict[str, Any], ) -> dict[str, Any]: normalized = email.strip().lower() next_normalized = next_email.strip().lower() if not normalized or not next_normalized: raise HTTPException(status_code=400, detail="A valid email is required.") async with _auth_lock: existing = _client_profiles.get(normalized) if not isinstance(existing, dict): raise HTTPException(status_code=404, detail="Client not found.") target_existing = _client_profiles.get(next_normalized) if next_normalized != normalized and isinstance(target_existing, dict): raise HTTPException(status_code=409, detail="Another client already uses that email address.") merged = _merge_client_profile(existing, profile_updates) if next_normalized != normalized: _client_profiles.pop(normalized, None) _client_profiles[next_normalized] = merged draft = _drafts.pop(normalized, None) if draft is not None: _drafts[next_normalized] = draft pending = _pending_codes.pop(normalized, None) if pending is not None: _pending_codes[next_normalized] = pending code_requests = _code_requests.pop(normalized, None) if code_requests is not None: _code_requests[next_normalized] = code_requests for session in _active_sessions.values(): if isinstance(session, dict) and session.get("email") == normalized: session["email"] = next_normalized if normalized != OWNER_EMAIL.strip().lower(): _allowed_emails.discard(normalized) _allowed_emails.add(next_normalized) else: _client_profiles[normalized] = merged profiles_snapshot = dict(_client_profiles) drafts_snapshot = dict(_drafts) allowed_emails_snapshot = sorted(_allowed_emails) await asyncio.to_thread(_save_client_profiles_file, profiles_snapshot) await asyncio.to_thread(_save_drafts_file, drafts_snapshot) await asyncio.to_thread(_save_allowed_emails_file, set(allowed_emails_snapshot)) await _persist_admin_state("client_profiles", profiles_snapshot) await _persist_admin_state("drafts", drafts_snapshot) await _persist_admin_state("allowed_emails", {"emails": allowed_emails_snapshot}) await _save_active_sessions_async() return merged async def _delete_client_profile(email: str) -> dict[str, Any]: """Permanently remove a client: their profile, saved draft, any pending login codes/sessions, their allowed-email entry (so they can no longer sign in), and any welcome emails still queued to send to them. The business owner's own account can never be deleted.""" normalized = email.strip().lower() if not normalized: raise HTTPException(status_code=400, detail="A valid email is required.") if normalized == OWNER_EMAIL.strip().lower(): raise HTTPException(status_code=400, detail="The business owner account cannot be deleted.") async with _auth_lock: if normalized not in _client_profiles: raise HTTPException(status_code=404, detail="Client not found.") removed = _client_profiles.pop(normalized, {}) _drafts.pop(normalized, None) _pending_codes.pop(normalized, None) _code_requests.pop(normalized, None) for token in [t for t, s in _active_sessions.items() if isinstance(s, dict) and s.get("email") == normalized]: _active_sessions.pop(token, None) _allowed_emails.discard(normalized) profiles_snapshot = dict(_client_profiles) drafts_snapshot = dict(_drafts) allowed_emails_snapshot = sorted(_allowed_emails) await asyncio.to_thread(_save_client_profiles_file, profiles_snapshot) await asyncio.to_thread(_save_drafts_file, drafts_snapshot) await asyncio.to_thread(_save_allowed_emails_file, set(allowed_emails_snapshot)) await _persist_admin_state("client_profiles", profiles_snapshot) await _persist_admin_state("drafts", drafts_snapshot) await _persist_admin_state("allowed_emails", {"emails": allowed_emails_snapshot}) await _save_active_sessions_async() # Drop any scheduled welcome emails still queued for this address. async with _scheduled_lock: stale = [eid for eid, entry in _scheduled_emails.items() if isinstance(entry, dict) and str(entry.get("email", "")).strip().lower() == normalized] for eid in stale: _scheduled_emails.pop(eid, None) if stale: await _persist_scheduled_emails() return removed if isinstance(removed, dict) else {} async def _reset_client_onboarding(email: str) -> dict[str, Any]: """Mark a client's onboarding as incomplete while keeping every saved detail. Used for clients imported from the legacy Gravity Forms data: they keep their contact/dog details (and their previous submission is archived for reference), but they drop back into the pending list so they can sign in with their email and complete the new onboarding form. """ normalized = email.strip().lower() if not normalized: raise HTTPException(status_code=400, detail="A valid email is required.") async with _auth_lock: existing = _client_profiles.get(normalized) if not isinstance(existing, dict): raise HTTPException(status_code=404, detail="Client not found.") updated = dict(existing) updated["onboardingCompleted"] = False # Archive the prior completion so nothing is lost, then clear the live # markers that keep them out of the pending / onboarding views. previous_submitted = updated.pop("onboardingSubmittedAt", "") previous_submission = updated.pop("onboardingSubmission", None) if previous_submitted: updated["previousOnboardingSubmittedAt"] = previous_submitted if previous_submission is not None: updated["previousOnboardingSubmission"] = previous_submission updated["onboardingResetAt"] = datetime.now().isoformat(timespec="seconds") _client_profiles[normalized] = updated snapshot = dict(_client_profiles) await asyncio.to_thread(_save_client_profiles_file, snapshot) await _persist_admin_state("client_profiles", snapshot) return updated 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.", "dogAge": "Please enter your dog's date of birth.", "vetName": "Please enter your vet clinic name.", "vetAddress": "Please enter your vet address.", "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 data.regularFleaTickTreatment not in {"yes", "no"}: raise HTTPException(status_code=400, detail="Please confirm whether your dog gets regular flea and tick treatment.") if data.petInsurance not in {"yes", "no"}: raise HTTPException(status_code=400, detail="Please confirm whether your dog has pet insurance.") if data.petInsurance == "no" and not data.petInsuranceOwnerExpenseAccepted: raise HTTPException(status_code=400, detail="Please confirm the owner-expense acknowledgement for dogs without pet insurance.") # Council registration and vaccination are no longer hard blockers: the owner # reviews these on the submission and follows up with the client separately. # The actual yes/no answers are preserved on the snapshot and surfaced below. 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.vetAddress = _trimmed(data.vetAddress) data.vetPhone = _trimmed(data.vetPhone) data.emergencyContactName = _trimmed(data.emergencyContactName) data.emergencyContactPhone = _trimmed(data.emergencyContactPhone) data.regularFleaTickTreatment = _trimmed(data.regularFleaTickTreatment).lower() data.petInsurance = _trimmed(data.petInsurance).lower() 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""" {label} {value} """ def _meta_row(label: str, value: str) -> str: if not value: return "" return f""" {label} {value} """ 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'
{badge_html}
' if badge_html else "" sub = f"""
{subtitle}
""" if subtitle else "" return f""" GoodWalk {sub} {badge} """ def client_email(data: BookingSubmission) -> str: is_general = _is_general_enquiry(data) services_text = ", ".join(data.services) if data.services else "Not specified" enquiry_summary_rows = [ _detail_row("Your name", data.fullName), _detail_row("Email", str(data.email)), _detail_row("Phone", data.phone), _detail_row("Type", _enquiry_type_label(data)), ] if is_general: if data.message: enquiry_summary_rows.append(_detail_row("Message", data.message)) intro_html = ( "We’ve received your message and we will be in touch shortly." ) next_steps_html = ( "We will review your message and reply within 1 business day." ) logo_subtitle = "General enquiries and dog walking support" else: enquiry_summary_rows.extend( [ _detail_row("Dog’s name", data.petName), _detail_row("Location", data.location), _detail_row("Services", services_text), ] ) if data.message: enquiry_summary_rows.append(_detail_row("About the dog", data.message)) intro_html = ( "We’ve received your enquiry and we will be in touch shortly to arrange " "a Meet & Greet with you and " f"{data.petName}." ) next_steps_html = ( "We will review your details and reach out within 1 business day " "to schedule a free Meet & Greet. No commitment required — just a " f"chance for {data.petName} to make a new best friend." ) logo_subtitle = "Professional dog walking services" return f""" We received your enquiry
{_logo_header(subtitle=logo_subtitle)}

Thanks, {data.fullName.split()[0]}! 🐾

{intro_html}

Your enquiry summary
{"".join(enquiry_summary_rows)}
What happens next?
{next_steps_html}

Questions? Just reply to this email or reach us at 022 642 1011.

GoodWalk · Auckland, New Zealand
goodwalk.co.nz
""" 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"""
{message_label}
{data.message}
""" if data.message else "" badge = """
📩  New enquiry!
Submitted {submitted_at}
""".format(submitted_at=submitted_at) referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark") page_row = _meta_row("Page", data.page) if data.page else "" visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt)) page_time_row = _meta_row("Time on page", _duration_between(data.pageEnteredAt, data.sendClickedAt)) active_time_row = _meta_row("Active form time", _duration_between(data.firstInteractionAt, data.sendClickedAt)) form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt)) step_changes_row = _meta_row("Step changes", str(data.stepChanges)) if data.stepChanges else "" journey_row = _meta_row("Journey", _journey_text(data.journey)) detail_heading = "Enquiry details" if is_general else "Dog & services" detail_rows = [_detail_row("Type", _enquiry_type_label(data))] if is_general: if data.petName: detail_rows.append(_detail_row("Dog", data.petName)) if data.location: detail_rows.append(_detail_row("Location", data.location)) else: detail_rows.extend( [ _detail_row("Dog", data.petName), _detail_row("Location", data.location), _detail_row("Services", services_text), ] ) return f""" {email_title}
{_logo_header(badge_html=badge)}
""" 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"""
Temperament and routine
{data.temperament}
""" if data.temperament else "" medical_block = f"""
Medical notes
{data.medicalNotes}
""" if data.medicalNotes else "" access_block = f"""
Home access instructions
{data.accessInstructions}
""" if data.accessInstructions else "" signature_block = f"""
Captured signature
Client signature
""" badge = f"""
✍  New onboarding form
Submitted {submitted_at}
""" return f""" New GoodWalk onboarding form
{_logo_header(badge_html=badge, subtitle="Signed onboarding form")}
Quick contact
Reply directly to the owner or call them back:
Call {data.phone}
Owner details
{_detail_row("Name", data.fullName)} {_detail_row("Email", str(data.email))} {_detail_row("Phone", data.phone)} {_detail_row("Address", data.address)} {access_block}
Dog and service details
{_detail_row("Dog", data.dogName)} {_detail_row("Breed", data.dogBreed)} {_detail_row("Date of birth", data.dogAge or "—")} {_detail_row("Service", services_text)} {dog_notes_block} {medical_block}
Safety details
{_detail_row("Vet clinic", data.vetName)} {_detail_row("Vet phone", data.vetPhone)} {_detail_row("Vet address", data.vetAddress)} {_detail_row("Emergency contact", data.emergencyContactName)} {_detail_row("Emergency phone", data.emergencyContactPhone)} {_detail_row("Regular flea and tick treatment", "Yes" if data.regularFleaTickTreatment == "yes" else "No")} {_detail_row("Pet insurance", "Yes" if data.petInsurance == "yes" else "No")} {_detail_row("Owner covers all vet costs", "Confirmed") if data.petInsurance == "no" and data.petInsuranceOwnerExpenseAccepted else ""} {_detail_row("Council registration", "Confirmed" if data.councilRegistrationConfirmed else "Not confirmed — follow up")} {_detail_row("Vaccinations", "Confirmed" if data.vaccinationsConfirmed else "Not confirmed — follow up")} {_detail_row("Emergency consent", "Confirmed")} {_detail_row("Declaration", "Signed")}
{signature_block}
Session info
{_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}
""" 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("&", "&") .replace("<", "<") .replace(">", ">") .replace("\n", "
") ) 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 "—" text = _pdf_escape(value).strip() return text if text else "—" 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 += ( "" f"{label}" f"{render_value(field.get('value'))}" "" ) if rows_html: sections_html_parts.append( f"
" f"

{title}

" f"{rows_html}
" f"
" ) else: # Fallback if snapshot is missing — render the core fields directly. def row(label: str, value: Any) -> str: return f"{_pdf_escape(label)}{render_value(value)}" owner_rows = ( row("Name", data.fullName) + row("Email", str(data.email)) + row("Phone", data.phone) + row("Address", data.address) + row("Home access", data.accessInstructions) ) 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) ) safety_rows = ( row("Vet clinic", data.vetName) + row("Vet phone", data.vetPhone) + row("Vet address", data.vetAddress) + row("Emergency contact", data.emergencyContactName) + row("Emergency phone", data.emergencyContactPhone) + row("Regular flea and tick treatment", "Yes" if data.regularFleaTickTreatment == "yes" else "No") + row("Pet insurance", "Yes" if data.petInsurance == "yes" else "No") + row("Owner covers all vet costs", "Confirmed" if data.petInsurance == "no" and data.petInsuranceOwnerExpenseAccepted else "") + 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"

Owner Details

{owner_rows}
" f"

Dog Details

{dog_rows}
" f"

Safety

{safety_rows}
" ) signature_html = "" if data.signatureDataUrl: signature_html = ( "
" "

Signature

" f"Client signature" f"
Signed by {_pdf_escape(data.fullName)} on {_pdf_escape(submitted_at)}
" "
" ) body_html = "".join(sections_html_parts) + signature_html return f""" Goodwalk onboarding form — {_pdf_escape(data.fullName)}

Goodwalk Onboarding Form

{_pdf_escape(data.fullName)} · {_pdf_escape(data.dogName)} · Submitted {_pdf_escape(submitted_at)}
{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 ───────────────────────────────────────────────────── def _client_bcc_list() -> list[str]: """BCC recipients for any real email sent home to a client (welcome pack, birthday, enquiry reply, onboarding confirmation, ...). The business owner is always copied so they can see exactly what each client received; CLIENT_BCC adds an optional extra inbox when configured. The OWNER_BCC placeholder is treated as unset.""" out: list[str] = [] for addr in (OWNER_EMAIL, CLIENT_BCC): a = (addr or "").strip() if not a or a.lower() == "example@example.com": continue if a.lower() not in {x.lower() for x in out}: out.append(a) return out 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, "ok") 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, _scheduled_task, _allowed_emails, _client_profiles, _drafts, _scheduled_emails # 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) db_scheduled = await _load_scheduled_emails_async() if isinstance(db_scheduled, dict): _scheduled_emails = db_scheduled logger.info( "Admin state refreshed from postgres: clients=%d emails=%d drafts=%d sessions=%d scheduled=%d", len(_client_profiles), len(_allowed_emails), len(_drafts), len(_active_sessions), len(_scheduled_emails), ) 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()) if _scheduled_task is None or _scheduled_task.done(): _scheduled_task = asyncio.create_task(_scheduled_sender_loop()) async def _shutdown_background_tasks() -> None: global _birthday_auto_task, _scheduled_task for task_name in ("_birthday_auto_task", "_scheduled_task"): task = globals().get(task_name) if task is not None: task.cancel() try: await task except asyncio.CancelledError: pass globals()[task_name] = None @app.get("/health") async def health() -> dict: return {"status": "ok"} def _auth_code_email(email: str, code: str) -> str: return f""" Your Goodwalk login code
Goodwalk
Your login code
{code}

Enter this code on the Goodwalk onboarding page.

This code expires in {AUTH_CODE_TTL_SECONDS // 60} minutes. If you didn’t request this, you can safely ignore it.

Goodwalk · Auckland, New Zealand
""" 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 WELCOME_FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" WELCOME_CTA_DEFAULT_LABEL = "Complete onboarding" WELCOME_CTA_DEFAULT_URL = "https://clients.goodwalk.co.nz/" # Canonical business contact details, kept in step with the public site footer # (src/lib/content/homepage.ts). These power the copyright + contact block at the # bottom of every email the owner sends. BUSINESS_EMAIL = "info@goodwalk.co.nz" BUSINESS_PHONE = "(022) 642 1011" BUSINESS_PHONE_TEL = "0226421011" def _email_footer_html( *, text_color: str = "#ffffff", link_color: str = "#ffffff", font_stack: str = WELCOME_FONT_STACK, ) -> str: """Shared email footer: a simple copyright notice plus the business contact details. White text by default so it reads cleanly on the dark-green band.""" year = datetime.now().year return ( f'
' f'© {year} Goodwalk
' f'{BUSINESS_EMAIL}' f' · ' f'{BUSINESS_PHONE}' f'
' ) def _apply_welcome_tokens(text: str, first_name: str, dog_name: str) -> str: """Substitute the personalisation tokens the owner can use in custom copy.""" dog = dog_name or "your dog" return ( text.replace("{first_name}", first_name) .replace("{firstName}", first_name) .replace("{dog_name}", dog) .replace("{dogName}", dog) .replace("{dog}", dog) ) def _welcome_paragraphs(text: str, color: str) -> str: """Render owner body text as one or more email-safe paragraphs.""" parts = [p.strip() for p in (text or "").split("\n\n") if p.strip()] return "".join( f'

{_html_breaks(para)}

' for para in parts ) def _html_breaks(text: str) -> str: """Convert single newlines within a paragraph to
(text is already trusted owner copy).""" return text.replace("\n", "
") # Inline styles applied to the whitelisted tags the WYSIWYG editor can produce, # so the owner's message renders consistently across email clients. _WELCOME_BODY_STYLES = { "p": f"margin:0 0 16px;font-family:{WELCOME_FONT_STACK};font-size:15px;line-height:1.7;color:#4b584b;", "h1": f"margin:0 0 12px;font-family:{WELCOME_FONT_STACK};font-size:26px;line-height:1.2;letter-spacing:-0.02em;font-weight:700;color:#171b20;", "h2": f"margin:20px 0 10px;font-family:{WELCOME_FONT_STACK};font-size:20px;line-height:1.25;letter-spacing:-0.01em;font-weight:700;color:#171b20;", "h3": f"margin:18px 0 8px;font-family:{WELCOME_FONT_STACK};font-size:16px;line-height:1.3;font-weight:700;color:#213021;", "ul": "margin:0 0 16px;padding-left:22px;", "ol": "margin:0 0 16px;padding-left:22px;", "li": f"margin:0 0 6px;font-family:{WELCOME_FONT_STACK};font-size:15px;line-height:1.6;color:#4b584b;", "strong": "font-weight:700;color:#171b20;", "em": "font-style:italic;", "u": "text-decoration:underline;", "a": "color:#213021;text-decoration:underline;", } # Tags browsers emit that we fold onto our canonical equivalents. _WELCOME_TAG_ALIASES = {"b": "strong", "i": "em", "div": "p"} # Block tags that establish a paragraph context (so stray text gets wrapped). _WELCOME_BLOCK_TAGS = {"p", "li", "h1", "h2", "h3"} class _WelcomeBodySanitizer(HTMLParser): """Reduce the contenteditable HTML the owner produces to a small, email-safe subset (paragraphs, bold/italic/underline, lists and http(s)/mailto links), re-emitting every tag with our inline styles and dropping everything else.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.parts: list[str] = [] self.open_tags: list[str] = [] def _in_block(self) -> bool: return any(t in _WELCOME_BLOCK_TAGS for t in self.open_tags) def _in_list(self) -> bool: return any(t in ("ul", "ol") for t in self.open_tags) def _emit_open(self, tag: str) -> None: style = _WELCOME_BODY_STYLES.get(tag, "") self.parts.append(f'<{tag} style="{style}">' if style else f"<{tag}>") self.open_tags.append(tag) def _emit_close(self, tag: str) -> None: if tag not in self.open_tags: return while self.open_tags: current = self.open_tags.pop() self.parts.append(f"") if current == tag: break def _ensure_paragraph(self) -> None: if not self._in_block() and not self._in_list(): self._emit_open("p") def handle_starttag(self, tag, attrs): tag = _WELCOME_TAG_ALIASES.get(tag, tag) if tag == "br": self._ensure_paragraph() self.parts.append("
") return if tag in ("p", "ul", "ol", "h1", "h2", "h3"): # A new block ends any paragraph/heading we were implicitly inside. if self._in_block(): for candidate in ("p", "li", "h1", "h2", "h3"): if candidate in self.open_tags: self._emit_close(candidate) break self._emit_open(tag) return if tag == "li": self._emit_open("li") return if tag in ("strong", "em", "u"): self._ensure_paragraph() self._emit_open(tag) return if tag == "a": href = "" for name, value in attrs: if name == "href" and value: href = value.strip() if not re.match(r"^(https?:|mailto:)", href, re.IGNORECASE): return # drop unsafe/relative links, keep their text self._ensure_paragraph() self.parts.append(f'') self.open_tags.append("a") return # Any other tag (span, font, h1…): ignored, but its text is kept. def handle_endtag(self, tag): tag = _WELCOME_TAG_ALIASES.get(tag, tag) if tag in ("p", "ul", "ol", "li", "strong", "em", "u", "a", "h1", "h2", "h3"): self._emit_close(tag) def handle_data(self, data): if not data.strip() and not self._in_block(): return # ignore whitespace between block tags self._ensure_paragraph() self.parts.append( data.replace("&", "&").replace("<", "<").replace(">", ">") ) def result(self) -> str: while self.open_tags: self.parts.append(f"") html = "".join(self.parts) # Drop empty paragraphs the editor leaves behind (e.g. trailing blank line). html = re.sub(r"]*>(?:\s|
| )*

", "", html) return html.strip() def _sanitize_welcome_body_html(raw: str) -> str: """Public entry point: parse and re-emit owner rich-text as email-safe HTML.""" if not (raw or "").strip(): return "" parser = _WelcomeBodySanitizer() parser.feed(raw) parser.close() return parser.result() def _strip_legacy_welcome_tag_html(raw: str) -> str: """Remove the old frontend-injected yellow tag block if it was saved into bodyHtml before tagLabel became a first-class server-rendered field.""" if not (raw or "").strip(): return "" return re.sub( r'^\s*]*>\s*]*background:\s*#f4e7a8;[^>]*>.*?\s*\s*', '', raw, flags=re.IGNORECASE | re.DOTALL, ).strip() def _welcome_cta_html(label: str, url: str) -> str: """The dark-green pill button used at the foot of the welcome email.""" label = (label or "").strip() url = (url or "").strip() if not (label and url): return "" safe_label = label.replace("&", "&").replace("<", "<").replace(">", ">") return ( f'
{safe_label}' ) def _welcome_pack_email_html( client_name: str, dog_name: str, service_type: str, price_details: str, start_date: str, *, heading: str = "", intro: str = "", outro: str = "", tag_label: str = "", cta_label: str = WELCOME_CTA_DEFAULT_LABEL, cta_url: str = WELCOME_CTA_DEFAULT_URL, show_details: bool = True, body_html: str = "", include_button: bool = True, ) -> 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) # Preferred path: the owner wrote a free-form message in the WYSIWYG editor. # We sanitise it, substitute the personalisation tokens, and (optionally) add # the onboarding button beneath it — no forced heading or details table. clean_body = _sanitize_welcome_body_html(_strip_legacy_welcome_tag_html(body_html)) if clean_body: clean_body = _apply_welcome_tokens(clean_body, first_name, dog_name) # The "Badge" field drives the single header badge in the shell — it is not a # second in-body pill, so the email never shows the badge text twice. badge_label = _apply_welcome_tokens((tag_label or "").strip(), first_name, dog_name) cta_html = _welcome_cta_html(WELCOME_CTA_DEFAULT_LABEL, WELCOME_CTA_DEFAULT_URL) if include_button else "" # Only add a top margin when there's a badge to sit beneath; otherwise the # cell's own top padding is enough and a top margin would leave an empty # gap where the badge would have been. body_top_margin = "16px" if badge_label else "0" content_inner = f'
{clean_body}
{cta_html}' return _welcome_pack_shell(content_inner, tag_label=badge_label) # No message written and no legacy structured copy supplied: render just the # email chrome (logo + badge) so the live preview stays genuinely empty until # the owner writes something, rather than falling back to canned default copy. if not any(((heading or "").strip(), (intro or "").strip(), (outro or "").strip())): badge_label = _apply_welcome_tokens((tag_label or "").strip(), first_name, dog_name) return _welcome_pack_shell("", tag_label=badge_label) # Legacy structured path: each block falls back to the original default copy # when blank, so older callers that pass heading/intro/outro look unchanged. heading_text = _apply_welcome_tokens( (heading or "").strip() or f"Hi {first_name}, we’d love to get {dog_name or 'your dog'} started with Goodwalk.", first_name, dog_name, ) intro_text = _apply_welcome_tokens( (intro or "").strip() or ( f"We’ve set aside the details below{dog_line}. When you’re ready, " "complete your onboarding form and we’ll take it from there." ), first_name, dog_name, ) outro_text = _apply_welcome_tokens( (outro or "").strip() or ( "Use the same email address you originally used with Goodwalk. " "We’ll send you a one-time code when you sign in." ), first_name, dog_name, ) intro_html = _welcome_paragraphs(intro_text, "#4b584b") outro_html = "" if outro_text: outro_html = ( f'

{_html_breaks(outro_text)}

' ) # The details table is optional — when hidden, or when every row is blank, it # is dropped entirely so a free-text email reads cleanly. details_rows = "" if show_details: details_rows = ( _detail_row("Service", service_type) + _detail_row("Price", price_details) + _detail_row("Start date", formatted_start_date if start_date.strip() else "") ) details_html = "" if details_rows: details_html = f"""
{details_rows}
""" # The button block keeps the welcome email's dark-green pill styling but lets # the owner relabel it and point it anywhere. Clearing either field hides it. # The standard default is materialised by callers (see _welcome_pack_payload), # so an explicit blank here genuinely means "no button". label = (cta_label or "").strip() url = (cta_url or "").strip() cta_html = "" if label and url: safe_url = _escape_attr(url) safe_label = label.replace("&", "&").replace("<", "<").replace(">", ">") cta_html = ( f'{safe_label}' ) # The legacy structured path never renders a header badge, so the heading # has no top margin — the cell's top padding alone sets the spacing. heading_html = ( f'

{heading_text}

' ) content_inner = f"{heading_html}{intro_html}{details_html}{cta_html}{outro_html}" return _welcome_pack_shell(content_inner) def _welcome_pack_shell(content_inner: str, *, tag_label: str = "") -> str: """The shared welcome-email chrome (logo header, optional header badge, rounded card) wrapped around whatever message content is supplied. The badge text is owner-editable via the "Badge" field; when it is left blank, no badge is shown. This is the email's only tag — there is no second in-body pill.""" badge_label = (tag_label or "").strip() badge_html = ( '
' f'{html.escape(badge_label)}
' if badge_label else "" ) return f""" Welcome to the pack
Goodwalk
{badge_html} {content_inner}
{_email_footer_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 = ( '
Submitted
" ) return f""" Your onboarding has been submitted
{_logo_header(badge_html=badge_html, subtitle="Your onboarding details are safely with us")}

Thanks, {first_name}. Your onboarding is complete.

We’ve received your details{f" for {dog_name}" if dog_name else ""} and they’re now on file with Goodwalk. You can sign back in any time to review what you submitted.

Snapshot
Owner
{data.fullName}
Dog
{dog_name or 'Details submitted'}
Services
{service_summary}
What happens next?
We’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.
Review your submission

Your submitted form is read-only after completion. If anything needs changing, just reply to this email or contact us directly.

Goodwalk · Auckland, New Zealand
""" 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""" Happy birthday from Goodwalk
Goodwalk
Happy birthday

Happy birthday to {dog_name_clean}.

Hi {first_name}, sending a little birthday love from all of us at Goodwalk. We hope {dog_name_clean} has a very good day.

Aless and the Goodwalk pack

{_email_footer_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 def _resolve_preview_recipients(requested: list[str] | None) -> list[str]: allowed = {addr.strip().lower() for addr in CP_ADMIN_EMAILS if addr and addr.strip()} resolved: list[str] = [] for raw in requested or []: email = str(raw).strip().lower() if email and email in allowed and email not in resolved: resolved.append(email) if resolved: return resolved owner = OWNER_EMAIL.strip().lower() return [owner] if owner else [] async def _send_birthday_email_for_profile( email: str, profile: dict, request_id: str, dog_id: str | None = None, mark_auto_year: int | None = None, preview: bool = False, preview_recipients: list[str] | None = None, subject: str = "", ) -> None: client_name = str(profile.get("fullName", "")).strip() dog = _get_selected_dog(profile, dog_id) if not dog: raise HTTPException(status_code=400, detail="This client does not have a dog on file.") dog_name = str(dog.get("name", "")).strip() recipients = _resolve_preview_recipients(preview_recipients) if preview else [email] subject = (subject or "").strip() or f"Happy birthday {dog_name or 'from Goodwalk'}" if preview: subject = f"[PREVIEW for {client_name or email}] {subject}" payload = { "from": FROM_EMAIL, "to": recipients, "reply_to": REPLY_TO, "subject": subject, "html": _birthday_email_html(client_name, dog_name), } bcc = _client_bcc_list() if bcc and not preview: payload["bcc"] = bcc await _send_email(payload, label="birthday_email_preview" if preview else "birthday_email", request_id=request_id) if preview: return sent_at = datetime.now().isoformat(timespec="seconds") dogs = _get_profile_dogs(profile) for item in dogs: if item.get("id") == dog.get("id"): item["birthdayEmailLastSentAt"] = sent_at if mark_auto_year is not None: item["birthdayEmailLastSentYear"] = str(mark_auto_year) break profile_update = {"dogs": dogs} 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 for dog in _get_profile_dogs(profile): if not dog.get("birthdayAutoSend"): continue upcoming = _upcoming_birthday_date(str(dog.get("birthDate", ""))) if not upcoming or (upcoming.month, upcoming.day) != today_month_day: continue last_sent_year = str(dog.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, dog_id=str(dog.get("id", "")), mark_auto_year=today.year) logger.info("[%s] auto birthday email sent: email=%s dog=%s", request_id, email, dog.get("name", "")) 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) raw_body = await request.body() try: body = json.loads(raw_body.decode("utf-8") or "{}") except (UnicodeDecodeError, json.JSONDecodeError): logger.warning( "[%s] auth: invalid json payload ip=%s raw_body=%r", request_id, ip, raw_body[:500], ) raise HTTPException(status_code=400, detail="Invalid request payload.") 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): logger.warning( "[%s] auth: invalid email payload ip=%s body=%r normalized_email=%r", request_id, ip, body, email, ) raise HTTPException(status_code=400, detail="Please enter a valid email address.") if email not in _allowed_emails: logger.info("[%s] auth: unknown email=%s ip=%s", request_id, email, ip) async with _auth_lock: _record_auth_failure(ip, request_id, "unknown_email") raise HTTPException( status_code=403, detail="We don’t have your email on file. Please use the address you used when enquiring with Goodwalk, or contact us at info@goodwalk.co.nz.", ) now = time.monotonic() async with _auth_lock: requests = _code_requests.setdefault(email, deque()) while requests and now - requests[0] > 3600: requests.popleft() if len(requests) >= AUTH_CODE_REQUESTS_PER_HOUR: raise HTTPException(status_code=429, detail="Too many code requests. Please wait before trying again.") requests.append(now) code = str(secrets.randbelow(900000) + 100000) _pending_codes[email] = {"code": code, "expires_at": time.time() + AUTH_CODE_TTL_SECONDS, "attempts": 0} logger.info("[%s] auth: code issued for email=%s", request_id, email) if DEV_MODE: logger.warning("[DEV] auth code for %s: %s", email, code) else: await _send_email( {"from": FROM_EMAIL, "to": [email], "subject": "Your Goodwalk login code", "html": _auth_code_email(email, code)}, label="auth_code_email", request_id=request_id, ) 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, "previewEmails": sorted(CP_ADMIN_EMAILS), } @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
wrappers (contenteditable often wraps in divs); convert to

s = body_html s = re.sub(r"]*>", "

", s) s = s.replace("

", "

") s = s.replace("
", "
").replace("
", "
") # 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"]*?)>", lambda m: f"" 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

. """ 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'

{para}

' for para in parts ) def _escape_attr(value: str) -> str: return (value or "").replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") 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("&", "&").replace("<", "<").replace(">", ">")) return f"""
{safe_label}
""" 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'
' f'{body_html_inner}' f'
' ) highlight_html = "" if hl: highlight_html = f"""

{hl}

""" cta_html = _bulletproof_button(cta_label, cta_url, accent, accent_text, font_stack) sub_heading_html = "" if sh: sub_heading_html = f"""

{sh}

""" ornament_top_html = "" if ornament_top: ornament_top_html = f""" {ornament_top} """ ornament_bottom_html = "" if ornament_bottom: ornament_bottom_html = f""" {ornament_bottom} """ kicker_html = f"""
{(emoji + '   ') if emoji else ''}{kicker}
""" font_link_html = "" if font_link: font_link_html = ( f'' ) return f""" {font_link_html} {h}
{sh or h}
{ornament_top_html} {ornament_bottom_html}
{band_decoration}
Goodwalk
{kicker_html}

{h}

{sub_heading_html} {body_html} {highlight_html} {cta_html} {('

With love,
' + so + '

') if so else ''}
{ornament_bottom or '🐾 · 🐾 · 🐾'}
{('
' + fn + '
') if fn else ''} {_email_footer_html(text_color=footer_text_color, link_color=footer_text_color, font_stack=font_stack)}
""" @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() custom = _welcome_custom_fields(data) html = _welcome_pack_email_html( owner_name, dog_name, _trimmed(data.serviceType), _trimmed(data.priceDetails), _trimmed(data.startDate), heading=str(custom["heading"]), intro=str(custom["intro"]), outro=str(custom["outro"]), tag_label=str(custom["tagLabel"]), cta_label=str(custom["ctaLabel"]), cta_url=str(custom["ctaUrl"]), show_details=bool(custom["showDetails"]), body_html=str(custom["bodyHtml"]), include_button=bool(custom["includeButton"]), ) 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 = _get_selected_dog(profile, data.dogId) if not dog: raise HTTPException(status_code=404, detail="Dog not found.") dog_name = str(dog.get("name", "")).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, ) if is_preview: preview_recipients = _resolve_preview_recipients([str(e) for e in (data.previewRecipients or [])]) payload = { "from": FROM_EMAIL, "to": preview_recipients, "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_EMAIL.strip().lower()], "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/client-onboarding-view") async def owner_client_onboarding_view(request: Request): """Read-only snapshot of a single client's onboarding view, used by the Control Panel "view as client" feature. Returns the same ``{email, profile, draft}`` shape the client receives from ``/auth/verify`` so the owner can render an exact replica of what the client sees. This endpoint performs NO writes to client data and never mints a client session — it is purely a read. Any submit from the impersonation run-through is routed to ``/owner/onboarding-preview-submit`` instead, which also never touches the real client.""" owner_email = 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 profile is None: raise HTTPException(status_code=404, detail="Client not found.") draft = _drafts.get(email, {}) await admin_db.record_event( event_type="owner_client_view", actor_email=owner_email, ip=_get_ip(request), status="ok", detail={"client": email}, ) return {"ok": True, "email": email, "profile": profile, "draft": draft} @app.post("/owner/onboarding-preview-submit") async def owner_onboarding_preview_submit(data: OnboardingSubmission, request: Request): """Owner-only "impersonation preview" submit. The owner can walk a client's onboarding all the way to the end. This endpoint renders the onboarding result exactly like a real submission but sends it ONLY to the owner-supplied preview address (``?to=...``). It never writes to the client profile, never emails the client, never registers the address and records no submission — so a full run-through has zero effect on the real client. Skips honeypot/timing/rate-limit checks (owner is trusted and authenticated).""" owner_email = await _require_owner_email(request) request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) ip = _get_ip(request) browser = _parse_ua(request.headers.get("user-agent", "")) preview_to = (request.query_params.get("to") or "").strip().lower() if not preview_to or not _EMAIL_RE.match(preview_to): raise HTTPException(status_code=400, detail="A valid preview email address is required.") _normalize_onboarding_submission(data) owner_html = owner_onboarding_email(data, ip, browser) preview_payload = { "from": FROM_EMAIL, "to": [preview_to], "reply_to": REPLY_TO, "subject": f"[PREVIEW] Onboarding run-through — {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: preview_payload["attachments"] = attachments try: await _send_email(preview_payload, label="owner_onboarding_preview_email", request_id=request_id) except Exception as exc: logger.error("[%s] onboarding preview email failed: %s", request_id, exc, exc_info=True) raise HTTPException( status_code=502, detail={ "request_id": request_id, "message": "The preview could not be delivered. Please try again shortly.", }, ) logger.info( "[%s] onboarding preview sent by owner=%s for client=%s → %s", request_id, owner_email, data.email, preview_to, ) await admin_db.record_event( event_type="owner_onboarding_preview", request_id=request_id, actor_email=owner_email, ip=ip, status="ok", detail={"client": str(data.email), "preview_to": preview_to}, ) return {"ok": True, "request_id": request_id, "preview_to": preview_to} @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/resend-overview") async def owner_resend_overview(request: Request): await _require_owner_email(request) qp = request.query_params try: limit = int(qp.get("limit", "50")) except ValueError: limit = 50 limit = max(1, min(100, limit)) try: emails_result = await asyncio.to_thread(resend.Emails.list, {"limit": limit}) logs_result = await asyncio.to_thread(resend.Logs.list, {"limit": limit}) except Exception as exc: logger.warning("owner_resend_overview failed: %s", exc, exc_info=True) raise HTTPException( status_code=502, detail={ "message": "Could not load Resend activity right now.", "error_type": type(exc).__name__, }, ) email_rows = emails_result.get("data", []) if isinstance(emails_result, dict) else [] log_rows = logs_result.get("data", []) if isinstance(logs_result, dict) else [] emails: list[dict[str, Any]] = [] for item in email_rows: if not isinstance(item, dict): continue emails.append({ "id": item.get("id"), "to": item.get("to") if isinstance(item.get("to"), list) else [], "from": item.get("from"), "createdAt": item.get("created_at"), "subject": item.get("subject"), "lastEvent": item.get("last_event"), "scheduledAt": item.get("scheduled_at"), }) logs: list[dict[str, Any]] = [] for item in log_rows: if not isinstance(item, dict): continue endpoint = str(item.get("endpoint") or "") if "/emails" not in endpoint: continue logs.append({ "id": item.get("id"), "createdAt": item.get("created_at"), "endpoint": endpoint, "method": item.get("method"), "responseStatus": item.get("response_status"), "userAgent": item.get("user_agent"), }) return { "ok": True, "emails": emails, "logs": logs[:limit], } @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", ""), "address": profile.get("address", ""), "dogName": profile.get("dogName", ""), "dogBreed": profile.get("dogBreed", ""), "dogAge": profile.get("dogAge", ""), "dogs": _get_profile_dogs(profile), "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", ""), "dogs": _get_profile_dogs(profile), "onboardingSubmittedAt": profile.get("onboardingSubmittedAt", ""), "hasBirthdayInvite": any(_trimmed(str(dog.get("birthDate", ""))) for dog in _get_profile_dogs(profile)), }) 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", ""), "address": profile.get("address", ""), "dogName": profile.get("dogName", ""), "dogBreed": profile.get("dogBreed", ""), "dogAge": profile.get("dogAge", ""), "dogs": _get_profile_dogs(profile), "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/client-directory") async def owner_client_directory(request: Request): await _require_owner_email(request) 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", ""), "address": profile.get("address", ""), "dogName": profile.get("dogName", ""), "dogBreed": profile.get("dogBreed", ""), "dogAge": profile.get("dogAge", ""), "dogs": _get_profile_dogs(profile), "status": "completed" if profile.get("onboardingCompleted") else "pending", "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, }) clients.sort(key=lambda item: (item.get("fullName", "").lower(), item.get("email", "").lower())) return {"ok": True, "clients": clients} @app.post("/owner/client-profile") async def owner_client_profile(data: ClientProfileUpdate, request: Request): request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) owner_email = await _require_owner_email(request) full_name = str(data.fullName).strip() dog_name = str(data.dogName).strip() dog_breed = str(data.dogBreed).strip() dog_age = str(data.dogAge).strip() phone = str(data.phone).strip() address = str(data.address).strip() current_email = str(data.email).strip().lower() next_email = str(data.nextEmail).strip().lower() if not full_name: raise HTTPException(status_code=400, detail="Please enter the owner name.") if not dog_name: raise HTTPException(status_code=400, detail="Please enter the dog name.") profile = await _update_client_profile( current_email, next_email, { "fullName": full_name, "phone": phone, "address": address, "dogName": dog_name, "dogBreed": dog_breed, "dogAge": dog_age, }, ) await admin_db.record_event( event_type="owner_client_profile_updated", request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", detail={ "previousEmail": current_email, "email": next_email, "fullName": full_name, "dogName": dog_name, "dogBreed": dog_breed, "dogAge": dog_age, }, ) return { "ok": True, "client": { "email": next_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", ""), "dogs": _get_profile_dogs(profile), "welcomePackSentAt": profile.get("welcomePackSentAt", ""), "welcomePackOffer": profile.get("welcomePackOffer", {}) if isinstance(profile.get("welcomePackOffer"), dict) else {}, }, } @app.post("/owner/delete-client") async def owner_delete_client(data: DeleteClientRequest, request: Request): """Permanently delete a client and revoke their access to the onboarding form.""" request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) owner_email = await _require_owner_email(request) email = str(data.email).strip().lower() removed = await _delete_client_profile(email) await admin_db.record_event( event_type="owner_client_deleted", request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", detail={ "email": email, "fullName": removed.get("fullName", "") if isinstance(removed, dict) else "", "dogName": removed.get("dogName", "") if isinstance(removed, dict) else "", }, ) return {"ok": True, "email": email} @app.post("/owner/add-client") async def owner_add_client(data: NewClientRequest, request: Request): """Create a client by hand (leads from Instagram / Facebook / referrals that never used the public enquiry form). Registers the email in the allowed-users list so the client can sign in to the onboarding form, and seeds a profile.""" request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) owner_email = await _require_owner_email(request) email = str(data.email).strip().lower() full_name = str(data.fullName).strip() phone = str(data.phone).strip() address = str(data.address).strip() dog_name = str(data.dogName).strip() dog_breed = str(data.dogBreed).strip() dog_age = str(data.dogAge).strip() source = str(data.source).strip() joining_date = str(data.joiningDate).strip() if not full_name: raise HTTPException(status_code=400, detail="Please enter the owner name.") if email == OWNER_EMAIL.strip().lower(): raise HTTPException(status_code=400, detail="That email is reserved for the business owner.") if email in _client_profiles: raise HTTPException(status_code=409, detail="A client with that email already exists.") added_at = datetime.now().isoformat(timespec="seconds") # Register the email first so the client can request a login code and reach # the onboarding form, then seed the profile shown across the control panel. await _register_email(email) await _store_client_profile(email, { "fullName": full_name, "phone": phone, "address": address, "dogName": dog_name, "dogBreed": dog_breed, "dogAge": dog_age, "source": source, # Owner-supplied date the client joined Goodwalk (falls back to the # add timestamp). Drives the joining-anniversaries view. "joiningDate": joining_date or added_at[:10], # Treated like an enquiry timestamp so the client surfaces in the # pending-onboarding and recent-clients views straight away. "lastEnquiryAt": added_at, "addedByOwnerAt": added_at, "addedBy": owner_email, }) await admin_db.record_event( event_type="owner_client_added", request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", detail={"email": email, "fullName": full_name, "source": source or "manual"}, ) # Optional MYOB sync. Never blocks the local client creation: if it is not # configured or the API call fails we surface that in the response and the # owner can retry later, but the client is already saved. myob_result: dict[str, Any] | None = None if data.createInMyob: if not myob.is_configured(): myob_result = {"ok": False, "skipped": True, "message": "MYOB integration is not configured."} logger.info("[%s] add-client: MYOB requested but not configured", request_id) else: try: created = await asyncio.to_thread( myob.create_customer, email=email, full_name=full_name, phone=phone, street=address, source=source, ) myob_result = {"ok": True, "uid": created.get("uid", "")} await _store_client_profile(email, { "myobCustomerUid": created.get("uid", ""), "myobSyncedAt": datetime.now().isoformat(timespec="seconds"), }) except Exception as exc: myob_result = {"ok": False, "message": str(exc)} logger.warning("[%s] add-client: MYOB customer create failed: %s", request_id, exc) await admin_db.record_event( event_type="owner_client_myob_sync", request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok" if myob_result.get("ok") else "error", detail={"email": email, **myob_result}, ) profile = _client_profiles.get(email, {}) lifecycle = profile.get("lifecycle") if isinstance(profile.get("lifecycle"), dict) else None return { "ok": True, "myob": myob_result, "client": { "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", ""), "dogs": _get_profile_dogs(profile), "status": "completed" if profile.get("onboardingCompleted") else "pending", "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, "lastActivityAt": profile.get("lastEnquiryAt", ""), "welcomePackSentAt": profile.get("welcomePackSentAt", ""), }, } @app.post("/owner/client-dog") async def owner_client_dog(data: ClientDogUpsertRequest, 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() if not email: raise HTTPException(status_code=400, detail="Client email is required.") dog_name = str(data.dogName).strip() dog_breed = str(data.dogBreed).strip() dog_age = str(data.dogAge).strip() auto_send = bool(data.birthdayAutoSend) if not dog_name: raise HTTPException(status_code=400, detail="Please enter the dog name.") if auto_send and not _upcoming_birthday_date(dog_age): raise HTTPException(status_code=400, detail="Add a valid birthday before turning on automatic birthday emails.") profile = _client_profiles.get(email, {}) if not profile: raise HTTPException(status_code=404, detail="Client not found.") dogs = _get_profile_dogs(profile) requested_dog_id = str(data.dogId or "").strip() existing = next((dog for dog in dogs if dog.get("id") == requested_dog_id), None) if requested_dog_id else None dog_id = requested_dog_id or _dog_slug(dog_name) duplicate_name = next( ( dog for dog in dogs if dog.get("id") != requested_dog_id and str(dog.get("name", "")).strip().lower() == dog_name.lower() ), None, ) if duplicate_name and not existing: dog_id = str(duplicate_name.get("id") or dog_id) next_dog = { "id": dog_id, "name": dog_name, "breed": dog_breed, "birthDate": dog_age, "birthdayAutoSend": auto_send, "birthdayEmailLastSentAt": str(existing.get("birthdayEmailLastSentAt", "")) if existing and dog_age else "", "birthdayEmailLastSentYear": str(existing.get("birthdayEmailLastSentYear", "")) if existing and dog_age else "", } updated_existing = False next_dogs: list[dict[str, Any]] = [] for dog in dogs: if dog.get("id") == dog_id: next_dogs.append(next_dog) updated_existing = True else: next_dogs.append(dog) if not updated_existing: next_dogs.append(next_dog) await _store_client_profile(email, {"dogs": next_dogs}) updated_profile = _client_profiles.get(email, {}) lifecycle = updated_profile.get("lifecycle") if isinstance(updated_profile.get("lifecycle"), dict) else None await admin_db.record_event( event_type="owner_client_dog_updated" if updated_existing else "owner_client_dog_added", request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", detail={ "email": email, "dogId": dog_id, "dogName": dog_name, "dogBreed": dog_breed, "dogAge": dog_age, }, ) return { "ok": True, "client": { "email": email, "fullName": updated_profile.get("fullName", ""), "phone": updated_profile.get("phone", ""), "address": updated_profile.get("address", ""), "dogName": updated_profile.get("dogName", ""), "dogBreed": updated_profile.get("dogBreed", ""), "dogAge": updated_profile.get("dogAge", ""), "dogs": _get_profile_dogs(updated_profile), "status": "completed" if updated_profile.get("onboardingCompleted") else "pending", "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, "lastActivityAt": updated_profile.get("lastEnquiryAt", ""), "welcomePackSentAt": updated_profile.get("welcomePackSentAt", ""), }, } @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 dogs = _get_profile_dogs(profile) if not dogs: continue for dog in dogs: upcoming = _upcoming_birthday_date(str(dog.get("birthDate", "")), today) if not upcoming: continue birthdays.append({ "email": email, "fullName": profile.get("fullName", ""), "dogId": dog.get("id", ""), "dogName": dog.get("name", ""), "dogBreed": dog.get("breed", ""), "dogAge": dog.get("birthDate", ""), "birthdayLabel": upcoming.isoformat(), "daysUntil": (upcoming - today.date()).days, "birthdayAutoSend": bool(dog.get("birthdayAutoSend")), "birthdayEmailLastSentAt": dog.get("birthdayEmailLastSentAt", ""), "dogs": dogs, }) 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.") dog = _get_selected_dog(profile, _trimmed(request.query_params.get("dogId", "")) or None) if not dog: raise HTTPException(status_code=404, detail="Dog not found.") attachment = _birthday_ics_attachment( str(dog.get("name", "")), str(dog.get("birthDate", "")), 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"]}"' }, ) def _default_welcome_subject(dog_name: str) -> str: """The fallback subject when the owner leaves the field blank.""" name = (dog_name or "").strip() return f"Goodwalk Onboarding - {name}" if name else "Goodwalk Onboarding" def _welcome_custom_fields(data: WelcomePackEmailRequest) -> dict: """Pull the owner's customisation fields off the request into a plain dict that travels with the offer (live send, preview, and scheduled queue).""" return { "tagLabel": _trimmed(data.tagLabel), "heading": _trimmed(data.heading), "intro": _trimmed(data.intro), "outro": _trimmed(data.outro), "ctaLabel": _trimmed(data.ctaLabel), "ctaUrl": _trimmed(data.ctaUrl), "showDetails": bool(data.showDetails), "bodyHtml": _trimmed(data.bodyHtml), "includeButton": bool(data.includeButton), } def _welcome_pack_payload( email: str, owner_name: str, dog_name: str, service: str, price: str, start: str, *, subject: str, is_preview: bool, recipients: list[str], custom: dict | None = None, cc: list[str] | None = None, bcc_extra: list[str] | None = None, ) -> dict: """Assemble the Resend payload for a welcome-pack email. Shared by the immediate send, the preview path, and the scheduled-email worker.""" subject = (subject or "").strip() or _default_welcome_subject(dog_name) if is_preview: subject = f"[PREVIEW for {owner_name or email}] {subject}" custom = custom or {} payload = { "from": FROM_EMAIL, "to": recipients, "reply_to": REPLY_TO, "subject": subject, "html": _welcome_pack_email_html( owner_name, dog_name, service, price, start, heading=str(custom.get("heading", "")), intro=str(custom.get("intro", "")), outro=str(custom.get("outro", "")), tag_label=str(custom.get("tagLabel", "")), # Missing keys (legacy scheduled entries) fall back to the standard # button; a present-but-empty value is an explicit "hide the button". cta_label=str(custom.get("ctaLabel", WELCOME_CTA_DEFAULT_LABEL)), cta_url=str(custom.get("ctaUrl", WELCOME_CTA_DEFAULT_URL)), show_details=bool(custom.get("showDetails", True)), body_html=str(custom.get("bodyHtml", "")), include_button=bool(custom.get("includeButton", True)), ), } bcc = list(_client_bcc_list()) if bcc_extra and not is_preview: for addr in bcc_extra: if addr and addr not in bcc: bcc.append(addr) if bcc and not is_preview: payload["bcc"] = bcc if cc and not is_preview: payload["cc"] = cc return payload async def _deliver_welcome_pack( email: str, profile: dict, service: str, price: str, start: str, subject: str, request_id: str, custom: dict | None = None, cc: list[str] | None = None, bcc_extra: list[str] | None = None, ) -> str: """Send the welcome pack to the client now and record it on the profile. Returns the sentAt timestamp. Raises on send failure (caller decides how to surface it). Used by both the live endpoint and the scheduled worker so the two paths can never drift apart.""" owner_name = str(profile.get("fullName", "")).strip() dog_name = str(profile.get("dogName", "")).strip() custom = custom or {} sent_at = datetime.now().isoformat(timespec="seconds") payload = _welcome_pack_payload( email, owner_name, dog_name, service, price, start, subject=subject, is_preview=False, recipients=[email], custom=custom, cc=cc, bcc_extra=bcc_extra, ) await _send_email(payload, label="welcome_pack_email", request_id=request_id) await _store_client_profile(email, { "welcomePackSentAt": sent_at, "welcomePackOffer": { "serviceType": service, "priceDetails": price, "startDate": start, "subject": subject, "tagLabel": str(custom.get("tagLabel", "")), "heading": str(custom.get("heading", "")), "intro": str(custom.get("intro", "")), "outro": str(custom.get("outro", "")), "ctaLabel": str(custom.get("ctaLabel", "")), "ctaUrl": str(custom.get("ctaUrl", "")), "showDetails": bool(custom.get("showDetails", True)), "bodyHtml": str(custom.get("bodyHtml", "")), "includeButton": bool(custom.get("includeButton", True)), "sentAt": sent_at, }, }) return sent_at async def _enqueue_scheduled_welcome( *, email: str, profile: dict, service: str, price: str, start: str, subject: str, scheduled_for: datetime, owner_email: str, custom: dict | None = None, ) -> dict: """Add a welcome email to the durable queue for later delivery.""" custom = custom or {} entry_id = uuid.uuid4().hex entry = { "id": entry_id, "kind": "welcome", "email": email, "subject": subject, "serviceType": service, "priceDetails": price, "startDate": start, "heading": str(custom.get("heading", "")), "intro": str(custom.get("intro", "")), "outro": str(custom.get("outro", "")), "ctaLabel": str(custom.get("ctaLabel", "")), "ctaUrl": str(custom.get("ctaUrl", "")), "showDetails": bool(custom.get("showDetails", True)), "clientName": str(profile.get("fullName", "")).strip(), "dogName": str(profile.get("dogName", "")).strip(), "scheduledFor": scheduled_for.isoformat(timespec="minutes"), "status": "pending", "attempts": 0, "createdAt": datetime.now().isoformat(timespec="seconds"), "createdBy": owner_email, "sentAt": None, "lastError": None, } async with _scheduled_lock: _scheduled_emails[entry_id] = entry await _persist_scheduled_emails() return entry async def _run_scheduled_sender_once() -> None: """Dispatch any scheduled emails that have come due. Successful sends are marked 'sent'; transient failures stay 'pending' and retry on later ticks until MAX_SEND_ATTEMPTS, after which they're marked 'failed'.""" now = datetime.now() due_ids: list[str] = [] for entry in list(_scheduled_emails.values()): if entry.get("status") != "pending": continue try: when = datetime.fromisoformat(str(entry.get("scheduledFor"))) except ValueError: continue if when.tzinfo is not None: when = when.astimezone().replace(tzinfo=None) if when <= now: due_ids.append(str(entry.get("id"))) for entry_id in due_ids: entry = _scheduled_emails.get(entry_id) if not entry or entry.get("status") != "pending": continue email = str(entry.get("email", "")).strip().lower() profile = _client_profiles.get(email, {}) request_id = f"sched-{entry_id[:6]}" # Guard against stale schedules: the client may have been removed or # completed onboarding between scheduling and the send time. if not profile: entry["status"] = "failed" entry["lastError"] = "Client profile no longer exists." elif profile.get("onboardingCompleted"): entry["status"] = "cancelled" entry["lastError"] = "Client completed onboarding before the scheduled time." else: try: sent_at = await _deliver_welcome_pack( email, profile, str(entry.get("serviceType", "")), str(entry.get("priceDetails", "")), str(entry.get("startDate", "")), str(entry.get("subject", "")), request_id, custom={ "heading": str(entry.get("heading", "")), "intro": str(entry.get("intro", "")), "outro": str(entry.get("outro", "")), # Pre-customisation entries have no cta keys → standard # button; newer entries store explicit values (incl. ""). "ctaLabel": str(entry.get("ctaLabel", WELCOME_CTA_DEFAULT_LABEL)), "ctaUrl": str(entry.get("ctaUrl", WELCOME_CTA_DEFAULT_URL)), "showDetails": bool(entry.get("showDetails", True)), }, ) entry["status"] = "sent" entry["sentAt"] = sent_at entry["attempts"] = int(entry.get("attempts", 0)) + 1 entry["lastError"] = None logger.info("[%s] scheduled welcome sent: email=%s", request_id, email) await admin_db.record_event( event_type="owner_welcome_pack_sent", request_id=request_id, actor_email=entry.get("createdBy"), status="ok", detail={"recipient": email, "scheduled": True, "scheduledFor": entry.get("scheduledFor")}, ) except Exception as exc: attempts = int(entry.get("attempts", 0)) + 1 entry["attempts"] = attempts entry["lastError"] = f"{type(exc).__name__}: {exc}" if attempts >= MAX_SEND_ATTEMPTS: entry["status"] = "failed" logger.error("[%s] scheduled welcome failed permanently after %d attempt(s): %s", request_id, attempts, exc, exc_info=True) else: logger.warning("[%s] scheduled welcome attempt %d failed, will retry: %s", request_id, attempts, exc) async with _scheduled_lock: await _persist_scheduled_emails() async def _scheduled_sender_loop() -> None: while True: try: await _run_scheduled_sender_once() except asyncio.CancelledError: raise except Exception: logger.exception("Scheduled sender loop failed") await asyncio.sleep(SCHEDULED_CHECK_INTERVAL_SECONDS) @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.") # The email is now fully customisable, so the structured offer fields are # optional. We only require that the email isn't completely empty. service = _trimmed(data.serviceType) price = _trimmed(data.priceDetails) start = _trimmed(data.startDate) custom = _welcome_custom_fields(data) subject = _trimmed(data.subject) or _default_welcome_subject(str(profile.get("dogName", ""))) is_preview = bool(data.preview) has_body = bool( _sanitize_welcome_body_html(str(custom["bodyHtml"])) or custom["heading"] or custom["intro"] or custom["outro"] ) has_details = bool(custom["showDetails"] and (service or price or start)) if not has_body and not has_details: raise HTTPException(status_code=400, detail="Please add some content or offer details to the email.") # Scheduled send: queue it for the worker rather than sending now. Previews # are always immediate — there's nothing to schedule about a test email. if data.scheduledFor and not is_preview: when = _parse_schedule_datetime(data.scheduledFor) if when <= datetime.now(): raise HTTPException(status_code=400, detail="Please choose a time in the future to schedule the email.") entry = await _enqueue_scheduled_welcome( email=email, profile=profile, service=service, price=price, start=start, subject=subject, scheduled_for=when, owner_email=owner_email, custom=custom, ) logger.info("[%s] welcome pack scheduled: email=%s for=%s id=%s", request_id, email, entry["scheduledFor"], entry["id"]) await admin_db.record_event( event_type="owner_welcome_pack_scheduled", request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", detail={"recipient": email, "serviceType": service, "scheduledFor": entry["scheduledFor"], "id": entry["id"]}, ) return {"ok": True, "scheduled": True, "id": entry["id"], "scheduledFor": entry["scheduledFor"]} if is_preview: owner_name = str(profile.get("fullName", "")).strip() dog_name = str(profile.get("dogName", "")).strip() recipients = _resolve_preview_recipients([str(e) for e in (data.previewRecipients or [])]) payload = _welcome_pack_payload( email, owner_name, dog_name, service, price, start, subject=subject, is_preview=True, recipients=recipients, custom=custom, ) try: await _send_email(payload, label="welcome_pack_email_preview", request_id=request_id) except Exception as exc: logger.error("[%s] welcome pack preview 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__, }, ) logger.info("[%s] welcome pack PREVIEW sent: original_recipient=%s -> owner", request_id, email) return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": True} cc_list = [owner_email] if (bool(data.ccOwner) and owner_email) else None bcc_list = [owner_email] if (bool(data.bccOwner) and owner_email) else None try: sent_at = await _deliver_welcome_pack(email, profile, service, price, start, subject, request_id, custom=custom, cc=cc_list, bcc_extra=bcc_list) 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__, }, ) 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": service, "startDate": start, }, ) return {"ok": True, "sentAt": sent_at} def _scheduled_email_view(entry: dict) -> dict: """Public shape of a scheduled-email record for the owner dashboard.""" return { "id": entry.get("id"), "kind": entry.get("kind", "welcome"), "email": entry.get("email"), "clientName": entry.get("clientName", ""), "dogName": entry.get("dogName", ""), "serviceType": entry.get("serviceType", ""), "priceDetails": entry.get("priceDetails", ""), "startDate": entry.get("startDate", ""), "scheduledFor": entry.get("scheduledFor"), "status": entry.get("status", "pending"), "attempts": int(entry.get("attempts", 0)), "createdAt": entry.get("createdAt"), "sentAt": entry.get("sentAt"), "lastError": entry.get("lastError"), } @app.get("/owner/scheduled-emails") async def owner_list_scheduled_emails(request: Request): await _require_owner_email(request) pending = [] history = [] for entry in _scheduled_emails.values(): view = _scheduled_email_view(entry) (pending if view["status"] == "pending" else history).append(view) # Pending: soonest first. History: most recently scheduled first. pending.sort(key=lambda e: str(e.get("scheduledFor") or "")) history.sort(key=lambda e: str(e.get("scheduledFor") or ""), reverse=True) return {"ok": True, "pending": pending, "history": history} @app.post("/owner/scheduled-emails/cancel") async def owner_cancel_scheduled_email(data: ScheduledEmailCancelRequest, request: Request): owner_email = await _require_owner_email(request) async with _scheduled_lock: entry = _scheduled_emails.get(data.id) if not entry: raise HTTPException(status_code=404, detail="That scheduled email no longer exists.") if entry.get("status") != "pending": raise HTTPException(status_code=400, detail="Only a pending scheduled email can be cancelled.") entry["status"] = "cancelled" entry["lastError"] = None await _persist_scheduled_emails() logger.info("scheduled welcome cancelled: id=%s by=%s", data.id, owner_email) await admin_db.record_event( event_type="owner_scheduled_email_cancelled", actor_email=owner_email, ip=_get_ip(request), status="ok", detail={"id": data.id, "recipient": entry.get("email")}, ) return {"ok": True, "scheduled": _scheduled_email_view(entry)} @app.post("/owner/scheduled-emails/reschedule") async def owner_reschedule_scheduled_email(data: ScheduledEmailRescheduleRequest, request: Request): owner_email = await _require_owner_email(request) when = _parse_schedule_datetime(data.scheduledFor) if when <= datetime.now(): raise HTTPException(status_code=400, detail="Please choose a time in the future.") async with _scheduled_lock: entry = _scheduled_emails.get(data.id) if not entry: raise HTTPException(status_code=404, detail="That scheduled email no longer exists.") if entry.get("status") != "pending": raise HTTPException(status_code=400, detail="Only a pending scheduled email can be rescheduled.") entry["scheduledFor"] = when.isoformat(timespec="minutes") await _persist_scheduled_emails() logger.info("scheduled welcome rescheduled: id=%s for=%s by=%s", data.id, entry["scheduledFor"], owner_email) await admin_db.record_event( event_type="owner_scheduled_email_rescheduled", actor_email=owner_email, ip=_get_ip(request), status="ok", detail={"id": data.id, "recipient": entry.get("email"), "scheduledFor": entry["scheduledFor"]}, ) return {"ok": True, "scheduled": _scheduled_email_view(entry)} @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.") dog = _get_selected_dog(profile, data.dogId) if not dog: raise HTTPException(status_code=404, detail="Dog not found.") if not _upcoming_birthday_date(str(dog.get("birthDate", ""))): raise HTTPException(status_code=400, detail="This dog does not have a valid birthday on file.") try: await _send_birthday_email_for_profile( email, profile, request_id, dog_id=data.dogId, preview=bool(data.preview), preview_recipients=[str(e) for e in (data.previewRecipients or [])], subject=str(data.subject or ""), ) 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, "dogId": data.dogId or str(dog.get("id", "")), "dogName": str(dog.get("name", "")), "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/reset-onboarding") async def owner_reset_onboarding(data: ResetOnboardingRequest, request: Request): """Reset a client's onboarding while keeping all their saved details. For clients imported from the legacy Gravity Forms data: keeps their contact and dog details (archiving any prior submission) but returns them to the pending list so they can sign in with their email and complete the new form. """ request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) owner_email = await _require_owner_email(request) email = str(data.email).strip().lower() updated = await _reset_client_onboarding(email) # Make sure the email is registered so they can request a login code even if # it was somehow missing from the allowed list after the legacy import. await _register_email(email) await admin_db.record_event( event_type="owner_onboarding_reset", request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", detail={"clientEmail": email}, ) logger.info("[%s] owner: %s reset onboarding for %s", request_id, owner_email, email) lifecycle = updated.get("lifecycle") if isinstance(updated.get("lifecycle"), dict) else None return { "ok": True, "client": { "email": email, "fullName": updated.get("fullName", ""), "phone": updated.get("phone", ""), "address": updated.get("address", ""), "dogName": updated.get("dogName", ""), "dogBreed": updated.get("dogBreed", ""), "dogAge": updated.get("dogAge", ""), "dogs": _get_profile_dogs(updated), "status": "pending", "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, "lastActivityAt": updated.get("lastEnquiryAt", "") or updated.get("welcomePackSentAt", ""), "welcomePackSentAt": updated.get("welcomePackSentAt", ""), }, } @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.") dog = _get_selected_dog(profile, data.dogId) if not dog: raise HTTPException(status_code=404, detail="Dog not found.") if not _upcoming_birthday_date(str(dog.get("birthDate", ""))): raise HTTPException(status_code=400, detail="This dog does not have a valid birthday on file.") dogs = _get_profile_dogs(profile) for item in dogs: if item.get("id") == dog.get("id"): item["birthdayAutoSend"] = data.enabled break await _store_client_profile(email, {"dogs": dogs}) await admin_db.record_event( event_type="owner_birthday_auto_toggled", actor_email=owner_email, ip=_get_ip(request), status="ok", detail={"clientEmail": email, "dogId": data.dogId or str(dog.get("id", "")), "dogName": str(dog.get("name", "")), "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), } client_bcc = _client_bcc_list() 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"""
Additional notes
{data.additionalNotes}
""" if data.additionalNotes else "" signature_block = f"""
Captured signature
Client signature
""" badge = f"""
📜  New signed contract
Submitted {submitted_at}
""" return f""" New GoodWalk service contract
{_logo_header(badge_html=badge, subtitle="Signed service agreement")}
Quick contact
Call {data.phone}
Client details
{_detail_row("Name", data.fullName)} {_detail_row("Email", str(data.email))} {_detail_row("Phone", data.phone)} {_detail_row("Address", data.address)}
Service agreement
{_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}
Declarations confirmed
{_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")}
{signature_block}
Session info
{_meta_row("IP address", ip)} {_meta_row("Browser", browser)} {visit_time_row} {form_time_row} {referrer_row} {page_row}
""" @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, "vetAddress": data.vetAddress, "regularFleaTickTreatment": data.regularFleaTickTreatment, "petInsurance": data.petInsurance, "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), } client_bcc = _client_bcc_list() 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}