4246 lines
173 KiB
Python
4246 lines
173 KiB
Python
import asyncio
|
||
import base64
|
||
from collections import deque
|
||
from contextlib import asynccontextmanager
|
||
import json
|
||
import os
|
||
import random
|
||
import re
|
||
import secrets
|
||
import time
|
||
import uuid
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import resend
|
||
from fastapi import FastAPI, HTTPException, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||
from fastapi.responses import JSONResponse, Response
|
||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||
|
||
import db as admin_db
|
||
from mail_api.config import (
|
||
ALLOWED_EMAILS_FILE as _ALLOWED_EMAILS_FILE,
|
||
APP_VERSION,
|
||
AUTH_CODE_MAX_ATTEMPTS,
|
||
AUTH_CODE_REQUESTS_PER_HOUR,
|
||
AUTH_CODE_TTL_SECONDS,
|
||
AUTH_IP_BLOCK_DURATION,
|
||
AUTH_IP_FAILURE_WINDOW,
|
||
AUTH_IP_MAX_FAILURES,
|
||
AUTH_SESSION_TTL_SECONDS,
|
||
BIRTHDAY_CHECK_INTERVAL_SECONDS,
|
||
CLIENT_BCC,
|
||
CLIENT_PROFILES_FILE as _CLIENT_PROFILES_FILE,
|
||
CORS_ALLOWED_ORIGINS,
|
||
CP_ADMIN_EMAILS,
|
||
DEPLOY_SMOKE_SECRET,
|
||
DEV_MODE,
|
||
DRAFTS_FILE as _DRAFTS_FILE,
|
||
EMAIL_SEND_TIMEOUT_SECONDS,
|
||
ENABLE_GENERAL_ENQUIRIES,
|
||
FORM_MAX_SECONDS,
|
||
FORM_MIN_SECONDS,
|
||
FROM_EMAIL,
|
||
LEGACY_SEED_FILE as _LEGACY_SEED_FILE,
|
||
LOGO_URL,
|
||
MAX_REQUEST_BODY_BYTES,
|
||
MAX_SEND_ATTEMPTS,
|
||
OWNER_BCC,
|
||
OWNER_EMAIL,
|
||
RATE_LIMIT_MAX_PER_EMAIL,
|
||
RATE_LIMIT_MAX_PER_IP,
|
||
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
||
RATE_LIMIT_WINDOW_SECONDS,
|
||
REPLY_TO,
|
||
STARTUP_TEST_RECIPIENT,
|
||
TRUSTED_HOSTS,
|
||
logger,
|
||
)
|
||
from mail_api.models import (
|
||
BaseSubmission,
|
||
BirthdayAutoSendRequest,
|
||
BirthdayEmailRequest,
|
||
BookingSubmission,
|
||
ClientStatusUpdate,
|
||
ContractSubmission,
|
||
OnboardingSubmission,
|
||
RenderMessageRequest,
|
||
SendMessageRequest,
|
||
WelcomePackEmailRequest,
|
||
)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _lifespan(app: FastAPI):
|
||
await _startup_mail_check()
|
||
try:
|
||
yield
|
||
finally:
|
||
await _shutdown_background_tasks()
|
||
|
||
|
||
app = FastAPI(title="GoodWalk Mail API", lifespan=_lifespan)
|
||
|
||
# ── Auth state ───────────────────────────────────────────────────────────────
|
||
|
||
def _write_pii_json(path: Path, payload: object) -> None:
|
||
"""Atomically write a JSON file and chmod it owner-only (0600).
|
||
|
||
The chmod is best-effort: it is a no-op on Windows, but on the Linux
|
||
Docker host it ensures the file with PII is unreadable by other users.
|
||
"""
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||
try:
|
||
os.chmod(tmp, 0o600)
|
||
except OSError:
|
||
pass
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def _load_allowed_emails_from_file() -> set[str]:
|
||
seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()}
|
||
try:
|
||
if _ALLOWED_EMAILS_FILE.exists():
|
||
data = json.loads(_ALLOWED_EMAILS_FILE.read_text(encoding="utf-8"))
|
||
seed.update(e.lower() for e in data.get("emails", []) if isinstance(e, str))
|
||
except Exception as exc:
|
||
logger.warning("Could not load allowed_emails file: %s", exc)
|
||
return seed
|
||
|
||
|
||
def _save_allowed_emails_file(emails: set[str]) -> None:
|
||
try:
|
||
_write_pii_json(_ALLOWED_EMAILS_FILE, {"emails": sorted(emails)})
|
||
except Exception as exc:
|
||
logger.warning("Could not save allowed_emails file: %s", exc)
|
||
|
||
|
||
def _load_client_profiles_from_file() -> dict[str, dict]:
|
||
try:
|
||
if _CLIENT_PROFILES_FILE.exists():
|
||
return json.loads(_CLIENT_PROFILES_FILE.read_text(encoding="utf-8"))
|
||
except Exception as exc:
|
||
logger.warning("Could not load client_profiles file: %s", exc)
|
||
return {}
|
||
|
||
|
||
def _save_client_profiles_file(profiles: dict) -> None:
|
||
try:
|
||
_write_pii_json(_CLIENT_PROFILES_FILE, profiles)
|
||
except Exception as exc:
|
||
logger.warning("Could not save client_profiles file: %s", exc)
|
||
|
||
|
||
def _load_drafts_from_file() -> dict:
|
||
try:
|
||
if _DRAFTS_FILE.exists():
|
||
return json.loads(_DRAFTS_FILE.read_text(encoding="utf-8"))
|
||
except Exception as exc:
|
||
logger.warning("Could not load drafts file: %s", exc)
|
||
return {}
|
||
|
||
|
||
def _save_drafts_file(drafts: dict) -> None:
|
||
try:
|
||
_write_pii_json(_DRAFTS_FILE, drafts)
|
||
except Exception as exc:
|
||
logger.warning("Could not save drafts file: %s", exc)
|
||
|
||
|
||
async def _save_active_sessions_async() -> None:
|
||
"""Persist live sessions to admin_kv so they survive container restarts.
|
||
|
||
Snapshot filters out expired entries before writing. Best-effort —
|
||
failure is logged but does not block the auth flow (memory remains
|
||
authoritative for the current process).
|
||
"""
|
||
now = time.time()
|
||
snapshot = {tok: s for tok, s in _active_sessions.items() if s.get("expires_at", 0) > now}
|
||
try:
|
||
await admin_db.set_kv("active_sessions", snapshot)
|
||
except Exception as exc:
|
||
logger.warning("Could not persist active_sessions: %s", exc)
|
||
|
||
|
||
async def _load_active_sessions_async() -> dict[str, dict]:
|
||
if not admin_db.is_enabled():
|
||
return {}
|
||
try:
|
||
data = await admin_db.get_kv("active_sessions")
|
||
if not isinstance(data, dict):
|
||
return {}
|
||
now = time.time()
|
||
return {
|
||
tok: s
|
||
for tok, s in data.items()
|
||
if isinstance(s, dict) and isinstance(s.get("expires_at"), (int, float)) and s["expires_at"] > now
|
||
}
|
||
except Exception as exc:
|
||
logger.warning("Could not load active_sessions from admin_kv: %s", exc)
|
||
return {}
|
||
|
||
|
||
async def _persist_admin_state(key: str, value: Any) -> None:
|
||
"""Write a single admin_kv blob to postgres when the database is available."""
|
||
try:
|
||
await admin_db.set_kv(key, value)
|
||
except Exception as exc:
|
||
logger.warning("Postgres persist (%s) failed; JSON copy is still authoritative: %s", key, exc)
|
||
|
||
|
||
async def _seed_admin_state_from_json_if_needed() -> None:
|
||
"""Seed admin_kv from the JSON files on disk.
|
||
|
||
Controlled by ADMIN_DATA_SEED_FROM_JSON:
|
||
- "never": do nothing
|
||
- "auto": seed only when admin_kv has no rows yet (default, safe on every boot)
|
||
- "force": overwrite postgres with whatever the JSON files currently hold
|
||
|
||
The deployer exposes -SeedAdminData which sets this to "force" for one boot.
|
||
"""
|
||
mode = (os.environ.get("ADMIN_DATA_SEED_FROM_JSON", "auto") or "auto").strip().lower()
|
||
if mode == "never":
|
||
return
|
||
if not admin_db.is_enabled():
|
||
return
|
||
try:
|
||
if mode == "auto" and await admin_db.has_any_value():
|
||
return
|
||
seed_clients = _load_client_profiles_from_file()
|
||
seed_emails = sorted(_load_allowed_emails_from_file())
|
||
seed_drafts = _load_drafts_from_file()
|
||
if not seed_clients and not seed_emails and not seed_drafts:
|
||
return
|
||
if seed_clients:
|
||
await admin_db.set_kv("client_profiles", seed_clients)
|
||
if seed_emails:
|
||
await admin_db.set_kv("allowed_emails", {"emails": seed_emails})
|
||
if seed_drafts:
|
||
await admin_db.set_kv("drafts", seed_drafts)
|
||
logger.info(
|
||
"Seeded admin_kv from JSON (mode=%s): clients=%d emails=%d drafts=%d",
|
||
mode, len(seed_clients), len(seed_emails), len(seed_drafts),
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Admin seed from JSON failed: %s", exc)
|
||
|
||
|
||
async def _merge_legacy_seed_if_present() -> None:
|
||
"""Merge the shipped legacy-clients-seed.json into _client_profiles.
|
||
|
||
Add-only: never overwrites an email that already exists in the live data.
|
||
Idempotent: re-running on every boot is a no-op once the entries are in.
|
||
|
||
Writes the updated profiles back to the JSON file + admin_kv so the merged
|
||
state survives container restarts.
|
||
"""
|
||
global _client_profiles
|
||
|
||
seed_path = _LEGACY_SEED_FILE
|
||
if not seed_path.exists():
|
||
return
|
||
|
||
try:
|
||
seed = json.loads(seed_path.read_text(encoding="utf-8"))
|
||
except Exception as exc:
|
||
logger.warning("Legacy seed file unreadable (%s): %s", seed_path, exc)
|
||
return
|
||
|
||
if not isinstance(seed, dict) or not seed:
|
||
return
|
||
|
||
added: list[str] = []
|
||
skipped_existing = 0
|
||
|
||
for raw_email, profile in seed.items():
|
||
if not isinstance(raw_email, str) or not isinstance(profile, dict):
|
||
continue
|
||
email = raw_email.strip().lower()
|
||
if not email:
|
||
continue
|
||
if email == OWNER_EMAIL.strip().lower():
|
||
continue
|
||
if email in _client_profiles:
|
||
skipped_existing += 1
|
||
continue
|
||
_client_profiles[email] = profile
|
||
added.append(email)
|
||
|
||
if not added:
|
||
logger.info(
|
||
"Legacy seed already merged (existing=%d, candidates=%d).",
|
||
skipped_existing, len(seed),
|
||
)
|
||
return
|
||
|
||
snapshot = dict(_client_profiles)
|
||
try:
|
||
await asyncio.to_thread(_save_client_profiles_file, snapshot)
|
||
except Exception as exc:
|
||
logger.warning("Could not save client_profiles after legacy merge: %s", exc)
|
||
try:
|
||
await _persist_admin_state("client_profiles", snapshot)
|
||
except Exception as exc:
|
||
logger.warning("Could not persist client_profiles to postgres after legacy merge: %s", exc)
|
||
|
||
logger.info(
|
||
"Legacy seed merged: added=%d skipped_existing=%d total_after=%d",
|
||
len(added), skipped_existing, len(_client_profiles),
|
||
)
|
||
|
||
|
||
async def _load_allowed_emails_async() -> set[str]:
|
||
if admin_db.is_enabled():
|
||
data = await admin_db.get_kv("allowed_emails")
|
||
if isinstance(data, dict):
|
||
emails = data.get("emails", [])
|
||
if isinstance(emails, list):
|
||
seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()}
|
||
seed.update(e.lower() for e in emails if isinstance(e, str))
|
||
return seed
|
||
return _load_allowed_emails_from_file()
|
||
|
||
|
||
async def _load_client_profiles_async() -> dict[str, dict]:
|
||
if admin_db.is_enabled():
|
||
data = await admin_db.get_kv("client_profiles")
|
||
if isinstance(data, dict):
|
||
return data
|
||
return _load_client_profiles_from_file()
|
||
|
||
|
||
async def _load_drafts_async() -> dict:
|
||
if admin_db.is_enabled():
|
||
data = await admin_db.get_kv("drafts")
|
||
if isinstance(data, dict):
|
||
return data
|
||
return _load_drafts_from_file()
|
||
|
||
|
||
_allowed_emails: set[str] = _load_allowed_emails_from_file()
|
||
if OWNER_EMAIL:
|
||
_allowed_emails.add(OWNER_EMAIL.strip().lower())
|
||
_pending_codes: dict[str, dict] = {} # email -> {code, expires_at, attempts}
|
||
_active_sessions: dict[str, dict] = {} # token -> {email, expires_at}
|
||
_code_requests: dict[str, deque] = {} # email -> deque of monotonic timestamps
|
||
_client_profiles: dict[str, dict] = _load_client_profiles_from_file()
|
||
_drafts: dict[str, dict] = _load_drafts_from_file() # email -> {onboarding: {...}, contract: {...}}
|
||
_auth_failures_by_ip: dict[str, deque] = {} # ip -> deque of failure timestamps
|
||
_blocked_ips: dict[str, float] = {} # ip -> unblock_at (monotonic)
|
||
_auth_lock = asyncio.Lock()
|
||
_birthday_auto_task: asyncio.Task | None = None
|
||
|
||
logger.info("Auth: loaded %d allowed email(s)", len(_allowed_emails))
|
||
|
||
|
||
async def _require_session_email(request: Request) -> str:
|
||
auth_header = request.headers.get("Authorization", "")
|
||
token = auth_header.removeprefix("Bearer ").strip()
|
||
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="No token provided.")
|
||
|
||
async with _auth_lock:
|
||
session = _active_sessions.get(token)
|
||
if not session:
|
||
raise HTTPException(status_code=401, detail="Invalid session.")
|
||
if time.time() > session["expires_at"]:
|
||
_active_sessions.pop(token, None)
|
||
raise HTTPException(status_code=401, detail="Session expired. Please sign in again.")
|
||
return session["email"]
|
||
|
||
|
||
async def _require_owner_email(request: Request) -> str:
|
||
email = await _require_session_email(request)
|
||
if email not in CP_ADMIN_EMAILS:
|
||
raise HTTPException(status_code=403, detail="Owner access required.")
|
||
return email
|
||
|
||
|
||
async def _register_email(email: str) -> None:
|
||
normalized = email.strip().lower()
|
||
if not normalized:
|
||
return
|
||
async with _auth_lock:
|
||
if normalized not in _allowed_emails:
|
||
_allowed_emails.add(normalized)
|
||
snapshot = sorted(_allowed_emails)
|
||
await asyncio.to_thread(_save_allowed_emails_file, set(_allowed_emails))
|
||
await _persist_admin_state("allowed_emails", {"emails": snapshot})
|
||
logger.info("Auth: registered new allowed email: %s", normalized)
|
||
|
||
|
||
def _client_is_reachable(profile: dict) -> bool:
|
||
"""True if outreach (welcome pack, birthday email, etc.) should still target
|
||
this client. Excludes lifecycle states that mean the relationship has ended.
|
||
"""
|
||
lifecycle = profile.get("lifecycle")
|
||
if not isinstance(lifecycle, dict):
|
||
return True
|
||
return lifecycle.get("status") not in {"cancelled", "archived"}
|
||
|
||
|
||
async def _store_client_profile(email: str, profile: dict) -> None:
|
||
normalized = email.strip().lower()
|
||
if not normalized:
|
||
return
|
||
async with _auth_lock:
|
||
existing = _client_profiles.get(normalized, {})
|
||
merged = {
|
||
k: v
|
||
for k, v in {**existing, **profile}.items()
|
||
if v is not None and not (isinstance(v, str) and v == "")
|
||
}
|
||
if merged != existing:
|
||
_client_profiles[normalized] = merged
|
||
snapshot = dict(_client_profiles)
|
||
await asyncio.to_thread(_save_client_profiles_file, snapshot)
|
||
await _persist_admin_state("client_profiles", snapshot)
|
||
|
||
def _check_ip_blocked(ip: str, request_id: str) -> None:
|
||
now = time.monotonic()
|
||
unblock_at = _blocked_ips.get(ip)
|
||
if unblock_at is not None:
|
||
if now < unblock_at:
|
||
remaining = int(unblock_at - now)
|
||
logger.warning("[%s] auth: blocked ip=%s (%ds remaining)", request_id, ip, remaining)
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=f"Too many failed attempts. Try again in {remaining // 60 + 1} minute(s).",
|
||
headers={"Retry-After": str(remaining)},
|
||
)
|
||
else:
|
||
del _blocked_ips[ip]
|
||
|
||
|
||
def _record_auth_failure(ip: str, request_id: str, reason: str) -> None:
|
||
now = time.monotonic()
|
||
failures = _auth_failures_by_ip.setdefault(ip, deque())
|
||
while failures and now - failures[0] > AUTH_IP_FAILURE_WINDOW:
|
||
failures.popleft()
|
||
failures.append(now)
|
||
logger.warning("[%s] auth: failure ip=%s reason=%r total_in_window=%d", request_id, ip, reason, len(failures))
|
||
if len(failures) >= AUTH_IP_MAX_FAILURES:
|
||
_blocked_ips[ip] = now + AUTH_IP_BLOCK_DURATION
|
||
logger.warning(
|
||
"[%s] auth: ip=%s BLOCKED for %ds after %d failures",
|
||
request_id, ip, AUTH_IP_BLOCK_DURATION, len(failures),
|
||
)
|
||
|
||
|
||
class _BodySizeLimitMiddleware:
|
||
"""Reject requests whose Content-Length exceeds MAX_REQUEST_BODY_BYTES.
|
||
|
||
Defence-in-depth alongside nginx ``client_max_body_size``. Streaming
|
||
requests without a Content-Length header are tracked byte-by-byte and
|
||
short-circuited if they overflow the cap.
|
||
"""
|
||
|
||
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
|
||
self.app = app
|
||
self.max_bytes = max_bytes
|
||
|
||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||
if scope["type"] != "http":
|
||
await self.app(scope, receive, send)
|
||
return
|
||
|
||
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
|
||
declared = headers.get("content-length")
|
||
if declared is not None:
|
||
try:
|
||
if int(declared) > self.max_bytes:
|
||
await _send_413(send)
|
||
return
|
||
except ValueError:
|
||
pass
|
||
|
||
received = 0
|
||
overflowed = False
|
||
|
||
async def _wrapped_receive():
|
||
nonlocal received, overflowed
|
||
message = await receive()
|
||
if message["type"] == "http.request":
|
||
received += len(message.get("body", b""))
|
||
if received > self.max_bytes:
|
||
overflowed = True
|
||
return {"type": "http.disconnect"}
|
||
return message
|
||
|
||
if overflowed:
|
||
await _send_413(send)
|
||
return
|
||
await self.app(scope, _wrapped_receive, send)
|
||
|
||
|
||
async def _send_413(send: Send) -> None:
|
||
await send({
|
||
"type": "http.response.start",
|
||
"status": 413,
|
||
"headers": [(b"content-type", b"application/json")],
|
||
})
|
||
await send({
|
||
"type": "http.response.body",
|
||
"body": b'{"detail":"Request body too large."}',
|
||
})
|
||
|
||
|
||
app.add_middleware(_BodySizeLimitMiddleware, max_bytes=MAX_REQUEST_BODY_BYTES)
|
||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(TRUSTED_HOSTS))
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=list(CORS_ALLOWED_ORIGINS),
|
||
allow_methods=["POST", "GET"],
|
||
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
|
||
allow_credentials=False,
|
||
max_age=600,
|
||
)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def _request_logging_middleware(request: Request, call_next):
|
||
request_id = uuid.uuid4().hex[:8]
|
||
request.state.request_id = request_id
|
||
started = time.monotonic()
|
||
try:
|
||
response = await call_next(request)
|
||
except Exception:
|
||
elapsed_ms = (time.monotonic() - started) * 1000
|
||
logger.exception(
|
||
"[%s] %s %s crashed after %.0fms",
|
||
request_id, request.method, request.url.path, elapsed_ms,
|
||
)
|
||
raise
|
||
elapsed_ms = (time.monotonic() - started) * 1000
|
||
logger.info(
|
||
"[%s] %s %s → %d (%.0fms)",
|
||
request_id, request.method, request.url.path, response.status_code, elapsed_ms,
|
||
)
|
||
response.headers["X-Request-ID"] = request_id
|
||
return response
|
||
|
||
|
||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
def _get_ip(request: Request) -> str:
|
||
forwarded = request.headers.get("x-forwarded-for")
|
||
if forwarded:
|
||
return forwarded.split(",")[0].strip()
|
||
return request.client.host if request.client else "unknown"
|
||
|
||
|
||
def _is_deploy_smoke(request: Request) -> bool:
|
||
"""True when the request carries a matching X-Deploy-Smoke header.
|
||
|
||
Used by the deploy script to verify the form endpoints are reachable and
|
||
parse a valid payload, without producing a real submission. Disabled
|
||
entirely when DEPLOY_SMOKE_SECRET is unset.
|
||
"""
|
||
if not DEPLOY_SMOKE_SECRET:
|
||
return False
|
||
presented = request.headers.get("x-deploy-smoke") or ""
|
||
if not presented:
|
||
return False
|
||
return secrets.compare_digest(presented, DEPLOY_SMOKE_SECRET)
|
||
|
||
|
||
_submit_attempts_by_ip: dict[str, deque[float]] = {}
|
||
_submit_attempts_by_email: dict[str, deque[float]] = {}
|
||
_submit_rate_limit_lock = asyncio.Lock()
|
||
|
||
|
||
def _trimmed(value: str) -> str:
|
||
return value.strip()
|
||
|
||
|
||
def _prune_attempts(attempts: deque[float], now: float, window_seconds: int) -> None:
|
||
while attempts and now - attempts[0] > window_seconds:
|
||
attempts.popleft()
|
||
|
||
|
||
def _seconds_until_allowed(last_attempt_at: float, now: float, min_interval_seconds: int) -> int:
|
||
retry_after = max(1, int(min_interval_seconds - (now - last_attempt_at)))
|
||
return retry_after
|
||
|
||
|
||
async def _enforce_submit_rate_limits(request_id: str, ip: str, email: str) -> None:
|
||
now = time.monotonic()
|
||
normalized_email = email.strip().lower()
|
||
|
||
async with _submit_rate_limit_lock:
|
||
ip_attempts = _submit_attempts_by_ip.setdefault(ip, deque())
|
||
email_attempts = _submit_attempts_by_email.setdefault(normalized_email, deque())
|
||
|
||
_prune_attempts(ip_attempts, now, RATE_LIMIT_WINDOW_SECONDS)
|
||
_prune_attempts(email_attempts, now, RATE_LIMIT_WINDOW_SECONDS)
|
||
|
||
if ip_attempts and now - ip_attempts[-1] < RATE_LIMIT_MIN_INTERVAL_SECONDS:
|
||
retry_after = _seconds_until_allowed(ip_attempts[-1], now, RATE_LIMIT_MIN_INTERVAL_SECONDS)
|
||
logger.warning(
|
||
"[%s] rate limited: ip=%s submitted again after %.1fs (minimum %ss)",
|
||
request_id,
|
||
ip,
|
||
now - ip_attempts[-1],
|
||
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
||
)
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=f"Please wait about {retry_after} seconds before trying again.",
|
||
)
|
||
|
||
if len(ip_attempts) >= RATE_LIMIT_MAX_PER_IP:
|
||
logger.warning(
|
||
"[%s] rate limited: ip=%s exceeded %d submissions in %ss",
|
||
request_id,
|
||
ip,
|
||
RATE_LIMIT_MAX_PER_IP,
|
||
RATE_LIMIT_WINDOW_SECONDS,
|
||
)
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail="Too many enquiries from this connection. Please try again a little later.",
|
||
)
|
||
|
||
if len(email_attempts) >= RATE_LIMIT_MAX_PER_EMAIL:
|
||
logger.warning(
|
||
"[%s] rate limited: email=%s exceeded %d submissions in %ss",
|
||
request_id,
|
||
normalized_email,
|
||
RATE_LIMIT_MAX_PER_EMAIL,
|
||
RATE_LIMIT_WINDOW_SECONDS,
|
||
)
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail="That email address has reached the enquiry limit for now. Please try again later.",
|
||
)
|
||
|
||
ip_attempts.append(now)
|
||
email_attempts.append(now)
|
||
|
||
|
||
def _enforce_form_timing(request_id: str, data: BaseSubmission) -> None:
|
||
if data.formStartedAt is None or data.formStartedAt <= 0:
|
||
logger.warning("[%s] rejected: missing or invalid formStartedAt", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please refresh the page and try again.",
|
||
)
|
||
|
||
elapsed_seconds = (time.time() * 1000 - data.formStartedAt) / 1000
|
||
|
||
if elapsed_seconds < FORM_MIN_SECONDS:
|
||
logger.warning(
|
||
"[%s] rejected: form submitted too quickly (%.2fs < %ss)",
|
||
request_id,
|
||
elapsed_seconds,
|
||
FORM_MIN_SECONDS,
|
||
)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please take a moment to fill in the form before sending it.",
|
||
)
|
||
|
||
if elapsed_seconds > FORM_MAX_SECONDS:
|
||
logger.warning(
|
||
"[%s] rejected: stale form submission (%.0fs > %ss)",
|
||
request_id,
|
||
elapsed_seconds,
|
||
FORM_MAX_SECONDS,
|
||
)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="This form has been open for too long. Please refresh the page and try again.",
|
||
)
|
||
|
||
|
||
def _is_honeypot_triggered(data: BaseSubmission) -> bool:
|
||
return bool(_trimmed(data.website))
|
||
|
||
|
||
def _is_general_enquiry(data: BookingSubmission) -> bool:
|
||
return _trimmed(data.enquiryType).lower() == "general"
|
||
|
||
|
||
def _enquiry_type_label(data: BookingSubmission) -> str:
|
||
return "General enquiry" if _is_general_enquiry(data) else "Booking enquiry"
|
||
|
||
|
||
def _validate_submission(request_id: str, data: BookingSubmission) -> None:
|
||
enquiry_type = _trimmed(data.enquiryType).lower()
|
||
|
||
if enquiry_type not in {"booking", "general"}:
|
||
logger.warning("[%s] rejected: invalid enquiryType=%r", request_id, data.enquiryType)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please choose a valid enquiry type and try again.",
|
||
)
|
||
|
||
if not _trimmed(data.fullName):
|
||
logger.warning("[%s] rejected: missing full name", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please enter your full name.",
|
||
)
|
||
|
||
if not _trimmed(data.phone):
|
||
logger.warning("[%s] rejected: missing phone number", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please enter your contact number.",
|
||
)
|
||
|
||
if _is_general_enquiry(data):
|
||
if not ENABLE_GENERAL_ENQUIRIES:
|
||
logger.warning("[%s] rejected: general enquiries are disabled", request_id)
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="General enquiries are currently unavailable through this form.",
|
||
)
|
||
if not _trimmed(data.message):
|
||
logger.warning("[%s] rejected: missing general enquiry message", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please tell us how we can help.",
|
||
)
|
||
return
|
||
|
||
if not _trimmed(data.petName):
|
||
logger.warning("[%s] rejected: missing pet name", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please enter your dog's name.",
|
||
)
|
||
|
||
if not _trimmed(data.location):
|
||
logger.warning("[%s] rejected: missing location", request_id)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Please enter your location.",
|
||
)
|
||
|
||
|
||
def _normalize_submission(data: BookingSubmission) -> None:
|
||
data.enquiryType = "general" if _is_general_enquiry(data) else "booking"
|
||
data.fullName = _trimmed(data.fullName)
|
||
data.phone = _trimmed(data.phone)
|
||
data.petName = _trimmed(data.petName)
|
||
data.location = _trimmed(data.location)
|
||
data.message = _trimmed(data.message)
|
||
data.referrer = _trimmed(data.referrer)
|
||
data.page = _trimmed(data.page)
|
||
data.services = [_trimmed(service) for service in data.services if _trimmed(service)]
|
||
data.journey = [_trimmed(step) for step in data.journey if _trimmed(step)][:12]
|
||
data.stepChanges = max(0, data.stepChanges)
|
||
|
||
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
|
||
value = getattr(data, field_name)
|
||
if value is None or value <= 0:
|
||
setattr(data, field_name, None)
|
||
|
||
if _is_general_enquiry(data):
|
||
data.petName = ""
|
||
data.location = ""
|
||
data.services = []
|
||
|
||
|
||
def _validate_onboarding_submission(request_id: str, data: OnboardingSubmission) -> None:
|
||
if not _trimmed(data.fullName):
|
||
logger.warning("[%s] onboarding rejected: missing full name", request_id)
|
||
raise HTTPException(status_code=400, detail="Please enter your full name.")
|
||
|
||
if not _trimmed(data.phone):
|
||
logger.warning("[%s] onboarding rejected: missing phone", request_id)
|
||
raise HTTPException(status_code=400, detail="Please enter your phone number.")
|
||
|
||
required_fields = {
|
||
"address": "Please enter your address.",
|
||
"dogName": "Please enter your dog's name.",
|
||
"dogBreed": "Please enter your dog's breed.",
|
||
"vetName": "Please enter your vet clinic name.",
|
||
"vetPhone": "Please enter your vet phone number.",
|
||
"emergencyContactName": "Please enter an emergency contact name.",
|
||
"emergencyContactPhone": "Please enter an emergency contact phone number.",
|
||
}
|
||
|
||
for field_name, message in required_fields.items():
|
||
if not _trimmed(getattr(data, field_name)):
|
||
logger.warning("[%s] onboarding rejected: missing %s", request_id, field_name)
|
||
raise HTTPException(status_code=400, detail=message)
|
||
|
||
if not data.servicesNeeded:
|
||
logger.warning("[%s] onboarding rejected: missing services", request_id)
|
||
raise HTTPException(status_code=400, detail="Please choose at least one service.")
|
||
|
||
if not data.councilRegistrationConfirmed:
|
||
raise HTTPException(status_code=400, detail="Please confirm council registration.")
|
||
|
||
if not data.vaccinationsConfirmed:
|
||
raise HTTPException(status_code=400, detail="Please confirm vaccinations are current.")
|
||
|
||
if not data.emergencyVetConsent:
|
||
raise HTTPException(status_code=400, detail="Please confirm emergency veterinary consent.")
|
||
|
||
if not data.termsAccepted:
|
||
raise HTTPException(status_code=400, detail="Please confirm the onboarding declaration.")
|
||
|
||
signature = _trimmed(data.signatureDataUrl)
|
||
if not signature.startswith("data:image/png;base64,") or len(signature) < 128:
|
||
logger.warning("[%s] onboarding rejected: invalid signature payload", request_id)
|
||
raise HTTPException(status_code=400, detail="Please add your signature before sending.")
|
||
|
||
|
||
def _normalize_onboarding_submission(data: OnboardingSubmission) -> None:
|
||
data.fullName = _trimmed(data.fullName)
|
||
data.phone = _trimmed(data.phone)
|
||
data.address = _trimmed(data.address)
|
||
data.dogName = _trimmed(data.dogName)
|
||
data.dogBreed = _trimmed(data.dogBreed)
|
||
data.dogAge = _trimmed(data.dogAge)
|
||
data.temperament = _trimmed(data.temperament)
|
||
data.medicalNotes = _trimmed(data.medicalNotes)
|
||
data.accessInstructions = _trimmed(data.accessInstructions)
|
||
data.vetName = _trimmed(data.vetName)
|
||
data.vetPhone = _trimmed(data.vetPhone)
|
||
data.emergencyContactName = _trimmed(data.emergencyContactName)
|
||
data.emergencyContactPhone = _trimmed(data.emergencyContactPhone)
|
||
data.referrer = _trimmed(data.referrer)
|
||
data.page = _trimmed(data.page)
|
||
data.servicesNeeded = [_trimmed(service) for service in data.servicesNeeded if _trimmed(service)][:8]
|
||
|
||
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
|
||
value = getattr(data, field_name)
|
||
if value is None or value <= 0:
|
||
setattr(data, field_name, None)
|
||
|
||
|
||
def _parse_ua(ua: str) -> str:
|
||
if not ua:
|
||
return "Unknown"
|
||
browsers = [("Edg/", "Edge"), ("OPR/", "Opera"), ("Chrome/", "Chrome"),
|
||
("Firefox/", "Firefox"), ("Safari/", "Safari")]
|
||
systems = [("Windows NT 10", "Windows 10/11"), ("Windows NT 6", "Windows 8"),
|
||
("Mac OS X", "macOS"), ("iPhone", "iPhone"), ("iPad", "iPad"),
|
||
("Android", "Android"), ("Linux", "Linux")]
|
||
browser = next((n for p, n in browsers if p in ua), "Unknown browser")
|
||
system = next((n for p, n in systems if p in ua), "Unknown OS")
|
||
return f"{browser} on {system}"
|
||
|
||
|
||
def _detail_row(label: str, value: str) -> str:
|
||
if not value:
|
||
return ""
|
||
return f"""
|
||
<tr>
|
||
<td style="padding:8px 0;color:#888;font-size:13px;white-space:nowrap;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;width:130px;">{label}</td>
|
||
<td style="padding:8px 0 8px 16px;color:#213021;font-size:14px;font-weight:500;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;">{value}</td>
|
||
</tr>"""
|
||
|
||
|
||
def _meta_row(label: str, value: str) -> str:
|
||
if not value:
|
||
return ""
|
||
return f"""
|
||
<tr>
|
||
<td style="padding:5px 0;color:#aaa;font-size:12px;white-space:nowrap;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;width:100px;">{label}</td>
|
||
<td style="padding:5px 0 5px 16px;color:#666;font-size:12px;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;word-break:break-all;">{value}</td>
|
||
</tr>"""
|
||
|
||
|
||
def _format_duration_ms(duration_ms: int | None) -> str:
|
||
if duration_ms is None or duration_ms < 0:
|
||
return ""
|
||
|
||
total_seconds = int(round(duration_ms / 1000))
|
||
minutes, seconds = divmod(total_seconds, 60)
|
||
hours, minutes = divmod(minutes, 60)
|
||
|
||
if hours > 0:
|
||
return f"{hours}h {minutes}m"
|
||
if minutes > 0:
|
||
return f"{minutes}m {seconds}s"
|
||
return f"{seconds}s"
|
||
|
||
|
||
def _duration_between(start_ms: int | None, end_ms: int | None) -> str:
|
||
if start_ms is None or end_ms is None or end_ms < start_ms:
|
||
return ""
|
||
return _format_duration_ms(end_ms - start_ms)
|
||
|
||
|
||
def _journey_text(journey: list[str]) -> str:
|
||
if not journey:
|
||
return ""
|
||
return " -> ".join(journey)
|
||
|
||
|
||
# ── Email templates ──────────────────────────────────────────────────────────
|
||
|
||
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
|
||
badge = f'<div style="margin-top:20px;">{badge_html}</div>' if badge_html else ""
|
||
sub = f"""<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:13px;color:#7aaa7a;letter-spacing:0.04em;margin-top:8px;">
|
||
{subtitle}</div>""" if subtitle else ""
|
||
return f"""
|
||
<tr>
|
||
<td style="background:#213021;padding:36px 48px 32px;text-align:center;">
|
||
<img src="{LOGO_URL}" width="161" height="32" alt="GoodWalk"
|
||
style="display:inline-block;max-width:161px;height:auto;border:0;">
|
||
{sub}
|
||
{badge}
|
||
</td>
|
||
</tr>"""
|
||
|
||
|
||
def client_email(data: BookingSubmission) -> str:
|
||
is_general = _is_general_enquiry(data)
|
||
services_text = ", ".join(data.services) if data.services else "Not specified"
|
||
enquiry_summary_rows = [
|
||
_detail_row("Your name", data.fullName),
|
||
_detail_row("Email", str(data.email)),
|
||
_detail_row("Phone", data.phone),
|
||
_detail_row("Type", _enquiry_type_label(data)),
|
||
]
|
||
|
||
if is_general:
|
||
if data.message:
|
||
enquiry_summary_rows.append(_detail_row("Message", data.message))
|
||
intro_html = (
|
||
"We’ve received your message and we will be in touch shortly."
|
||
)
|
||
next_steps_html = (
|
||
"We will review your message and reply within 1 business day."
|
||
)
|
||
logo_subtitle = "General enquiries and dog walking support"
|
||
else:
|
||
enquiry_summary_rows.extend(
|
||
[
|
||
_detail_row("Dog’s name", data.petName),
|
||
_detail_row("Location", data.location),
|
||
_detail_row("Services", services_text),
|
||
]
|
||
)
|
||
if data.message:
|
||
enquiry_summary_rows.append(_detail_row("About the dog", data.message))
|
||
intro_html = (
|
||
"We’ve received your enquiry and we will be in touch shortly to arrange "
|
||
"a <strong style=\"color:#213021;\">Meet & Greet</strong> with you and "
|
||
f"{data.petName}."
|
||
)
|
||
next_steps_html = (
|
||
"We will review your details and reach out within 1 business day "
|
||
"to schedule a free Meet & Greet. No commitment required — just a "
|
||
f"chance for {data.petName} to make a new best friend."
|
||
)
|
||
logo_subtitle = "Professional dog walking services"
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>We received your enquiry</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="background:#f2f2f0;padding:40px 16px;">
|
||
<tr><td align="center">
|
||
|
||
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
||
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||
|
||
{_logo_header(subtitle=logo_subtitle)}
|
||
|
||
<!-- Body -->
|
||
<tr>
|
||
<td style="background:#ffffff;padding:48px 48px 40px;">
|
||
|
||
<h1 style="margin:0 0 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:26px;font-weight:700;color:#213021;line-height:1.2;">
|
||
Thanks, {data.fullName.split()[0]}! 🐾
|
||
</h1>
|
||
<p style="margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:16px;color:#555;line-height:1.65;">
|
||
{intro_html}
|
||
</p>
|
||
|
||
<!-- Details card -->
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="background:#f8f7f4;border-radius:12px;margin-bottom:36px;">
|
||
<tr>
|
||
<td style="padding:28px 32px;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.1em;
|
||
color:#888;text-transform:uppercase;margin-bottom:20px;">
|
||
Your enquiry summary
|
||
</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{"".join(enquiry_summary_rows)}
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<!-- What's next -->
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="border-left:3px solid #FFD100;margin-bottom:36px;">
|
||
<tr>
|
||
<td style="padding:4px 0 4px 20px;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:13px;font-weight:700;color:#213021;margin-bottom:6px;">
|
||
What happens next?
|
||
</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;color:#666;line-height:1.6;">
|
||
{next_steps_html}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;color:#888;line-height:1.6;">
|
||
Questions? Just reply to this email or reach us at 022 642 1011.
|
||
</p>
|
||
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Footer -->
|
||
<tr>
|
||
<td style="background:#213021;padding:24px 48px;text-align:center;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:12px;color:#5a8a5a;line-height:1.6;">
|
||
GoodWalk · Auckland, New Zealand<br>
|
||
<a href="https://www.goodwalk.co.nz" style="color:#7aaa7a;text-decoration:none;">
|
||
goodwalk.co.nz
|
||
</a>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||
is_general = _is_general_enquiry(data)
|
||
services_text = ", ".join(data.services) if data.services else "—"
|
||
now = datetime.now()
|
||
submitted_at = now.strftime("%d %b %Y at %I:%M %p").lstrip("0")
|
||
first_name = data.fullName.split()[0] if data.fullName.strip() else "them"
|
||
email_title = "New GoodWalk Enquiry" if is_general else "New GoodWalk Lead"
|
||
|
||
message_label = "Message" if is_general else "About the dog"
|
||
message_block = f"""
|
||
<tr>
|
||
<td colspan="2" style="padding:16px 0 0;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
||
text-transform:uppercase;margin-bottom:8px;">{message_label}</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
||
border-radius:8px;padding:14px 16px;">{data.message}</div>
|
||
</td>
|
||
</tr>""" if data.message else ""
|
||
|
||
badge = """<div style="display:inline-block;background:#FFD100;border-radius:100px;
|
||
padding:10px 28px;">
|
||
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:18px;font-weight:700;color:#213021;">
|
||
📩 New enquiry!
|
||
</span>
|
||
</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:12px;color:#5a8a5a;margin-top:12px;">
|
||
Submitted {submitted_at}
|
||
</div>""".format(submitted_at=submitted_at)
|
||
|
||
referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark")
|
||
page_row = _meta_row("Page", data.page) if data.page else ""
|
||
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
|
||
page_time_row = _meta_row("Time on page", _duration_between(data.pageEnteredAt, data.sendClickedAt))
|
||
active_time_row = _meta_row("Active form time", _duration_between(data.firstInteractionAt, data.sendClickedAt))
|
||
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
|
||
step_changes_row = _meta_row("Step changes", str(data.stepChanges)) if data.stepChanges else ""
|
||
journey_row = _meta_row("Journey", _journey_text(data.journey))
|
||
detail_heading = "Enquiry details" if is_general else "Dog & services"
|
||
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
|
||
|
||
if is_general:
|
||
if data.petName:
|
||
detail_rows.append(_detail_row("Dog", data.petName))
|
||
if data.location:
|
||
detail_rows.append(_detail_row("Location", data.location))
|
||
else:
|
||
detail_rows.extend(
|
||
[
|
||
_detail_row("Dog", data.petName),
|
||
_detail_row("Location", data.location),
|
||
_detail_row("Services", services_text),
|
||
]
|
||
)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta name="color-scheme" content="light only">
|
||
<meta name="supported-color-schemes" content="light">
|
||
<title>{email_title}</title>
|
||
<style>
|
||
:root {{
|
||
color-scheme: light only;
|
||
supported-color-schemes: light;
|
||
}}
|
||
|
||
body,
|
||
table,
|
||
td,
|
||
div,
|
||
p,
|
||
span,
|
||
a {{
|
||
forced-color-adjust: none !important;
|
||
-webkit-text-size-adjust: 100%;
|
||
}}
|
||
|
||
.gw-owner-body {{
|
||
background: #f2f2f0 !important;
|
||
color: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-shell {{
|
||
background: #ffffff !important;
|
||
}}
|
||
|
||
.gw-owner-dark-panel {{
|
||
background: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-email-chip {{
|
||
display: inline-block;
|
||
background: #ffffff !important;
|
||
color: #213021 !important;
|
||
border-radius: 10px;
|
||
padding: 12px 14px;
|
||
border: 1px solid #d9dfd9;
|
||
text-decoration: none !important;
|
||
}}
|
||
|
||
.gw-owner-email-chip,
|
||
.gw-owner-email-chip a,
|
||
a.gw-owner-email-chip {{
|
||
color: #213021 !important;
|
||
}}
|
||
|
||
@media (prefers-color-scheme: dark) {{
|
||
html,
|
||
body,
|
||
.gw-owner-body {{
|
||
background: #f2f2f0 !important;
|
||
color: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-shell,
|
||
.gw-owner-shell td {{
|
||
background: #ffffff !important;
|
||
color: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-dark-panel,
|
||
.gw-owner-dark-panel td {{
|
||
background: #213021 !important;
|
||
}}
|
||
|
||
.gw-owner-email-chip,
|
||
.gw-owner-email-chip a,
|
||
a.gw-owner-email-chip {{
|
||
background: #ffffff !important;
|
||
color: #213021 !important;
|
||
}}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body class="gw-owner-body" style="margin:0;padding:0;background:#f2f2f0;color:#213021;">
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0"
|
||
style="background:#f2f2f0;padding:40px 16px;">
|
||
<tr><td align="center">
|
||
|
||
<table class="gw-owner-shell" width="600" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
|
||
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
||
box-shadow:0 4px 24px rgba(0,0,0,0.08);background:#ffffff;">
|
||
|
||
{_logo_header(badge_html=badge)}
|
||
|
||
<!-- Body -->
|
||
<tr>
|
||
<td bgcolor="#ffffff" style="background:#ffffff;padding:40px 48px 36px;color:#213021;">
|
||
|
||
<!-- Quick contact -->
|
||
<table class="gw-owner-dark-panel" width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#213021"
|
||
style="background:#213021;border-radius:12px;margin-bottom:28px;">
|
||
<tr>
|
||
<td bgcolor="#213021" style="padding:22px 24px;background:#213021;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;
|
||
text-transform:uppercase;margin-bottom:10px;">
|
||
Quick contact
|
||
</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;color:#d8e6d8;line-height:1.6;margin-bottom:10px;">
|
||
Email {first_name} directly:
|
||
</div>
|
||
<div style="margin-bottom:12px;">
|
||
<a href="mailto:{data.email}" class="gw-owner-email-chip"
|
||
style="display:inline-block;background:#ffffff;color:#213021 !important;
|
||
font-family:Menlo,Consolas,'SFMono-Regular',monospace;
|
||
font-size:20px;font-weight:700;line-height:1.4;word-break:break-all;
|
||
border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;
|
||
text-decoration:none;">
|
||
{data.email}
|
||
</a>
|
||
</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:12px;color:#b7cbb7;line-height:1.6;">
|
||
Tap and hold the address to copy on iPhone, or tap below to open a new email.
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<!-- Owner details -->
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
||
text-transform:uppercase;margin-bottom:16px;">Owner details</div>
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
<tr>
|
||
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;">Name</td>
|
||
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
|
||
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
|
||
'Segoe UI',sans-serif;vertical-align:top;">{data.fullName}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;">Email</td>
|
||
<td style="padding:6px 0 6px 16px;font-size:14px;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;">
|
||
<a href="mailto:{data.email}" style="color:#213021;font-weight:500;
|
||
text-decoration:none;">{data.email}</a>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;">Phone</td>
|
||
<td style="padding:6px 0 6px 16px;font-size:14px;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
vertical-align:top;">
|
||
<a href="tel:{data.phone}" style="color:#213021;font-weight:500;
|
||
text-decoration:none;">{data.phone}</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<!-- Dog & service details -->
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
||
text-transform:uppercase;margin-bottom:16px;">{detail_heading}</div>
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{"".join(detail_rows)}
|
||
{message_block}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<!-- CTA buttons -->
|
||
<table cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:32px;">
|
||
<tr>
|
||
<td style="padding-right:12px;">
|
||
<a href="mailto:{data.email}"
|
||
style="display:inline-block;background:#213021;color:#FFD100;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;font-weight:600;text-decoration:none;
|
||
border-radius:8px;padding:12px 24px;">
|
||
Email {first_name}
|
||
</a>
|
||
</td>
|
||
<td>
|
||
<a href="tel:{data.phone}"
|
||
style="display:inline-block;background:#f8f7f4;color:#213021;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;font-weight:600;text-decoration:none;
|
||
border-radius:8px;padding:12px 24px;border:1px solid #e0e0d8;">
|
||
Call {data.phone}
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<!-- Session info -->
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="border-top:1px solid #eeeee8;padding-top:20px;">
|
||
<tr><td>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#ccc;
|
||
text-transform:uppercase;margin-bottom:12px;">Session info</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_meta_row("IP address", ip)}
|
||
{_meta_row("Browser", browser)}
|
||
{visit_time_row}
|
||
{page_time_row}
|
||
{active_time_row}
|
||
{form_time_row}
|
||
{step_changes_row}
|
||
{referrer_row}
|
||
{page_row}
|
||
{journey_row}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Footer -->
|
||
<tr>
|
||
<td style="background:#f8f7f4;padding:18px 48px;text-align:center;
|
||
border-top:1px solid #e8e8e4;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:12px;color:#bbb;">
|
||
Sent automatically by GoodWalk enquiry form
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def owner_onboarding_email(data: OnboardingSubmission, ip: str, browser: str) -> str:
|
||
submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0")
|
||
services_text = ", ".join(data.servicesNeeded)
|
||
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
|
||
page_time_row = _meta_row("Time on page", _duration_between(data.pageEnteredAt, data.sendClickedAt))
|
||
active_time_row = _meta_row("Active form time", _duration_between(data.firstInteractionAt, data.sendClickedAt))
|
||
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
|
||
referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark")
|
||
page_row = _meta_row("Page", data.page) if data.page else ""
|
||
|
||
dog_notes_block = f"""
|
||
<tr>
|
||
<td colspan="2" style="padding:16px 0 0;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
||
text-transform:uppercase;margin-bottom:8px;">Temperament and routine</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
||
border-radius:8px;padding:14px 16px;">{data.temperament}</div>
|
||
</td>
|
||
</tr>""" if data.temperament else ""
|
||
|
||
medical_block = f"""
|
||
<tr>
|
||
<td colspan="2" style="padding:16px 0 0;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
||
text-transform:uppercase;margin-bottom:8px;">Medical notes</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
||
border-radius:8px;padding:14px 16px;">{data.medicalNotes}</div>
|
||
</td>
|
||
</tr>""" if data.medicalNotes else ""
|
||
|
||
access_block = f"""
|
||
<tr>
|
||
<td colspan="2" style="padding:16px 0 0;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
||
text-transform:uppercase;margin-bottom:8px;">Home access instructions</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
||
border-radius:8px;padding:14px 16px;">{data.accessInstructions}</div>
|
||
</td>
|
||
</tr>""" if data.accessInstructions else ""
|
||
|
||
signature_block = f"""
|
||
<div style="margin-top:16px;border-radius:16px;background:#ffffff;border:1px solid #e3e3db;padding:14px 14px 10px;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
||
text-transform:uppercase;margin-bottom:10px;">Captured signature</div>
|
||
<img src="{data.signatureDataUrl}" alt="Client signature" style="display:block;max-width:100%;height:auto;border-radius:10px;background:#fff;">
|
||
</div>"""
|
||
|
||
badge = f"""<div style="display:inline-block;background:#FFD100;border-radius:100px;
|
||
padding:10px 28px;">
|
||
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:18px;font-weight:700;color:#213021;">
|
||
✍ New onboarding form
|
||
</span>
|
||
</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:12px;color:#5a8a5a;margin-top:12px;">
|
||
Submitted {submitted_at}
|
||
</div>"""
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>New GoodWalk onboarding form</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:40px 16px;">
|
||
<tr><td align="center">
|
||
<table width="680" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="max-width:680px;width:100%;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||
|
||
{_logo_header(badge_html=badge, subtitle="Signed onboarding form")}
|
||
|
||
<tr>
|
||
<td style="background:#ffffff;padding:38px 40px 34px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="background:#213021;border-radius:12px;margin-bottom:26px;">
|
||
<tr>
|
||
<td style="padding:22px 24px;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;text-transform:uppercase;margin-bottom:10px;">
|
||
Quick contact
|
||
</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;color:#d8e6d8;line-height:1.6;margin-bottom:10px;">
|
||
Reply directly to the owner or call them back:
|
||
</div>
|
||
<div style="margin-bottom:10px;">
|
||
<a href="mailto:{data.email}" style="display:inline-block;background:#ffffff;color:#213021;text-decoration:none;border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;font-family:Menlo,Consolas,'SFMono-Regular',monospace;font-size:18px;font-weight:700;">{data.email}</a>
|
||
</div>
|
||
<a href="tel:{data.phone}" style="display:inline-block;background:#ffd100;color:#213021;text-decoration:none;border-radius:999px;padding:10px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;font-weight:700;">Call {data.phone}</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;text-transform:uppercase;margin-bottom:16px;">Owner details</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Name", data.fullName)}
|
||
{_detail_row("Email", str(data.email))}
|
||
{_detail_row("Phone", data.phone)}
|
||
{_detail_row("Address", data.address)}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;text-transform:uppercase;margin-bottom:16px;">Dog and service details</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Dog", data.dogName)}
|
||
{_detail_row("Breed", data.dogBreed)}
|
||
{_detail_row("Age", data.dogAge or "—")}
|
||
{_detail_row("Service", services_text)}
|
||
{dog_notes_block}
|
||
{medical_block}
|
||
{access_block}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;text-transform:uppercase;margin-bottom:16px;">Safety details</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Vet clinic", data.vetName)}
|
||
{_detail_row("Vet phone", data.vetPhone)}
|
||
{_detail_row("Emergency contact", data.emergencyContactName)}
|
||
{_detail_row("Emergency phone", data.emergencyContactPhone)}
|
||
{_detail_row("Council registration", "Confirmed")}
|
||
{_detail_row("Vaccinations", "Confirmed")}
|
||
{_detail_row("Emergency consent", "Confirmed")}
|
||
{_detail_row("Declaration", "Signed")}
|
||
</table>
|
||
{signature_block}
|
||
</td></tr>
|
||
</table>
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-top:1px solid #eeeee8;padding-top:20px;">
|
||
<tr><td>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.08em;color:#ccc;text-transform:uppercase;margin-bottom:12px;">Session info</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_meta_row("IP address", ip)}
|
||
{_meta_row("Browser", browser)}
|
||
{visit_time_row}
|
||
{page_time_row}
|
||
{active_time_row}
|
||
{form_time_row}
|
||
{referrer_row}
|
||
{page_row}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
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", "<br>")
|
||
)
|
||
|
||
|
||
def owner_onboarding_pdf_html(data: OnboardingSubmission) -> str:
|
||
"""Clean, full-width, black-and-white, Arial HTML for the printable onboarding PDF."""
|
||
submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0")
|
||
|
||
snapshot = data.submissionSnapshot or {}
|
||
sections = snapshot.get("sections") if isinstance(snapshot, dict) else None
|
||
|
||
def render_value(value: Any) -> str:
|
||
if isinstance(value, list):
|
||
items = [_pdf_escape(item) for item in value if str(item).strip()]
|
||
return ", ".join(items) if items else "—"
|
||
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 += (
|
||
"<tr>"
|
||
f"<th>{label}</th>"
|
||
f"<td>{render_value(field.get('value'))}</td>"
|
||
"</tr>"
|
||
)
|
||
if rows_html:
|
||
sections_html_parts.append(
|
||
f"<section class='pdf-section'>"
|
||
f"<h2>{title}</h2>"
|
||
f"<table class='pdf-table'><tbody>{rows_html}</tbody></table>"
|
||
f"</section>"
|
||
)
|
||
else:
|
||
# Fallback if snapshot is missing — render the core fields directly.
|
||
def row(label: str, value: Any) -> str:
|
||
return f"<tr><th>{_pdf_escape(label)}</th><td>{render_value(value)}</td></tr>"
|
||
|
||
owner_rows = (
|
||
row("Name", data.fullName)
|
||
+ row("Email", str(data.email))
|
||
+ row("Phone", data.phone)
|
||
+ row("Address", data.address)
|
||
)
|
||
dog_rows = (
|
||
row("Dog", data.dogName)
|
||
+ row("Breed", data.dogBreed)
|
||
+ row("Date of birth", data.dogAge or "")
|
||
+ row("Services", data.servicesNeeded)
|
||
+ row("Temperament / routine", data.temperament)
|
||
+ row("Medical notes", data.medicalNotes)
|
||
+ row("Home access", data.accessInstructions)
|
||
)
|
||
safety_rows = (
|
||
row("Vet clinic", data.vetName)
|
||
+ row("Vet phone", data.vetPhone)
|
||
+ row("Emergency contact", data.emergencyContactName)
|
||
+ row("Emergency phone", data.emergencyContactPhone)
|
||
+ row("Council registration", "Confirmed" if data.councilRegistrationConfirmed else "Not confirmed")
|
||
+ row("Vaccinations", "Confirmed" if data.vaccinationsConfirmed else "Not confirmed")
|
||
+ row("Emergency vet consent", "Confirmed" if data.emergencyVetConsent else "Not confirmed")
|
||
+ row("Declaration", "Signed" if data.termsAccepted else "Not signed")
|
||
)
|
||
sections_html_parts.append(
|
||
f"<section class='pdf-section'><h2>Owner Details</h2><table class='pdf-table'><tbody>{owner_rows}</tbody></table></section>"
|
||
f"<section class='pdf-section'><h2>Dog Details</h2><table class='pdf-table'><tbody>{dog_rows}</tbody></table></section>"
|
||
f"<section class='pdf-section'><h2>Safety</h2><table class='pdf-table'><tbody>{safety_rows}</tbody></table></section>"
|
||
)
|
||
|
||
signature_html = ""
|
||
if data.signatureDataUrl:
|
||
signature_html = (
|
||
"<section class='pdf-section pdf-signature'>"
|
||
"<h2>Signature</h2>"
|
||
f"<img src='{data.signatureDataUrl}' alt='Client signature'>"
|
||
f"<div class='pdf-signed-line'>Signed by {_pdf_escape(data.fullName)} on {_pdf_escape(submitted_at)}</div>"
|
||
"</section>"
|
||
)
|
||
|
||
body_html = "".join(sections_html_parts) + signature_html
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Goodwalk onboarding form — {_pdf_escape(data.fullName)}</title>
|
||
<style>
|
||
@page {{
|
||
size: A4;
|
||
margin: 14mm 12mm 14mm 12mm;
|
||
}}
|
||
* {{ box-sizing: border-box; }}
|
||
html, body {{
|
||
margin: 0;
|
||
padding: 0;
|
||
background: #ffffff;
|
||
color: #000000;
|
||
font-family: Arial, Helvetica, sans-serif;
|
||
font-size: 10.5pt;
|
||
line-height: 1.4;
|
||
}}
|
||
.pdf-doc {{ width: 100%; }}
|
||
.pdf-header {{
|
||
width: 100%;
|
||
border-bottom: 1.5pt solid #000;
|
||
padding-bottom: 8pt;
|
||
margin-bottom: 14pt;
|
||
}}
|
||
.pdf-header h1 {{
|
||
font-size: 18pt;
|
||
font-weight: 700;
|
||
margin: 0 0 4pt 0;
|
||
letter-spacing: 0.5pt;
|
||
text-transform: uppercase;
|
||
}}
|
||
.pdf-header .pdf-meta {{
|
||
font-size: 9.5pt;
|
||
color: #000;
|
||
}}
|
||
.pdf-section {{
|
||
width: 100%;
|
||
margin: 0 0 14pt 0;
|
||
page-break-inside: avoid;
|
||
}}
|
||
.pdf-section h2 {{
|
||
font-size: 11pt;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.6pt;
|
||
margin: 0 0 6pt 0;
|
||
padding: 0 0 3pt 0;
|
||
border-bottom: 0.75pt solid #000;
|
||
}}
|
||
table.pdf-table {{
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
}}
|
||
table.pdf-table th,
|
||
table.pdf-table td {{
|
||
text-align: left;
|
||
vertical-align: top;
|
||
padding: 5pt 8pt;
|
||
border-bottom: 0.4pt solid #000;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
}}
|
||
table.pdf-table th {{
|
||
width: 34%;
|
||
font-weight: 700;
|
||
background: #ffffff;
|
||
}}
|
||
table.pdf-table td {{
|
||
width: 66%;
|
||
font-weight: 400;
|
||
}}
|
||
.pdf-signature img {{
|
||
display: block;
|
||
max-width: 70%;
|
||
max-height: 60mm;
|
||
height: auto;
|
||
border: 0.5pt solid #000;
|
||
padding: 4pt;
|
||
margin: 4pt 0 6pt 0;
|
||
background: #fff;
|
||
}}
|
||
.pdf-signed-line {{
|
||
font-size: 9.5pt;
|
||
border-top: 0.4pt solid #000;
|
||
padding-top: 4pt;
|
||
margin-top: 4pt;
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="pdf-doc">
|
||
<header class="pdf-header">
|
||
<h1>Goodwalk Onboarding Form</h1>
|
||
<div class="pdf-meta">
|
||
<strong>{_pdf_escape(data.fullName)}</strong> · {_pdf_escape(data.dogName)} · Submitted {_pdf_escape(submitted_at)}
|
||
</div>
|
||
</header>
|
||
{body_html}
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _render_pdf_sync(html: str) -> bytes:
|
||
from weasyprint import HTML # imported lazily so unit tests don't require the native libs
|
||
return HTML(string=html).write_pdf()
|
||
|
||
|
||
# Feature flags — flip to True to attach a PDF copy of the signed form to the owner email.
|
||
# Kept as in-code booleans (not env vars) so the contract path stays off until explicitly enabled.
|
||
CONTRACT_PDF_ATTACHMENT_ENABLED = False
|
||
ONBOARDING_PDF_ATTACHMENT_ENABLED = True
|
||
|
||
|
||
async def _signed_form_pdf_attachment(html: str, full_name: str, kind: str, request_id: str) -> dict | None:
|
||
safe_name = re.sub(r"[^a-z0-9]+", "-", _trimmed(full_name).lower()).strip("-") or "client"
|
||
try:
|
||
pdf_bytes = await asyncio.to_thread(_render_pdf_sync, html)
|
||
except Exception as exc:
|
||
logger.error("[%s] %s PDF generation failed: %s", request_id, kind, exc, exc_info=True)
|
||
return None
|
||
|
||
logger.info("[%s] %s PDF generated: %d bytes", request_id, kind, len(pdf_bytes))
|
||
return {
|
||
"filename": f"goodwalk-{kind}-{safe_name}.pdf",
|
||
"content": base64.b64encode(pdf_bytes).decode("ascii"),
|
||
}
|
||
|
||
|
||
# ── Sending with retries ─────────────────────────────────────────────────────
|
||
|
||
async def _send_email(payload: dict, label: str, request_id: str) -> dict:
|
||
if DEV_MODE:
|
||
to = payload.get("to", [])
|
||
subject = payload.get("subject", "(no subject)")
|
||
logger.warning("[DEV] skipping email send — label=%s to=%s subject=%r", label, to, subject)
|
||
return {"id": "dev-mode"}
|
||
|
||
last_exc: Exception | None = None
|
||
|
||
for attempt in range(1, MAX_SEND_ATTEMPTS + 1):
|
||
started = time.monotonic()
|
||
try:
|
||
result = await asyncio.wait_for(
|
||
asyncio.to_thread(resend.Emails.send, payload),
|
||
timeout=EMAIL_SEND_TIMEOUT_SECONDS,
|
||
)
|
||
elapsed_ms = (time.monotonic() - started) * 1000
|
||
email_id = result.get("id") if isinstance(result, dict) else None
|
||
logger.info(
|
||
"[%s] %s sent to %s (attempt %d/%d, %.0fms, id=%s)",
|
||
request_id, label, payload.get("to"), attempt, MAX_SEND_ATTEMPTS,
|
||
elapsed_ms, email_id or "n/a",
|
||
)
|
||
return result or {}
|
||
except Exception as exc:
|
||
last_exc = exc
|
||
elapsed_ms = (time.monotonic() - started) * 1000
|
||
status = getattr(exc, "status_code", None) or getattr(exc, "code", None)
|
||
non_retryable = (
|
||
isinstance(status, int) and 400 <= status < 500 and status != 429
|
||
)
|
||
logger.warning(
|
||
"[%s] %s send failed (attempt %d/%d, %.0fms): %s: %s (status=%s)",
|
||
request_id, label, attempt, MAX_SEND_ATTEMPTS, elapsed_ms,
|
||
type(exc).__name__, exc, status,
|
||
exc_info=True,
|
||
)
|
||
if non_retryable:
|
||
logger.info(
|
||
"[%s] %s: non-retryable status %s, aborting retries",
|
||
request_id, label, status,
|
||
)
|
||
break
|
||
if attempt == MAX_SEND_ATTEMPTS:
|
||
break
|
||
backoff = (2 ** (attempt - 1)) + random.uniform(0, 0.4)
|
||
logger.info("[%s] retrying %s in %.2fs", request_id, label, backoff)
|
||
await asyncio.sleep(backoff)
|
||
|
||
assert last_exc is not None
|
||
raise last_exc
|
||
|
||
|
||
def _build_startup_test_submission() -> BookingSubmission:
|
||
now_ms = int(time.time() * 1000)
|
||
|
||
sample = BookingSubmission(
|
||
enquiryType="booking",
|
||
fullName="Sarah Thompson",
|
||
email="sarah.thompson@example.com",
|
||
phone="021 555 0142",
|
||
petName="Milo",
|
||
location="Grey Lynn",
|
||
message=(
|
||
"Milo is a 2-year-old cavoodle with good recall and a friendly nature. "
|
||
"He loves other dogs, is comfortable off lead in safe areas, and we are "
|
||
"looking for regular weekday pack walks while we are at work."
|
||
),
|
||
services=["Pack Walks", "Puppy Visits"],
|
||
formStartedAt=now_ms - (6 * 60 * 1000 + 35 * 1000),
|
||
visitStartedAt=now_ms - (14 * 60 * 1000 + 10 * 1000),
|
||
pageEnteredAt=now_ms - (7 * 60 * 1000 + 5 * 1000),
|
||
firstInteractionAt=now_ms - (5 * 60 * 1000 + 20 * 1000),
|
||
sendClickedAt=now_ms,
|
||
stepChanges=3,
|
||
journey=["/", "/pack-walks", "/our-pricing", "/book"],
|
||
referrer="https://www.google.com/search?q=goodwalk+auckland+dog+walking",
|
||
page="https://www.goodwalk.co.nz/book?service=pack-walks",
|
||
)
|
||
_normalize_submission(sample)
|
||
return sample
|
||
|
||
|
||
async def _send_startup_test_email() -> None:
|
||
if not STARTUP_TEST_RECIPIENT:
|
||
logger.info("Startup test email skipped: OWNER_BCC is not set to a real address")
|
||
return
|
||
|
||
request_id = "startup-test"
|
||
sample = _build_startup_test_submission()
|
||
payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [STARTUP_TEST_RECIPIENT],
|
||
"reply_to": str(sample.email),
|
||
"subject": f"Startup preview — New GoodWalk lead — {sample.fullName} ({sample.petName})",
|
||
"html": owner_email(sample, "127.0.0.1", f"Startup Preview ({APP_VERSION})"),
|
||
}
|
||
|
||
await _send_email(payload, label="startup_test_email", request_id=request_id)
|
||
|
||
|
||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||
|
||
async def _startup_smoke_pdf() -> None:
|
||
"""Import WeasyPrint and run a trivial render to surface native-lib issues
|
||
(libpango/cairo/etc.) at boot rather than on the first PDF request."""
|
||
try:
|
||
await asyncio.to_thread(_render_pdf_sync, "<html><body>ok</body></html>")
|
||
logger.info("Startup smoke: WeasyPrint OK — PDF attachments available")
|
||
except Exception as exc:
|
||
logger.error("Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (%s)", exc)
|
||
|
||
|
||
async def _startup_verify_schema() -> None:
|
||
"""Force schema creation at boot and verify the new tables exist so the
|
||
activity log isn't silently empty if CREATE permission is missing."""
|
||
if not admin_db.is_enabled():
|
||
logger.warning("Startup smoke: postgres disabled — activity/submissions will NOT be recorded")
|
||
return
|
||
try:
|
||
pool = await admin_db.get_pool()
|
||
if pool is None:
|
||
logger.warning("Startup smoke: postgres pool unavailable — activity/submissions will NOT be recorded")
|
||
return
|
||
await admin_db._ensure_schema() # idempotent
|
||
async with pool.acquire() as conn:
|
||
row = await conn.fetchrow(
|
||
"select to_regclass('public.events') as ev, to_regclass('public.submissions') as sub"
|
||
)
|
||
if row and row["ev"] and row["sub"]:
|
||
logger.info("Startup smoke: pg tables OK — events + submissions ready")
|
||
else:
|
||
logger.error("Startup smoke: pg tables MISSING (events=%s submissions=%s) — check CREATE perms",
|
||
row["ev"] if row else None, row["sub"] if row else None)
|
||
except Exception as exc:
|
||
logger.error("Startup smoke: pg schema verify FAILED (%s)", exc)
|
||
|
||
|
||
async def _startup_mail_check() -> None:
|
||
global _birthday_auto_task, _allowed_emails, _client_profiles, _drafts
|
||
|
||
# 0. Boot-time smoke tests so silent failures surface immediately.
|
||
await _startup_smoke_pdf()
|
||
await _startup_verify_schema()
|
||
|
||
# 1. Seed postgres from JSON if admin_kv is empty (one-time migration).
|
||
await _seed_admin_state_from_json_if_needed()
|
||
|
||
# 2. Refresh the in-memory caches from postgres so the app reads the
|
||
# canonical dataset even after restarts.
|
||
if admin_db.is_enabled():
|
||
try:
|
||
db_clients = await _load_client_profiles_async()
|
||
if isinstance(db_clients, dict):
|
||
_client_profiles = db_clients
|
||
db_emails = await _load_allowed_emails_async()
|
||
if isinstance(db_emails, set):
|
||
_allowed_emails = db_emails
|
||
if OWNER_EMAIL:
|
||
_allowed_emails.add(OWNER_EMAIL.strip().lower())
|
||
db_drafts = await _load_drafts_async()
|
||
if isinstance(db_drafts, dict):
|
||
_drafts = db_drafts
|
||
db_sessions = await _load_active_sessions_async()
|
||
if db_sessions:
|
||
_active_sessions.update(db_sessions)
|
||
logger.info(
|
||
"Admin state refreshed from postgres: clients=%d emails=%d drafts=%d sessions=%d",
|
||
len(_client_profiles), len(_allowed_emails), len(_drafts), len(_active_sessions),
|
||
)
|
||
except Exception:
|
||
logger.exception("Admin state refresh from postgres failed; using JSON snapshot")
|
||
|
||
# 3. Merge any shipped legacy seed (add-only — never clobbers live entries).
|
||
await _merge_legacy_seed_if_present()
|
||
|
||
try:
|
||
await _send_startup_test_email()
|
||
except Exception:
|
||
logger.exception("Startup test email failed")
|
||
if _birthday_auto_task is None or _birthday_auto_task.done():
|
||
_birthday_auto_task = asyncio.create_task(_birthday_auto_sender_loop())
|
||
|
||
|
||
async def _shutdown_background_tasks() -> None:
|
||
global _birthday_auto_task
|
||
if _birthday_auto_task is not None:
|
||
_birthday_auto_task.cancel()
|
||
try:
|
||
await _birthday_auto_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
_birthday_auto_task = None
|
||
|
||
@app.get("/health")
|
||
async def health() -> dict:
|
||
return {"status": "ok"}
|
||
|
||
|
||
def _auth_code_email(email: str, code: str) -> str:
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Your Goodwalk login code</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:40px 16px;">
|
||
<tr><td align="center">
|
||
<table width="480" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="max-width:480px;width:100%;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||
<tr>
|
||
<td style="background:#213021;padding:32px 40px;text-align:center;">
|
||
<img src="{LOGO_URL}" width="161" height="32" alt="Goodwalk" style="display:inline-block;max-width:161px;height:auto;border:0;">
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#ffffff;padding:40px 40px 36px;text-align:center;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#888;margin-bottom:16px;">Your login code</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:52px;font-weight:800;letter-spacing:0.18em;color:#213021;background:#f8f7f4;border-radius:14px;padding:20px 28px;display:inline-block;margin-bottom:24px;">{code}</div>
|
||
<p style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;color:#666;line-height:1.6;margin:0 0 8px;">
|
||
Enter this code on the Goodwalk onboarding page.
|
||
</p>
|
||
<p style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;color:#aaa;margin:0;">
|
||
This code expires in {AUTH_CODE_TTL_SECONDS // 60} minutes. If you didn’t request this, you can safely ignore it.
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#213021;padding:20px 40px;text-align:center;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;color:#5a8a5a;">
|
||
Goodwalk · Auckland, New Zealand
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _format_date_label(value: str) -> str:
|
||
raw = _trimmed(value)
|
||
if not raw:
|
||
return "To be confirmed"
|
||
try:
|
||
parsed = datetime.fromisoformat(raw)
|
||
return f"{parsed.day} {parsed.strftime('%b %Y')}"
|
||
except ValueError:
|
||
return raw
|
||
|
||
|
||
def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str, price_details: str, start_date: str) -> str:
|
||
first_name = client_name.split()[0] if client_name.strip() else "there"
|
||
dog_line = f" for {dog_name}" if dog_name.strip() else ""
|
||
formatted_start_date = _format_date_label(start_date)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Welcome to the pack</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:28px 12px;">
|
||
<tr><td align="center">
|
||
<table width="560" cellpadding="0" cellspacing="0" role="presentation" style="max-width:560px;width:100%;border-radius:22px;overflow:hidden;box-shadow:0 10px 32px rgba(17,20,24,0.08);">
|
||
<tr>
|
||
<td style="background:#213021;padding:28px 24px;text-align:center;">
|
||
<img src="{LOGO_URL}" width="161" height="32" alt="Goodwalk" style="display:inline-block;max-width:161px;height:auto;border:0;">
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#fbfaf7;padding:34px 24px 30px;">
|
||
<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">
|
||
Welcome to the pack
|
||
</div>
|
||
<h1 style="margin:18px 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
|
||
Hi {first_name}, we’d love to get {dog_name or 'your dog'} started with Goodwalk.
|
||
</h1>
|
||
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
|
||
We’ve set aside the details below{dog_line}. When you’re ready, complete your onboarding form and we’ll take it from there.
|
||
</p>
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
|
||
<tr><td style="padding:22px 20px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Service", service_type)}
|
||
{_detail_row("Price", price_details)}
|
||
{_detail_row("Start date", formatted_start_date)}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<a href="https://onboarding.goodwalk.co.nz/" style="display:inline-block;background:#213021;color:#ffffff;text-decoration:none;border-radius:999px;padding:14px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;font-weight:700;">
|
||
Complete onboarding
|
||
</a>
|
||
|
||
<p style="margin:18px 0 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.7;color:#657365;">
|
||
Use the same email address you originally used with Goodwalk. We’ll send you a one-time code when you sign in.
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _onboarding_confirmation_email_html(data: OnboardingSubmission) -> str:
|
||
first_name = data.fullName.split()[0] if data.fullName.strip() else "there"
|
||
dog_name = _trimmed(data.dogName)
|
||
service_names = [service.strip() for service in data.servicesNeeded if isinstance(service, str) and service.strip()]
|
||
service_summary = ", ".join(service_names[:2]) if service_names else "your selected service"
|
||
if len(service_names) > 2:
|
||
service_summary += f" + {len(service_names) - 2} more"
|
||
|
||
onboarding_url = "https://clients.goodwalk.co.nz/"
|
||
badge_html = (
|
||
'<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;'
|
||
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;"
|
||
"font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;\">Submitted</div>"
|
||
)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Your onboarding has been submitted</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:28px 12px;">
|
||
<tr><td align="center">
|
||
<table width="560" cellpadding="0" cellspacing="0" role="presentation" style="max-width:560px;width:100%;border-radius:22px;overflow:hidden;box-shadow:0 10px 32px rgba(17,20,24,0.08);">
|
||
{_logo_header(badge_html=badge_html, subtitle="Your onboarding details are safely with us")}
|
||
<tr>
|
||
<td style="background:#fbfaf7;padding:34px 24px 30px;">
|
||
<h1 style="margin:0 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
|
||
Thanks, {first_name}. Your onboarding is complete.
|
||
</h1>
|
||
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
|
||
We’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.
|
||
</p>
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
|
||
<tr><td style="padding:22px 20px;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;text-transform:uppercase;margin-bottom:16px;">
|
||
Snapshot
|
||
</div>
|
||
<div style="display:grid;gap:14px;">
|
||
<div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#7b867b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;">Owner</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:18px;font-weight:700;color:#171b20;">{data.fullName}</div>
|
||
</div>
|
||
<div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#7b867b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;">Dog</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:18px;font-weight:700;color:#171b20;">{dog_name or 'Details submitted'}</div>
|
||
</div>
|
||
<div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#7b867b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;">Services</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:16px;line-height:1.5;color:#425042;">{service_summary}</div>
|
||
</div>
|
||
</div>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-left:3px solid #ffd100;margin-bottom:24px;">
|
||
<tr>
|
||
<td style="padding:4px 0 4px 20px;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;font-weight:700;color:#213021;margin-bottom:6px;">
|
||
What happens next?
|
||
</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;color:#666;line-height:1.65;">
|
||
We’ll review your submission and come back to you if we need anything clarified.
|
||
If you need to check your details again, use the button below to sign back in with a one-time code.
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<a href="{onboarding_url}" style="display:inline-block;background:#213021;color:#ffffff;text-decoration:none;border-radius:999px;padding:14px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;font-weight:700;">
|
||
Review your submission
|
||
</a>
|
||
|
||
<p style="margin:18px 0 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.7;color:#657365;">
|
||
Your submitted form is read-only after completion. If anything needs changing, just reply to this email or contact us directly.
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#213021;padding:24px 48px;text-align:center;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;color:#5a8a5a;line-height:1.6;">
|
||
Goodwalk · Auckland, New Zealand
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _birthday_email_html(client_name: str, dog_name: str) -> str:
|
||
first_name = client_name.split()[0] if client_name.strip() else "there"
|
||
dog_name_clean = dog_name.strip() or "your dog"
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Happy birthday from Goodwalk</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:28px 12px;">
|
||
<tr><td align="center">
|
||
<table width="560" cellpadding="0" cellspacing="0" role="presentation" style="max-width:560px;width:100%;border-radius:22px;overflow:hidden;box-shadow:0 10px 32px rgba(17,20,24,0.08);">
|
||
<tr>
|
||
<td style="background:#213021;padding:28px 24px;text-align:center;">
|
||
<img src="{LOGO_URL}" width="161" height="32" alt="Goodwalk" style="display:inline-block;max-width:161px;height:auto;border:0;">
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:#fbfaf7;padding:34px 24px 30px;">
|
||
<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">
|
||
Happy birthday
|
||
</div>
|
||
<h1 style="margin:18px 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
|
||
Happy birthday to {dog_name_clean}.
|
||
</h1>
|
||
<p style="margin:0 0 18px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
|
||
Hi {first_name}, sending a little birthday love from all of us at Goodwalk. We hope {dog_name_clean} has a very good day.
|
||
</p>
|
||
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
|
||
Aless and the Goodwalk pack
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _upcoming_birthday_date(dog_birth_date: str, today: datetime | None = None):
|
||
raw = _trimmed(dog_birth_date)
|
||
if not raw:
|
||
return None
|
||
|
||
try:
|
||
birth_date = datetime.strptime(raw, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
return None
|
||
|
||
today_date = (today or datetime.now()).date()
|
||
target_year = today_date.year
|
||
|
||
while True:
|
||
try:
|
||
candidate = birth_date.replace(year=target_year)
|
||
break
|
||
except ValueError:
|
||
# Handle 29 Feb birthdays by moving them to 28 Feb on non-leap years.
|
||
candidate = birth_date.replace(year=target_year, month=2, day=28)
|
||
break
|
||
|
||
if candidate < today_date:
|
||
target_year += 1
|
||
try:
|
||
candidate = birth_date.replace(year=target_year)
|
||
except ValueError:
|
||
candidate = birth_date.replace(year=target_year, month=2, day=28)
|
||
|
||
return candidate
|
||
|
||
|
||
async def _send_birthday_email_for_profile(email: str, profile: dict, request_id: str, mark_auto_year: int | None = None, preview: bool = False) -> None:
|
||
client_name = str(profile.get("fullName", "")).strip()
|
||
dog_name = str(profile.get("dogName", "")).strip()
|
||
recipient = OWNER_EMAIL.strip().lower() if preview else email
|
||
subject = f"Happy birthday {dog_name or 'from Goodwalk'}"
|
||
if preview:
|
||
subject = f"[PREVIEW for {client_name or email}] {subject}"
|
||
payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [recipient],
|
||
"reply_to": REPLY_TO,
|
||
"subject": subject,
|
||
"html": _birthday_email_html(client_name, dog_name),
|
||
}
|
||
if CLIENT_BCC and not preview:
|
||
payload["bcc"] = [CLIENT_BCC]
|
||
|
||
await _send_email(payload, label="birthday_email_preview" if preview else "birthday_email", request_id=request_id)
|
||
|
||
if preview:
|
||
return
|
||
|
||
profile_update = {
|
||
"birthdayEmailLastSentAt": datetime.now().isoformat(timespec="seconds"),
|
||
}
|
||
if mark_auto_year is not None:
|
||
profile_update["birthdayEmailLastSentYear"] = str(mark_auto_year)
|
||
await _store_client_profile(email, profile_update)
|
||
|
||
|
||
async def _run_birthday_auto_sender_once() -> None:
|
||
today = datetime.now().date()
|
||
today_month_day = (today.month, today.day)
|
||
|
||
for email, profile in list(_client_profiles.items()):
|
||
if not profile.get("onboardingCompleted"):
|
||
continue
|
||
if not profile.get("birthdayAutoSend"):
|
||
continue
|
||
|
||
upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")))
|
||
if not upcoming or (upcoming.month, upcoming.day) != today_month_day:
|
||
continue
|
||
|
||
last_sent_year = str(profile.get("birthdayEmailLastSentYear", "")).strip()
|
||
if last_sent_year == str(today.year):
|
||
continue
|
||
|
||
request_id = f"birthday-auto-{uuid.uuid4().hex[:6]}"
|
||
try:
|
||
await _send_birthday_email_for_profile(email, profile, request_id, mark_auto_year=today.year)
|
||
logger.info("[%s] auto birthday email sent: email=%s", request_id, email)
|
||
except Exception as exc:
|
||
logger.error("[%s] auto birthday email failed: %s", request_id, exc, exc_info=True)
|
||
|
||
|
||
async def _birthday_auto_sender_loop() -> None:
|
||
while True:
|
||
try:
|
||
await _run_birthday_auto_sender_once()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.exception("Birthday auto sender loop failed")
|
||
await asyncio.sleep(BIRTHDAY_CHECK_INTERVAL_SECONDS)
|
||
|
||
|
||
_EMAIL_RE = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$')
|
||
|
||
|
||
@app.post("/auth/request-code")
|
||
async def auth_request_code(request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
ip = _get_ip(request)
|
||
body = await request.json()
|
||
email = str(body.get("email", "")).strip().lower()
|
||
|
||
async with _auth_lock:
|
||
_check_ip_blocked(ip, request_id)
|
||
|
||
if not email or not _EMAIL_RE.match(email):
|
||
raise HTTPException(status_code=400, detail="Please enter a valid email address.")
|
||
|
||
if email not in _allowed_emails:
|
||
logger.info("[%s] auth: unknown email=%s ip=%s", request_id, email, ip)
|
||
async with _auth_lock:
|
||
_record_auth_failure(ip, request_id, "unknown_email")
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="We 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,
|
||
}
|
||
|
||
|
||
@app.post("/auth/logout")
|
||
async def auth_logout(request: Request):
|
||
auth_header = request.headers.get("Authorization", "")
|
||
token = auth_header.removeprefix("Bearer ").strip()
|
||
logged_out_email = None
|
||
if token:
|
||
async with _auth_lock:
|
||
existing = _active_sessions.pop(token, None)
|
||
logged_out_email = existing.get("email") if isinstance(existing, dict) else None
|
||
await _save_active_sessions_async()
|
||
await admin_db.record_event(
|
||
event_type="auth_logout",
|
||
actor_email=logged_out_email, ip=_get_ip(request), status="ok",
|
||
)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/auth/save-draft")
|
||
async def auth_save_draft(request: Request):
|
||
email = await _require_session_email(request)
|
||
|
||
body = await request.json()
|
||
form = str(body.get("form", "")).strip()
|
||
data = body.get("data", {})
|
||
|
||
if form not in ("onboarding", "contract"):
|
||
raise HTTPException(status_code=400, detail="form must be 'onboarding' or 'contract'.")
|
||
if not isinstance(data, dict):
|
||
raise HTTPException(status_code=400, detail="data must be an object.")
|
||
|
||
async with _auth_lock:
|
||
user_drafts = _drafts.setdefault(email, {})
|
||
user_drafts[form] = data
|
||
snapshot = dict(_drafts)
|
||
|
||
await asyncio.to_thread(_save_drafts_file, snapshot)
|
||
await _persist_admin_state("drafts", snapshot)
|
||
logger.info("Draft saved: email=%s form=%s", email, form)
|
||
return {"ok": True}
|
||
|
||
|
||
MESSAGE_TEMPLATES: dict[str, dict[str, str]] = {
|
||
"general": {
|
||
"id": "general",
|
||
"name": "General update",
|
||
"description": "Clean Goodwalk branding for everyday news and updates.",
|
||
"kicker": "From Goodwalk",
|
||
"banner_emoji": "🐾",
|
||
"accent": "#ffd100",
|
||
"accent_text": "#213021",
|
||
"page_bg": "#f3f0e5",
|
||
"card_bg": "#fbfaf7",
|
||
"heading_color": "#171b20",
|
||
"body_color": "#4b584b",
|
||
"muted_color": "#6b766b",
|
||
"band_bg": "#213021",
|
||
"band_text": "#ffd100",
|
||
"band_decoration": "🐾 · 🐾 · 🐾 · 🐾 · 🐾",
|
||
"footer_bg": "#213021",
|
||
"footer_text": "#fbfaf7",
|
||
"highlight_bg": "#fff8d6",
|
||
"highlight_border": "#ffd100",
|
||
"highlight_text": "#213021",
|
||
"ornament_top": "",
|
||
"ornament_bottom": "",
|
||
"default_subject": "A note from Goodwalk",
|
||
"default_heading": "Hello from the pack",
|
||
"default_sub_heading": "A quick update from your dog walking team.",
|
||
"default_body": "Thank you for being part of our community. Every wag and woof matters to us, and we have a small update to share.\n\nWe're always here if you need to chat about walks, training, or anything else dog-related.",
|
||
"default_highlight": "",
|
||
"default_sign_off": "Aless & the Goodwalk pack",
|
||
"default_footer_note": "goodwalk.co.nz · Auckland, NZ",
|
||
},
|
||
"christmas": {
|
||
"id": "christmas",
|
||
"name": "Christmas",
|
||
"description": "Deep green and red festive styling with snow accents.",
|
||
"kicker": "Season's greetings",
|
||
"banner_emoji": "🎄",
|
||
"accent": "#c0392b",
|
||
"accent_text": "#ffffff",
|
||
"page_bg": "#e8dccb",
|
||
"card_bg": "#fbf6ec",
|
||
"heading_color": "#0d3b1e",
|
||
"body_color": "#3a4a3a",
|
||
"muted_color": "#6b766b",
|
||
"band_bg": "#0d4d2a",
|
||
"band_text": "#ffffff",
|
||
"band_decoration": "❄ · 🎄 · ❄ · 🎁 · ❄ · 🦌 · ❄ · ⭐ · ❄",
|
||
"footer_bg": "#0d4d2a",
|
||
"footer_text": "#ffe8d6",
|
||
"highlight_bg": "#fff0ea",
|
||
"highlight_border": "#c0392b",
|
||
"highlight_text": "#7a1d12",
|
||
"ornament_top": "❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄",
|
||
"ornament_bottom": "🎄 ⭐ 🎁 🦌 ❄ 🎁 ⭐ 🎄",
|
||
"default_subject": "Merry Christmas from the Goodwalk pack 🎄",
|
||
"default_heading": "Wishing you a very woofy Christmas",
|
||
"default_sub_heading": "From our pack to yours — thank you for an incredible year.",
|
||
"default_body": "It's been a year full of muddy paws, sunny walks, and very good dogs. From all of us at Goodwalk, we wish you and your pup a warm, joyful Christmas.\n\nWe'll be taking a short break over the holidays and will be back in full swing for the new year. Looking forward to many more adventures in 2026.",
|
||
"default_highlight": "🎁 Holiday schedule: walks pause from 24 Dec, resuming 6 Jan.",
|
||
"default_sign_off": "Aless & the Goodwalk pack",
|
||
"default_footer_note": "Wishing you a warm and joyful Christmas",
|
||
},
|
||
"easter": {
|
||
"id": "easter",
|
||
"name": "Easter",
|
||
"description": "Soft pastel styling with floral and bunny accents.",
|
||
"kicker": "Happy Easter",
|
||
"banner_emoji": "🐰",
|
||
"accent": "#d8a8de",
|
||
"accent_text": "#3a2a4a",
|
||
"page_bg": "#fdf3f8",
|
||
"card_bg": "#ffffff",
|
||
"heading_color": "#3a2a4a",
|
||
"body_color": "#5a4a5a",
|
||
"muted_color": "#8a7a8a",
|
||
"band_bg": "#f5d6e5",
|
||
"band_text": "#5a2a6b",
|
||
"band_decoration": "🌷 · 🐰 · 🌸 · 🥚 · 🐣 · 🌷 · 🌸",
|
||
"footer_bg": "#e7c9f0",
|
||
"footer_text": "#3a2a4a",
|
||
"highlight_bg": "#fff0fa",
|
||
"highlight_border": "#d8a8de",
|
||
"highlight_text": "#5a2a6b",
|
||
"ornament_top": "🌷 🌸 🌷 🌸 🌷 🌸 🌷 🌸 🌷 🌸",
|
||
"ornament_bottom": "🥚 🐰 🌸 🐣 🥚 🐰",
|
||
"default_subject": "Hop on into Easter with Goodwalk 🐰",
|
||
"default_heading": "A happy, hoppy Easter to you",
|
||
"default_sub_heading": "Spring is in the air and tails are wagging.",
|
||
"default_body": "Wishing you and your pup a beautiful Easter weekend. May your walks be sunny, your eggs uneaten by curious snouts, and your treats plentiful.\n\nA little reminder: chocolate is not for dogs, no matter how sweetly they ask. We'll be sticking to the good stuff on our walks.",
|
||
"default_highlight": "🐣 Keep chocolate well out of reach — even small amounts can be harmful to dogs.",
|
||
"default_sign_off": "Aless & the Goodwalk pack",
|
||
"default_footer_note": "Happy Easter from all of us",
|
||
},
|
||
"halloween": {
|
||
"id": "halloween",
|
||
"name": "Halloween",
|
||
"description": "Dark purple and orange spooky styling.",
|
||
"kicker": "Trick or treat",
|
||
"banner_emoji": "🎃",
|
||
"accent": "#ff7518",
|
||
"accent_text": "#1a0d1f",
|
||
"page_bg": "#1a0d1f",
|
||
"card_bg": "#2b1838",
|
||
"heading_color": "#ffe8d0",
|
||
"body_color": "#d8c8d8",
|
||
"muted_color": "#9a8aaa",
|
||
"band_bg": "#0a0410",
|
||
"band_text": "#ff7518",
|
||
"band_decoration": "🎃 · 👻 · 🕷 · 🦇 · 🌙 · 🕸 · 🎃 · 👻",
|
||
"footer_bg": "#0a0410",
|
||
"footer_text": "#ff7518",
|
||
"highlight_bg": "#4a2b66",
|
||
"highlight_border": "#ff7518",
|
||
"highlight_text": "#ffe8d0",
|
||
"ornament_top": "🦇 🕸 🦇 🕸 🦇 🕸 🦇 🕸 🦇 🕸",
|
||
"ornament_bottom": "🎃 👻 🕷 🌙 🦇 🕸 🎃",
|
||
"default_subject": "Spooky season at Goodwalk 🎃",
|
||
"default_heading": "It's Howl-oween",
|
||
"default_sub_heading": "Costumes optional. Treats mandatory.",
|
||
"default_body": "Spooky season is upon us. We'll be out walking with extra vigilance — fireworks, doorbell mayhem, and rogue chocolate are all on our radar.\n\nIf your pup is nervous around fireworks or doorbells, let us know and we'll factor it into walks this week.",
|
||
"default_highlight": "🍫 Reminder: chocolate, raisins, and xylitol are all toxic to dogs. Keep the treat bowl high.",
|
||
"default_sign_off": "Aless & the Goodwalk pack",
|
||
"default_footer_note": "Stay spooky out there",
|
||
},
|
||
"promo": {
|
||
"id": "promo",
|
||
"name": "Sale / promotional offer",
|
||
"description": "Bright yellow promotional styling with a clear discount callout.",
|
||
"kicker": "Limited offer",
|
||
"banner_emoji": "🦴",
|
||
"accent": "#ffd100",
|
||
"accent_text": "#213021",
|
||
"page_bg": "#fffaeb",
|
||
"card_bg": "#fffdf5",
|
||
"heading_color": "#171b20",
|
||
"body_color": "#3a4a3a",
|
||
"muted_color": "#6b766b",
|
||
"band_bg": "#213021",
|
||
"band_text": "#ffd100",
|
||
"band_decoration": "★ · SPECIAL OFFER · ★ · LIMITED TIME · ★",
|
||
"footer_bg": "#213021",
|
||
"footer_text": "#ffd100",
|
||
"highlight_bg": "#fff3a0",
|
||
"highlight_border": "#ffd100",
|
||
"highlight_text": "#213021",
|
||
"ornament_top": "★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★",
|
||
"ornament_bottom": "🦴 ★ 🐾 ★ 🦴 ★ 🐾",
|
||
"default_subject": "A little something from Goodwalk 🦴",
|
||
"default_heading": "A special offer for our pack",
|
||
"default_sub_heading": "Because regulars are family.",
|
||
"default_body": "We're running a small thank-you offer for our existing clients. As a regular, you're first in line.\n\nReply to this email or hit the button below to take it up. Offer is limited and won't be around long.",
|
||
"default_highlight": "20% off your next week of walks · Use code PACKLOVE at booking",
|
||
"default_sign_off": "Aless & the Goodwalk pack",
|
||
"default_footer_note": "Limited time — be quick",
|
||
},
|
||
}
|
||
|
||
|
||
MESSAGE_FONTS: dict[str, dict[str, str]] = {
|
||
"system": {
|
||
"id": "system",
|
||
"name": "System (clean sans-serif)",
|
||
"stack": "-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
|
||
"link": "",
|
||
"heading_stack": "Georgia,'Times New Roman',serif",
|
||
},
|
||
"lora": {
|
||
"id": "lora",
|
||
"name": "Lora (warm serif)",
|
||
"stack": "'Lora',Georgia,'Times New Roman',serif",
|
||
"link": "https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400&display=swap",
|
||
"heading_stack": "'Lora',Georgia,'Times New Roman',serif",
|
||
},
|
||
"playfair": {
|
||
"id": "playfair",
|
||
"name": "Playfair Display (editorial serif)",
|
||
"stack": "Georgia,'Times New Roman',serif",
|
||
"link": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Source+Sans+3:wght@400;600&display=swap",
|
||
"heading_stack": "'Playfair Display',Georgia,'Times New Roman',serif",
|
||
},
|
||
"merriweather": {
|
||
"id": "merriweather",
|
||
"name": "Merriweather (readable serif)",
|
||
"stack": "'Merriweather',Georgia,'Times New Roman',serif",
|
||
"link": "https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap",
|
||
"heading_stack": "'Merriweather',Georgia,'Times New Roman',serif",
|
||
},
|
||
"crimson": {
|
||
"id": "crimson",
|
||
"name": "Crimson Text (classic serif)",
|
||
"stack": "'Crimson Text',Georgia,'Times New Roman',serif",
|
||
"link": "https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&display=swap",
|
||
"heading_stack": "'Crimson Text',Georgia,'Times New Roman',serif",
|
||
},
|
||
"inter": {
|
||
"id": "inter",
|
||
"name": "Inter (modern sans)",
|
||
"stack": "'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
|
||
"link": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
|
||
"heading_stack": "'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
|
||
},
|
||
"montserrat": {
|
||
"id": "montserrat",
|
||
"name": "Montserrat (geometric sans)",
|
||
"stack": "'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
|
||
"link": "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap",
|
||
"heading_stack": "'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
|
||
},
|
||
"opensans": {
|
||
"id": "opensans",
|
||
"name": "Open Sans (friendly sans)",
|
||
"stack": "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
|
||
"link": "https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,600;0,700;1,400&display=swap",
|
||
"heading_stack": "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif",
|
||
},
|
||
}
|
||
|
||
|
||
def _style_body_html(body_html: str, font_stack: str, body_color: str, accent_color: str) -> str:
|
||
"""Apply email-safe inline styles to common HTML tags in user-provided body content."""
|
||
import re
|
||
|
||
base_p_style = f"margin:0 0 16px;font-family:{font_stack};font-size:16px;line-height:1.7;color:{body_color};"
|
||
base_li_style = f"margin:0 0 6px;font-family:{font_stack};font-size:16px;line-height:1.7;color:{body_color};"
|
||
base_ul_style = f"margin:0 0 16px 0;padding:0 0 0 22px;font-family:{font_stack};color:{body_color};"
|
||
base_ol_style = base_ul_style
|
||
a_style = f"color:{accent_color};text-decoration:underline;"
|
||
|
||
# Strip <div> wrappers (contenteditable often wraps in divs); convert to <p>
|
||
s = body_html
|
||
s = re.sub(r"<div\b[^>]*>", "<p>", s)
|
||
s = s.replace("</div>", "</p>")
|
||
s = s.replace("<br>", "<br/>").replace("<br />", "<br/>")
|
||
|
||
# Apply inline styles by replacing opening tags (only if no style attribute already)
|
||
def _inject(tag: str, style: str, text: str) -> str:
|
||
return re.sub(
|
||
rf"<{tag}(\s[^>]*)?>",
|
||
lambda m: f"<{tag}{m.group(1) or ''} style=\"{style}\">",
|
||
text,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
|
||
s = _inject("p", base_p_style, s)
|
||
s = _inject("ul", base_ul_style, s)
|
||
s = _inject("ol", base_ol_style, s)
|
||
s = _inject("li", base_li_style, s)
|
||
s = re.sub(
|
||
r"<a(\s[^>]*?)>",
|
||
lambda m: f"<a{m.group(1)} style=\"{a_style}\">" if "style=" not in m.group(1).lower() else m.group(0),
|
||
s,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
|
||
return s
|
||
|
||
|
||
def _body_to_html(body_text: str, font_stack: str, body_color: str, accent_color: str) -> str:
|
||
"""Convert user body input to email-safe HTML.
|
||
|
||
If the input already looks like HTML (contains a tag), we treat it as HTML and inline-style it.
|
||
Otherwise we split on blank lines and wrap each paragraph in a <p>.
|
||
"""
|
||
if not body_text or not body_text.strip():
|
||
return ""
|
||
|
||
if "<" in body_text and ">" in body_text:
|
||
return _style_body_html(body_text, font_stack, body_color, accent_color)
|
||
|
||
parts = [p.strip() for p in body_text.split("\n\n") if p.strip()]
|
||
return "".join(
|
||
f'<p style="margin:0 0 16px;font-family:{font_stack};font-size:16px;line-height:1.7;color:{body_color};">{para}</p>'
|
||
for para in parts
|
||
)
|
||
|
||
|
||
def _escape_attr(value: str) -> str:
|
||
return (value or "").replace("&", "&").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"""
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:22px 0 6px;">
|
||
<tr><td align="left">
|
||
<!--[if mso]>
|
||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{safe_url}" style="height:48px;v-text-anchor:middle;width:240px;" arcsize="50%" stroke="f" fillcolor="{bg}">
|
||
<w:anchorlock/>
|
||
<center style="color:{text_color};font-family:Arial,sans-serif;font-size:14px;font-weight:bold;letter-spacing:0.04em;">{safe_label}</center>
|
||
</v:roundrect>
|
||
<![endif]-->
|
||
<!--[if !mso]><!-- -->
|
||
<a href="{safe_url}" style="display:inline-block;padding:14px 28px;border-radius:999px;background:{bg};color:{text_color};font-family:{font_stack};font-size:14px;font-weight:700;text-decoration:none;letter-spacing:0.04em;mso-hide:all;">{safe_label}</a>
|
||
<!--<![endif]-->
|
||
</td></tr>
|
||
</table>"""
|
||
|
||
|
||
def _render_message_html(
|
||
template_id: str,
|
||
heading: str,
|
||
body: str,
|
||
cta_label: str,
|
||
cta_url: str,
|
||
sub_heading: str = "",
|
||
highlight_text: str = "",
|
||
sign_off: str = "",
|
||
footer_note: str = "",
|
||
font_id: str = "system",
|
||
) -> str:
|
||
tmpl = MESSAGE_TEMPLATES.get(template_id, MESSAGE_TEMPLATES["general"])
|
||
font = MESSAGE_FONTS.get(font_id, MESSAGE_FONTS["system"])
|
||
font_stack = font["stack"]
|
||
heading_font_stack = font["heading_stack"]
|
||
font_link = font["link"]
|
||
accent = tmpl["accent"]
|
||
accent_text = tmpl["accent_text"]
|
||
page_bg = tmpl["page_bg"]
|
||
card_bg = tmpl["card_bg"]
|
||
heading_color = tmpl["heading_color"]
|
||
body_color = tmpl["body_color"]
|
||
muted_color = tmpl["muted_color"]
|
||
band_bg = tmpl["band_bg"]
|
||
band_text = tmpl["band_text"]
|
||
band_decoration = tmpl["band_decoration"]
|
||
footer_bg = tmpl["footer_bg"]
|
||
footer_text_color = tmpl["footer_text"]
|
||
highlight_bg = tmpl["highlight_bg"]
|
||
highlight_border = tmpl["highlight_border"]
|
||
highlight_text_color = tmpl["highlight_text"]
|
||
ornament_top = tmpl["ornament_top"]
|
||
ornament_bottom = tmpl["ornament_bottom"]
|
||
kicker = tmpl["kicker"]
|
||
emoji = tmpl["banner_emoji"]
|
||
|
||
h = (heading or tmpl["default_heading"]).strip()
|
||
sh = (sub_heading or tmpl["default_sub_heading"]).strip()
|
||
so = (sign_off or tmpl.get("default_sign_off", "")).strip()
|
||
fn = (footer_note or tmpl["default_footer_note"]).strip()
|
||
hl = (highlight_text or tmpl["default_highlight"]).strip()
|
||
body_text = (body or tmpl["default_body"]).strip()
|
||
|
||
body_html_inner = _body_to_html(body_text, font_stack, body_color, accent)
|
||
body_html = (
|
||
f'<div style="font-family:{font_stack};color:{body_color};font-size:16px;line-height:1.7;">'
|
||
f'{body_html_inner}'
|
||
f'</div>'
|
||
)
|
||
|
||
highlight_html = ""
|
||
if hl:
|
||
highlight_html = f"""
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:8px 0 18px;">
|
||
<tr><td style="background:{highlight_bg};border-left:4px solid {highlight_border};padding:14px 18px;border-radius:8px;">
|
||
<p style="margin:0;font-family:{font_stack};font-size:14px;line-height:1.55;color:{highlight_text_color};font-weight:600;">
|
||
{hl}
|
||
</p>
|
||
</td></tr>
|
||
</table>"""
|
||
|
||
cta_html = _bulletproof_button(cta_label, cta_url, accent, accent_text, font_stack)
|
||
|
||
sub_heading_html = ""
|
||
if sh:
|
||
sub_heading_html = f"""
|
||
<p style="margin:0 0 22px;font-family:{heading_font_stack};font-style:italic;font-size:16px;line-height:1.5;color:{muted_color};">
|
||
{sh}
|
||
</p>"""
|
||
|
||
ornament_top_html = ""
|
||
if ornament_top:
|
||
ornament_top_html = f"""
|
||
<tr>
|
||
<td align="center" style="background:{card_bg};padding:18px 24px 0;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.4em;color:{accent};line-height:1;">
|
||
{ornament_top}
|
||
</td>
|
||
</tr>"""
|
||
|
||
ornament_bottom_html = ""
|
||
if ornament_bottom:
|
||
ornament_bottom_html = f"""
|
||
<tr>
|
||
<td align="center" style="background:{card_bg};padding:0 24px 22px;font-family:Arial,sans-serif;font-size:15px;letter-spacing:0.4em;color:{accent};line-height:1;">
|
||
{ornament_bottom}
|
||
</td>
|
||
</tr>"""
|
||
|
||
kicker_html = f"""
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px;">
|
||
<tr><td style="background:{accent};border-radius:999px;padding:8px 16px;font-family:{font_stack};font-size:11px;font-weight:700;color:{accent_text};letter-spacing:0.12em;text-transform:uppercase;">
|
||
{(emoji + ' ') if emoji else ''}{kicker}
|
||
</td></tr>
|
||
</table>"""
|
||
|
||
font_link_html = ""
|
||
if font_link:
|
||
font_link_html = (
|
||
f'<!--[if !mso]><!--><link href="{font_link}" rel="stylesheet" type="text/css"><!--<![endif]-->'
|
||
)
|
||
|
||
return f"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en">
|
||
<head>
|
||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta name="x-apple-disable-message-reformatting">
|
||
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no">
|
||
{font_link_html}
|
||
<!--[if mso]>
|
||
<xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml>
|
||
<![endif]-->
|
||
<title>{h}</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:{page_bg};width:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||
<div style="display:none;max-height:0;overflow:hidden;font-size:1px;line-height:1px;color:{page_bg};opacity:0;">{sh or h}</div>
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:{page_bg};">
|
||
<tr><td align="center" style="padding:24px 12px;">
|
||
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;background:{card_bg};border-radius:18px;overflow:hidden;">
|
||
|
||
<tr>
|
||
<td align="center" style="background:{band_bg};padding:14px 20px;font-family:{font_stack};font-size:13px;letter-spacing:0.18em;color:{band_text};font-weight:700;">
|
||
{band_decoration}
|
||
</td>
|
||
</tr>
|
||
|
||
<tr>
|
||
<td align="center" style="background:#213021;padding:22px 24px;">
|
||
<img src="{LOGO_URL}" width="161" height="32" alt="Goodwalk" style="display:block;max-width:161px;height:auto;border:0;outline:none;text-decoration:none;">
|
||
</td>
|
||
</tr>
|
||
|
||
{ornament_top_html}
|
||
|
||
<tr>
|
||
<td style="background:{card_bg};padding:30px 30px 8px;">
|
||
{kicker_html}
|
||
<h1 style="margin:0 0 14px;font-family:{heading_font_stack};font-size:32px;line-height:1.1;letter-spacing:-0.02em;color:{heading_color};font-weight:700;">
|
||
{h}
|
||
</h1>
|
||
{sub_heading_html}
|
||
{body_html}
|
||
{highlight_html}
|
||
{cta_html}
|
||
{('<p style="margin:24px 0 0;font-family:' + font_stack + ';font-size:14px;line-height:1.6;color:' + muted_color + ';">With love,<br><strong style="color:' + heading_color + ';">' + so + '</strong></p>') if so else ''}
|
||
</td>
|
||
</tr>
|
||
|
||
{ornament_bottom_html}
|
||
|
||
<tr>
|
||
<td align="center" style="background:{footer_bg};padding:22px 24px 18px;font-family:{font_stack};font-size:12px;line-height:1.6;color:{footer_text_color};">
|
||
<div style="font-size:14px;letter-spacing:0.3em;margin-bottom:8px;color:{footer_text_color};">{ornament_bottom or '🐾 · 🐾 · 🐾'}</div>
|
||
{('<div style="font-weight:600;">' + fn + '</div>') if fn else ''}
|
||
</td>
|
||
</tr>
|
||
|
||
</table>
|
||
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
@app.get("/owner/message-templates")
|
||
async def owner_message_templates(request: Request):
|
||
await _require_owner_email(request)
|
||
templates = [
|
||
{
|
||
"id": t["id"],
|
||
"name": t["name"],
|
||
"description": t["description"],
|
||
"accent": t["accent"],
|
||
"bannerEmoji": t["banner_emoji"],
|
||
"defaultSubject": t["default_subject"],
|
||
"defaultHeading": t["default_heading"],
|
||
"defaultSubHeading": t["default_sub_heading"],
|
||
"defaultBody": t["default_body"],
|
||
"defaultHighlight": t["default_highlight"],
|
||
"defaultSignOff": t.get("default_sign_off", ""),
|
||
"defaultFooterNote": t["default_footer_note"],
|
||
}
|
||
for t in MESSAGE_TEMPLATES.values()
|
||
]
|
||
fonts = [
|
||
{"id": f["id"], "name": f["name"], "link": f["link"], "stack": f["stack"]}
|
||
for f in MESSAGE_FONTS.values()
|
||
]
|
||
return {"ok": True, "templates": templates, "fonts": fonts}
|
||
|
||
|
||
@app.post("/owner/render-message")
|
||
async def owner_render_message(data: RenderMessageRequest, request: Request):
|
||
await _require_owner_email(request)
|
||
if data.templateId not in MESSAGE_TEMPLATES:
|
||
raise HTTPException(status_code=400, detail="Unknown template.")
|
||
html = _render_message_html(
|
||
data.templateId,
|
||
data.heading,
|
||
data.body,
|
||
data.ctaLabel,
|
||
data.ctaUrl,
|
||
sub_heading=data.subHeading,
|
||
highlight_text=data.highlightText,
|
||
sign_off=data.signOff,
|
||
footer_note=data.footerNote,
|
||
font_id=data.fontId,
|
||
)
|
||
return {"ok": True, "html": html}
|
||
|
||
|
||
@app.post("/owner/render-welcome-pack")
|
||
async def owner_render_welcome_pack(data: WelcomePackEmailRequest, request: Request):
|
||
"""Render the welcome pack email as HTML for in-modal preview."""
|
||
await _require_owner_email(request)
|
||
|
||
email = str(data.email).strip().lower()
|
||
profile = _client_profiles.get(email, {})
|
||
owner_name = str(profile.get("fullName", "")).strip()
|
||
dog_name = str(profile.get("dogName", "")).strip()
|
||
|
||
html = _welcome_pack_email_html(
|
||
owner_name,
|
||
dog_name,
|
||
_trimmed(data.serviceType),
|
||
_trimmed(data.priceDetails),
|
||
_trimmed(data.startDate),
|
||
)
|
||
return {"ok": True, "html": html}
|
||
|
||
|
||
@app.post("/owner/render-birthday-email")
|
||
async def owner_render_birthday_email(data: BirthdayEmailRequest, request: Request):
|
||
"""Render the birthday email as HTML for in-modal preview."""
|
||
await _require_owner_email(request)
|
||
|
||
email = str(data.email).strip().lower()
|
||
profile = _client_profiles.get(email, {})
|
||
if not profile:
|
||
raise HTTPException(status_code=404, detail="Client profile not found.")
|
||
|
||
owner_name = str(profile.get("fullName", "")).strip()
|
||
dog_name = str(profile.get("dogName", "")).strip()
|
||
html = _birthday_email_html(owner_name, dog_name)
|
||
return {"ok": True, "html": html}
|
||
|
||
|
||
@app.post("/owner/send-message")
|
||
async def owner_send_message(data: SendMessageRequest, request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
owner_email = await _require_owner_email(request)
|
||
|
||
if data.templateId not in MESSAGE_TEMPLATES:
|
||
raise HTTPException(status_code=400, detail="Unknown template.")
|
||
subject = _trimmed(data.subject)
|
||
if not subject:
|
||
raise HTTPException(status_code=400, detail="Please enter a subject.")
|
||
is_preview = bool(data.preview)
|
||
recipient_emails = [str(e).strip().lower() for e in (data.recipients or []) if str(e).strip()]
|
||
|
||
if not is_preview and not recipient_emails:
|
||
raise HTTPException(status_code=400, detail="Please choose at least one recipient.")
|
||
|
||
html = _render_message_html(
|
||
data.templateId,
|
||
data.heading,
|
||
data.body,
|
||
data.ctaLabel,
|
||
data.ctaUrl,
|
||
sub_heading=data.subHeading,
|
||
highlight_text=data.highlightText,
|
||
sign_off=data.signOff,
|
||
footer_note=data.footerNote,
|
||
font_id=data.fontId,
|
||
)
|
||
owner_addr = OWNER_EMAIL.strip().lower()
|
||
|
||
if is_preview:
|
||
payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [owner_addr],
|
||
"reply_to": REPLY_TO,
|
||
"subject": f"[PREVIEW] {subject}",
|
||
"html": html,
|
||
}
|
||
try:
|
||
await _send_email(payload, label="bulk_message_preview", request_id=request_id)
|
||
except Exception as exc:
|
||
logger.error("[%s] bulk message preview failed: %s", request_id, exc, exc_info=True)
|
||
raise HTTPException(status_code=502, detail={"request_id": request_id, "message": "The preview could not be sent."})
|
||
return {"ok": True, "preview": True}
|
||
|
||
# Real send — always BCC, To: owner. Each recipient sees only owner in To.
|
||
payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [owner_addr],
|
||
"bcc": recipient_emails,
|
||
"reply_to": REPLY_TO,
|
||
"subject": subject,
|
||
"html": html,
|
||
}
|
||
try:
|
||
await _send_email(payload, label="bulk_message", request_id=request_id)
|
||
except Exception as exc:
|
||
logger.error("[%s] bulk message failed: %s", request_id, exc, exc_info=True)
|
||
raise HTTPException(status_code=502, detail={"request_id": request_id, "message": "The message could not be sent."})
|
||
|
||
logger.info("[%s] bulk message sent: template=%s recipients=%d", request_id, data.templateId, len(recipient_emails))
|
||
await admin_db.record_event(
|
||
event_type="owner_message_sent",
|
||
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
|
||
detail={
|
||
"templateId": data.templateId,
|
||
"subject": subject,
|
||
"recipientCount": len(recipient_emails),
|
||
"recipients": recipient_emails,
|
||
},
|
||
)
|
||
return {"ok": True, "recipientCount": len(recipient_emails)}
|
||
|
||
|
||
@app.get("/owner/client-enquiry")
|
||
async def owner_client_enquiry(request: Request):
|
||
await _require_owner_email(request)
|
||
email = (request.query_params.get("email") or "").strip().lower()
|
||
if not email:
|
||
raise HTTPException(status_code=400, detail="Email is required.")
|
||
profile = _client_profiles.get(email)
|
||
if not profile:
|
||
raise HTTPException(status_code=404, detail="Client not found.")
|
||
enquiry = profile.get("lastEnquiry") if isinstance(profile.get("lastEnquiry"), dict) else None
|
||
if not enquiry:
|
||
# Fall back to legacy profile fields if no enquiry snapshot was stored
|
||
enquiry = {
|
||
"submittedAt": profile.get("lastEnquiryAt", ""),
|
||
"enquiryType": profile.get("enquiryType", ""),
|
||
"fullName": profile.get("fullName", ""),
|
||
"email": email,
|
||
"phone": profile.get("phone", ""),
|
||
"petName": profile.get("dogName", ""),
|
||
"location": profile.get("location", ""),
|
||
"services": profile.get("services", []) if isinstance(profile.get("services"), list) else [],
|
||
"message": "",
|
||
"referrer": "",
|
||
"page": "",
|
||
}
|
||
# Journey is populated by the SvelteKit /api/track/promote endpoint when
|
||
# the visitor submits the booking form. None means we never recorded a
|
||
# journey for this email (legacy submission, ad-blocker that also blocked
|
||
# /api/track, or DB-less local dev).
|
||
journey = await admin_db.get_submission_journey(email)
|
||
return {"ok": True, "enquiry": enquiry, "journey": journey}
|
||
|
||
|
||
@app.get("/owner/activity")
|
||
async def owner_activity(request: Request):
|
||
await _require_owner_email(request)
|
||
qp = request.query_params
|
||
try:
|
||
limit = int(qp.get("limit", "100"))
|
||
except ValueError:
|
||
limit = 100
|
||
before_id = qp.get("beforeId") or qp.get("before_id")
|
||
try:
|
||
before_id_int = int(before_id) if before_id else None
|
||
except ValueError:
|
||
before_id_int = None
|
||
event_type = _trimmed(qp.get("eventType", "")) or None
|
||
actor_email = _trimmed(qp.get("actorEmail", "")) or None
|
||
events = await admin_db.list_events(
|
||
limit=limit,
|
||
before_id=before_id_int,
|
||
event_type=event_type,
|
||
actor_email=actor_email,
|
||
)
|
||
return {"ok": True, "events": events}
|
||
|
||
|
||
@app.get("/owner/submissions")
|
||
async def owner_submissions(request: Request):
|
||
await _require_owner_email(request)
|
||
qp = request.query_params
|
||
try:
|
||
limit = int(qp.get("limit", "100"))
|
||
except ValueError:
|
||
limit = 100
|
||
before_id = qp.get("beforeId") or qp.get("before_id")
|
||
try:
|
||
before_id_int = int(before_id) if before_id else None
|
||
except ValueError:
|
||
before_id_int = None
|
||
kind = _trimmed(qp.get("kind", "")) or None
|
||
email_filter = _trimmed(qp.get("email", "")) or None
|
||
rows = await admin_db.list_submissions(
|
||
limit=limit,
|
||
before_id=before_id_int,
|
||
kind=kind,
|
||
email=email_filter,
|
||
)
|
||
return {"ok": True, "submissions": rows}
|
||
|
||
|
||
@app.get("/owner/pending-onboarding")
|
||
async def owner_pending_onboarding(request: Request):
|
||
await _require_owner_email(request)
|
||
|
||
def _sort_timestamp(value: Any) -> float:
|
||
if not isinstance(value, str) or not value:
|
||
return 0
|
||
try:
|
||
return datetime.fromisoformat(value).timestamp()
|
||
except ValueError:
|
||
return 0
|
||
|
||
pending_clients: list[dict[str, Any]] = []
|
||
for email, profile in _client_profiles.items():
|
||
if email == OWNER_EMAIL.strip().lower():
|
||
continue
|
||
if profile.get("onboardingCompleted"):
|
||
continue
|
||
if not _client_is_reachable(profile):
|
||
continue
|
||
|
||
pending_clients.append({
|
||
"email": email,
|
||
"fullName": profile.get("fullName", ""),
|
||
"phone": profile.get("phone", ""),
|
||
"dogName": profile.get("dogName", ""),
|
||
"dogBreed": profile.get("dogBreed", ""),
|
||
"services": profile.get("services", []) if isinstance(profile.get("services"), list) else [],
|
||
"lastEnquiryAt": profile.get("lastEnquiryAt", ""),
|
||
"welcomePackSentAt": profile.get("welcomePackSentAt", ""),
|
||
"welcomePackOffer": profile.get("welcomePackOffer", {}) if isinstance(profile.get("welcomePackOffer"), dict) else {},
|
||
})
|
||
|
||
pending_clients.sort(
|
||
key=lambda item: (
|
||
item.get("welcomePackSentAt", "") != "",
|
||
-_sort_timestamp(item.get("lastEnquiryAt")),
|
||
item.get("fullName", "").lower(),
|
||
),
|
||
)
|
||
|
||
return {"ok": True, "clients": pending_clients}
|
||
|
||
|
||
@app.get("/owner/completed-onboarding")
|
||
async def owner_completed_onboarding(request: Request):
|
||
await _require_owner_email(request)
|
||
|
||
def _sort_timestamp(value: Any) -> float:
|
||
if not isinstance(value, str) or not value:
|
||
return 0
|
||
try:
|
||
return datetime.fromisoformat(value).timestamp()
|
||
except ValueError:
|
||
return 0
|
||
|
||
try:
|
||
page = max(1, int(request.query_params.get("page", "1")))
|
||
except ValueError:
|
||
page = 1
|
||
try:
|
||
page_size = min(24, max(1, int(request.query_params.get("page_size", "10"))))
|
||
except ValueError:
|
||
page_size = 10
|
||
|
||
completed_clients: list[dict[str, Any]] = []
|
||
for email, profile in _client_profiles.items():
|
||
if email == OWNER_EMAIL.strip().lower():
|
||
continue
|
||
if not profile.get("onboardingCompleted"):
|
||
continue
|
||
if not _client_is_reachable(profile):
|
||
continue
|
||
|
||
completed_clients.append({
|
||
"email": email,
|
||
"fullName": profile.get("fullName", ""),
|
||
"phone": profile.get("phone", ""),
|
||
"address": profile.get("address", ""),
|
||
"dogName": profile.get("dogName", ""),
|
||
"dogBreed": profile.get("dogBreed", ""),
|
||
"dogAge": profile.get("dogAge", ""),
|
||
"onboardingSubmittedAt": profile.get("onboardingSubmittedAt", ""),
|
||
"hasBirthdayInvite": bool(_trimmed(str(profile.get("dogAge", "")))),
|
||
})
|
||
|
||
completed_clients.sort(
|
||
key=lambda item: (
|
||
-_sort_timestamp(item.get("onboardingSubmittedAt")),
|
||
item.get("fullName", "").lower(),
|
||
),
|
||
)
|
||
|
||
total = len(completed_clients)
|
||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||
page = min(page, total_pages)
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
|
||
return {
|
||
"ok": True,
|
||
"clients": completed_clients[start:end],
|
||
"pagination": {
|
||
"page": page,
|
||
"pageSize": page_size,
|
||
"total": total,
|
||
"totalPages": total_pages,
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/owner/all-clients")
|
||
async def owner_all_clients(request: Request):
|
||
await _require_owner_email(request)
|
||
|
||
def _sort_timestamp(value: Any) -> float:
|
||
if not isinstance(value, str) or not value:
|
||
return 0
|
||
try:
|
||
return datetime.fromisoformat(value).timestamp()
|
||
except ValueError:
|
||
return 0
|
||
|
||
try:
|
||
page = max(1, int(request.query_params.get("page", "1")))
|
||
except ValueError:
|
||
page = 1
|
||
try:
|
||
page_size = min(30, max(1, int(request.query_params.get("page_size", "12"))))
|
||
except ValueError:
|
||
page_size = 12
|
||
|
||
clients: list[dict[str, Any]] = []
|
||
for email, profile in _client_profiles.items():
|
||
if email == OWNER_EMAIL.strip().lower():
|
||
continue
|
||
|
||
lifecycle = profile.get("lifecycle") if isinstance(profile.get("lifecycle"), dict) else None
|
||
clients.append({
|
||
"email": email,
|
||
"fullName": profile.get("fullName", ""),
|
||
"phone": profile.get("phone", ""),
|
||
"dogName": profile.get("dogName", ""),
|
||
"dogBreed": profile.get("dogBreed", ""),
|
||
"status": "completed" if profile.get("onboardingCompleted") else "pending",
|
||
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
|
||
"lastActivityAt": profile.get("onboardingSubmittedAt", "") or profile.get("lastEnquiryAt", "") or profile.get("welcomePackSentAt", ""),
|
||
"welcomePackSentAt": profile.get("welcomePackSentAt", ""),
|
||
})
|
||
|
||
clients.sort(
|
||
key=lambda item: (
|
||
item.get("status") != "pending",
|
||
-_sort_timestamp(item.get("lastActivityAt")),
|
||
item.get("fullName", "").lower(),
|
||
),
|
||
)
|
||
|
||
total = len(clients)
|
||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||
page = min(page, total_pages)
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
|
||
return {
|
||
"ok": True,
|
||
"clients": clients[start:end],
|
||
"pagination": {
|
||
"page": page,
|
||
"pageSize": page_size,
|
||
"total": total,
|
||
"totalPages": total_pages,
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/owner/birthdays")
|
||
async def owner_birthdays(request: Request):
|
||
await _require_owner_email(request)
|
||
|
||
try:
|
||
page = max(1, int(request.query_params.get("page", "1")))
|
||
except ValueError:
|
||
page = 1
|
||
try:
|
||
page_size = min(30, max(1, int(request.query_params.get("page_size", "12"))))
|
||
except ValueError:
|
||
page_size = 12
|
||
|
||
today = datetime.now()
|
||
birthdays: list[dict[str, Any]] = []
|
||
for email, profile in _client_profiles.items():
|
||
if email == OWNER_EMAIL.strip().lower():
|
||
continue
|
||
if not profile.get("onboardingCompleted"):
|
||
continue
|
||
if not _client_is_reachable(profile):
|
||
continue
|
||
|
||
upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")), today)
|
||
if not upcoming:
|
||
continue
|
||
|
||
birthdays.append({
|
||
"email": email,
|
||
"fullName": profile.get("fullName", ""),
|
||
"dogName": profile.get("dogName", ""),
|
||
"dogBreed": profile.get("dogBreed", ""),
|
||
"dogAge": profile.get("dogAge", ""),
|
||
"birthdayLabel": upcoming.isoformat(),
|
||
"daysUntil": (upcoming - today.date()).days,
|
||
"birthdayAutoSend": bool(profile.get("birthdayAutoSend")),
|
||
"birthdayEmailLastSentAt": profile.get("birthdayEmailLastSentAt", ""),
|
||
})
|
||
|
||
birthdays.sort(
|
||
key=lambda item: (
|
||
item.get("daysUntil", 10**9),
|
||
item.get("dogName", "").lower(),
|
||
item.get("fullName", "").lower(),
|
||
),
|
||
)
|
||
|
||
total = len(birthdays)
|
||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||
page = min(page, total_pages)
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
|
||
return {
|
||
"ok": True,
|
||
"clients": birthdays[start:end],
|
||
"pagination": {
|
||
"page": page,
|
||
"pageSize": page_size,
|
||
"total": total,
|
||
"totalPages": total_pages,
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/owner/birthday-ics")
|
||
async def owner_birthday_ics(request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
await _require_owner_email(request)
|
||
|
||
email = _trimmed(request.query_params.get("email", "")).lower()
|
||
if not email:
|
||
raise HTTPException(status_code=400, detail="Email is required.")
|
||
|
||
profile = _client_profiles.get(email, {})
|
||
if not profile or not profile.get("onboardingCompleted"):
|
||
raise HTTPException(status_code=404, detail="Completed client not found.")
|
||
|
||
attachment = _birthday_ics_attachment(
|
||
str(profile.get("dogName", "")),
|
||
str(profile.get("dogAge", "")),
|
||
str(profile.get("fullName", "")),
|
||
request_id,
|
||
)
|
||
if not attachment:
|
||
raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.")
|
||
|
||
content = base64.b64decode(attachment["content"])
|
||
return Response(
|
||
content=content,
|
||
media_type="text/calendar; charset=utf-8",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="{attachment["filename"]}"'
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/owner/send-welcome-pack")
|
||
async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
owner_email = await _require_owner_email(request)
|
||
|
||
email = str(data.email).strip().lower()
|
||
profile = _client_profiles.get(email, {})
|
||
|
||
if not profile:
|
||
raise HTTPException(status_code=404, detail="Client profile not found.")
|
||
if profile.get("onboardingCompleted"):
|
||
raise HTTPException(status_code=400, detail="This client has already completed onboarding.")
|
||
if not _trimmed(data.serviceType):
|
||
raise HTTPException(status_code=400, detail="Please enter a service.")
|
||
if not _trimmed(data.priceDetails):
|
||
raise HTTPException(status_code=400, detail="Please enter the price details.")
|
||
if not _trimmed(data.startDate):
|
||
raise HTTPException(status_code=400, detail="Please enter a start date.")
|
||
|
||
owner_name = str(profile.get("fullName", "")).strip()
|
||
dog_name = str(profile.get("dogName", "")).strip()
|
||
sent_at = datetime.now().isoformat(timespec="seconds")
|
||
is_preview = bool(data.preview)
|
||
recipient = OWNER_EMAIL.strip().lower() if is_preview else email
|
||
subject = "Welcome to the pack | Goodwalk"
|
||
if is_preview:
|
||
subject = f"[PREVIEW for {owner_name or email}] {subject}"
|
||
|
||
payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [recipient],
|
||
"reply_to": REPLY_TO,
|
||
"subject": subject,
|
||
"html": _welcome_pack_email_html(owner_name, dog_name, _trimmed(data.serviceType), _trimmed(data.priceDetails), _trimmed(data.startDate)),
|
||
}
|
||
if CLIENT_BCC and not is_preview:
|
||
payload["bcc"] = [CLIENT_BCC]
|
||
|
||
try:
|
||
await _send_email(payload, label="welcome_pack_email_preview" if is_preview else "welcome_pack_email", request_id=request_id)
|
||
except Exception as exc:
|
||
logger.error("[%s] welcome pack email failed: %s", request_id, exc, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={
|
||
"request_id": request_id,
|
||
"message": "The welcome email could not be sent. Please try again shortly.",
|
||
"error_type": type(exc).__name__,
|
||
},
|
||
)
|
||
|
||
if is_preview:
|
||
logger.info("[%s] welcome pack PREVIEW sent: original_recipient=%s -> owner", request_id, email)
|
||
return {"ok": True, "sentAt": sent_at, "preview": True}
|
||
|
||
await _store_client_profile(email, {
|
||
"welcomePackSentAt": sent_at,
|
||
"welcomePackOffer": {
|
||
"serviceType": _trimmed(data.serviceType),
|
||
"priceDetails": _trimmed(data.priceDetails),
|
||
"startDate": _trimmed(data.startDate),
|
||
"sentAt": sent_at,
|
||
},
|
||
})
|
||
|
||
logger.info("[%s] welcome pack sent: email=%s service=%s start=%s", request_id, email, data.serviceType, data.startDate)
|
||
await admin_db.record_event(
|
||
event_type="owner_welcome_pack_sent",
|
||
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
|
||
detail={
|
||
"recipient": email,
|
||
"serviceType": _trimmed(data.serviceType),
|
||
"startDate": _trimmed(data.startDate),
|
||
},
|
||
)
|
||
return {"ok": True, "sentAt": sent_at}
|
||
|
||
|
||
@app.post("/owner/send-birthday-email")
|
||
async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
owner_email = await _require_owner_email(request)
|
||
|
||
email = str(data.email).strip().lower()
|
||
profile = _client_profiles.get(email, {})
|
||
if not profile or not profile.get("onboardingCompleted"):
|
||
raise HTTPException(status_code=404, detail="Completed client not found.")
|
||
|
||
if not _upcoming_birthday_date(str(profile.get("dogAge", ""))):
|
||
raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.")
|
||
|
||
try:
|
||
await _send_birthday_email_for_profile(email, profile, request_id, preview=bool(data.preview))
|
||
except Exception as exc:
|
||
logger.error("[%s] birthday email failed: %s", request_id, exc, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={
|
||
"request_id": request_id,
|
||
"message": "The birthday email could not be sent. Please try again shortly.",
|
||
"error_type": type(exc).__name__,
|
||
},
|
||
)
|
||
|
||
await admin_db.record_event(
|
||
event_type="owner_birthday_email_sent",
|
||
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
|
||
detail={"recipient": email, "preview": bool(data.preview)},
|
||
)
|
||
return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": bool(data.preview)}
|
||
|
||
|
||
@app.post("/owner/client-status")
|
||
async def owner_client_status(data: ClientStatusUpdate, request: Request):
|
||
"""Set a client's lifecycle status (active / paused / cancelled / archived).
|
||
|
||
Soft-delete only: no client record is ever removed. Each change is recorded
|
||
in the profile's lifecycleHistory list and the global activity feed.
|
||
"""
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
owner_email = await _require_owner_email(request)
|
||
|
||
email = str(data.email).strip().lower()
|
||
profile = _client_profiles.get(email)
|
||
if not profile:
|
||
raise HTTPException(status_code=404, detail="Client not found.")
|
||
|
||
reason = (data.reason or "").strip()[:500]
|
||
now_iso = datetime.now().isoformat(timespec="seconds")
|
||
|
||
existing_history = profile.get("lifecycleHistory")
|
||
history: list[dict[str, Any]] = list(existing_history) if isinstance(existing_history, list) else []
|
||
history.append({
|
||
"status": data.status,
|
||
"reason": reason,
|
||
"changedAt": now_iso,
|
||
"changedBy": owner_email,
|
||
})
|
||
# Cap history to a sensible size so the JSON file doesn't grow unbounded.
|
||
history = history[-50:]
|
||
|
||
lifecycle = {
|
||
"status": data.status,
|
||
"reason": reason,
|
||
"changedAt": now_iso,
|
||
"changedBy": owner_email,
|
||
}
|
||
|
||
await _store_client_profile(email, {
|
||
"lifecycle": lifecycle,
|
||
"lifecycleHistory": history,
|
||
})
|
||
|
||
await admin_db.record_event(
|
||
event_type="owner_client_status_changed",
|
||
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
|
||
detail={"clientEmail": email, "status": data.status, "reason": reason},
|
||
)
|
||
logger.info("[%s] owner: %s set %s -> %s", request_id, owner_email, email, data.status)
|
||
return {"ok": True, "email": email, "lifecycle": lifecycle}
|
||
|
||
|
||
@app.post("/owner/birthday-auto-send")
|
||
async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Request):
|
||
owner_email = await _require_owner_email(request)
|
||
|
||
email = str(data.email).strip().lower()
|
||
profile = _client_profiles.get(email, {})
|
||
if not profile or not profile.get("onboardingCompleted"):
|
||
raise HTTPException(status_code=404, detail="Completed client not found.")
|
||
|
||
if not _upcoming_birthday_date(str(profile.get("dogAge", ""))):
|
||
raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.")
|
||
|
||
await _store_client_profile(email, {
|
||
"birthdayAutoSend": data.enabled,
|
||
})
|
||
|
||
await admin_db.record_event(
|
||
event_type="owner_birthday_auto_toggled",
|
||
actor_email=owner_email, ip=_get_ip(request), status="ok",
|
||
detail={"clientEmail": email, "enabled": bool(data.enabled)},
|
||
)
|
||
return {"ok": True, "enabled": data.enabled}
|
||
|
||
|
||
@app.post("/submit")
|
||
async def submit_booking(data: BookingSubmission, request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
ip = _get_ip(request)
|
||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||
|
||
if _is_deploy_smoke(request):
|
||
logger.info("[%s] /submit deploy-smoke bypass (no email, no db write)", request_id)
|
||
return {"ok": True, "request_id": request_id, "smoke": True}
|
||
|
||
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
||
_enforce_form_timing(request_id, data)
|
||
|
||
if _is_honeypot_triggered(data):
|
||
logger.warning(
|
||
"[%s] honeypot triggered for ip=%s email=%s page=%r",
|
||
request_id,
|
||
ip,
|
||
data.email,
|
||
data.page,
|
||
)
|
||
await admin_db.record_event(
|
||
event_type="booking_honeypot",
|
||
request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored",
|
||
detail={"page": data.page},
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"request_id": request_id,
|
||
"ignored": True,
|
||
}
|
||
|
||
_validate_submission(request_id, data)
|
||
_normalize_submission(data)
|
||
|
||
name_parts = data.fullName.split()
|
||
first_name = name_parts[0] if name_parts else "there"
|
||
|
||
logger.info(
|
||
"[%s] /submit: type=%s email=%s ip=%s browser=%r dog=%s services=%s page=%r",
|
||
request_id, data.enquiryType, data.email, ip, browser, data.petName, data.services, data.page,
|
||
)
|
||
# PII intentionally NOT logged here — payload contains submitter contact details.
|
||
logger.debug("[%s] booking payload keys=%s", request_id, sorted(data.model_dump().keys()))
|
||
|
||
failures: list[dict] = []
|
||
|
||
client_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [data.email],
|
||
"reply_to": REPLY_TO,
|
||
"subject": f"We received your {'general enquiry' if _is_general_enquiry(data) else 'enquiry'}, {first_name}! 🐾",
|
||
"html": client_email(data),
|
||
}
|
||
if CLIENT_BCC:
|
||
client_payload["bcc"] = [CLIENT_BCC]
|
||
|
||
try:
|
||
await _send_email(
|
||
client_payload,
|
||
label="client_email",
|
||
request_id=request_id,
|
||
)
|
||
except Exception as exc:
|
||
failures.append({
|
||
"label": "client_email",
|
||
"error_type": type(exc).__name__,
|
||
"error": str(exc),
|
||
"status": getattr(exc, "status_code", None) or getattr(exc, "code", None),
|
||
})
|
||
|
||
owner_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [OWNER_EMAIL],
|
||
"reply_to": data.email,
|
||
"subject": (
|
||
f"New GoodWalk general enquiry — {data.fullName}"
|
||
if _is_general_enquiry(data)
|
||
else f"New GoodWalk lead — {data.fullName} ({data.petName})"
|
||
),
|
||
"html": owner_email(data, ip, browser),
|
||
}
|
||
if OWNER_BCC:
|
||
owner_payload["bcc"] = [OWNER_BCC]
|
||
|
||
try:
|
||
await _send_email(
|
||
owner_payload,
|
||
label="owner_email",
|
||
request_id=request_id,
|
||
)
|
||
except Exception as exc:
|
||
failures.append({
|
||
"label": "owner_email",
|
||
"error_type": type(exc).__name__,
|
||
"error": str(exc),
|
||
"status": getattr(exc, "status_code", None) or getattr(exc, "code", None),
|
||
})
|
||
|
||
if len(failures) == 2:
|
||
logger.error("[%s] both emails failed after retries: %s", request_id, failures)
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={
|
||
"request_id": request_id,
|
||
"message": "Both confirmation and notification emails failed to send. Please try again shortly.",
|
||
"failures": failures,
|
||
},
|
||
)
|
||
|
||
if failures:
|
||
logger.warning("[%s] partial failure: %s", request_id, failures)
|
||
|
||
await _register_email(str(data.email))
|
||
enquiry_at = datetime.now().isoformat(timespec="seconds")
|
||
await _store_client_profile(str(data.email), {
|
||
"fullName": data.fullName,
|
||
"phone": data.phone,
|
||
"dogName": data.petName,
|
||
"services": data.services,
|
||
"location": data.location,
|
||
"enquiryType": data.enquiryType,
|
||
"lastEnquiryAt": enquiry_at,
|
||
"lastEnquiry": {
|
||
"submittedAt": enquiry_at,
|
||
"enquiryType": data.enquiryType,
|
||
"fullName": data.fullName,
|
||
"email": str(data.email),
|
||
"phone": data.phone,
|
||
"petName": data.petName,
|
||
"location": data.location,
|
||
"services": data.services,
|
||
"message": data.message,
|
||
"referrer": data.referrer,
|
||
"page": data.page,
|
||
},
|
||
})
|
||
|
||
await admin_db.record_submission(
|
||
kind="booking",
|
||
email=str(data.email), full_name=data.fullName, phone=data.phone,
|
||
ip=ip, request_id=request_id, payload=data.model_dump(),
|
||
)
|
||
await admin_db.record_event(
|
||
event_type="booking_submitted",
|
||
request_id=request_id, actor_email=str(data.email), ip=ip,
|
||
status="partial" if failures else "ok",
|
||
detail={
|
||
"enquiryType": data.enquiryType,
|
||
"dog": data.petName,
|
||
"services": data.services,
|
||
"failures": [f["label"] for f in failures],
|
||
},
|
||
)
|
||
|
||
return {
|
||
"ok": True,
|
||
"request_id": request_id,
|
||
"partial_failures": [f["label"] for f in failures],
|
||
}
|
||
|
||
|
||
def _validate_contract_submission(request_id: str, data: ContractSubmission) -> None:
|
||
if not _trimmed(data.fullName):
|
||
raise HTTPException(status_code=400, detail="Please enter your full name.")
|
||
if not _trimmed(data.phone):
|
||
raise HTTPException(status_code=400, detail="Please enter your phone number.")
|
||
for field_name, message in {
|
||
"address": "Please enter your address.",
|
||
"dogName": "Please enter your dog's name.",
|
||
"dogBreed": "Please enter your dog's breed.",
|
||
"serviceType": "Please select a service type.",
|
||
"startDate": "Please enter a start date.",
|
||
}.items():
|
||
if not _trimmed(getattr(data, field_name)):
|
||
logger.warning("[%s] contract rejected: missing %s", request_id, field_name)
|
||
raise HTTPException(status_code=400, detail=message)
|
||
|
||
if not all([data.agreeServiceTerms, data.agreeCancellation, data.agreePayment,
|
||
data.agreeEmergency, data.agreeLiability, data.agreeAccuracy]):
|
||
logger.warning("[%s] contract rejected: incomplete declarations", request_id)
|
||
raise HTTPException(status_code=400, detail="Please confirm all declarations before signing.")
|
||
|
||
signature = _trimmed(data.signatureDataUrl)
|
||
if not signature.startswith("data:image/png;base64,") or len(signature) < 128:
|
||
logger.warning("[%s] contract rejected: invalid signature payload", request_id)
|
||
raise HTTPException(status_code=400, detail="Please add your signature before sending.")
|
||
|
||
|
||
def _normalize_contract_submission(data: ContractSubmission) -> None:
|
||
data.fullName = _trimmed(data.fullName)
|
||
data.phone = _trimmed(data.phone)
|
||
data.address = _trimmed(data.address)
|
||
data.dogName = _trimmed(data.dogName)
|
||
data.dogBreed = _trimmed(data.dogBreed)
|
||
data.dogAge = _trimmed(data.dogAge)
|
||
data.serviceType = _trimmed(data.serviceType)
|
||
data.startDate = _trimmed(data.startDate)
|
||
data.walkFrequency = _trimmed(data.walkFrequency)
|
||
data.additionalNotes = _trimmed(data.additionalNotes)
|
||
data.referrer = _trimmed(data.referrer)
|
||
data.page = _trimmed(data.page)
|
||
|
||
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
|
||
value = getattr(data, field_name)
|
||
if value is None or value <= 0:
|
||
setattr(data, field_name, None)
|
||
|
||
|
||
def owner_contract_email(data: ContractSubmission, ip: str, browser: str) -> str:
|
||
submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0")
|
||
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
|
||
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
|
||
referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark")
|
||
page_row = _meta_row("Page", data.page) if data.page else ""
|
||
|
||
notes_block = f"""
|
||
<tr>
|
||
<td colspan="2" style="padding:16px 0 0;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
||
text-transform:uppercase;margin-bottom:8px;">Additional notes</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
||
border-radius:8px;padding:14px 16px;">{data.additionalNotes}</div>
|
||
</td>
|
||
</tr>""" if data.additionalNotes else ""
|
||
|
||
signature_block = f"""
|
||
<div style="margin-top:16px;border-radius:16px;background:#ffffff;border:1px solid #e3e3db;padding:14px 14px 10px;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
||
text-transform:uppercase;margin-bottom:10px;">Captured signature</div>
|
||
<img src="{data.signatureDataUrl}" alt="Client signature" style="display:block;max-width:100%;height:auto;border-radius:10px;background:#fff;">
|
||
</div>"""
|
||
|
||
badge = f"""<div style="display:inline-block;background:#FFD100;border-radius:100px;
|
||
padding:10px 28px;">
|
||
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:18px;font-weight:700;color:#213021;">
|
||
📜 New signed contract
|
||
</span>
|
||
</div>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
font-size:12px;color:#5a8a5a;margin-top:12px;">
|
||
Submitted {submitted_at}
|
||
</div>"""
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>New GoodWalk service contract</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f2f2f0;padding:40px 16px;">
|
||
<tr><td align="center">
|
||
<table width="680" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="max-width:680px;width:100%;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||
|
||
{_logo_header(badge_html=badge, subtitle="Signed service agreement")}
|
||
|
||
<tr>
|
||
<td style="background:#ffffff;padding:38px 40px 34px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||
style="background:#213021;border-radius:12px;margin-bottom:26px;">
|
||
<tr>
|
||
<td style="padding:22px 24px;">
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;text-transform:uppercase;margin-bottom:10px;">
|
||
Quick contact
|
||
</div>
|
||
<div style="margin-bottom:10px;">
|
||
<a href="mailto:{data.email}" style="display:inline-block;background:#ffffff;color:#213021;text-decoration:none;border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;font-family:Menlo,Consolas,'SFMono-Regular',monospace;font-size:18px;font-weight:700;">{data.email}</a>
|
||
</div>
|
||
<a href="tel:{data.phone}" style="display:inline-block;background:#ffd100;color:#213021;text-decoration:none;border-radius:999px;padding:10px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;font-weight:700;">Call {data.phone}</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;text-transform:uppercase;margin-bottom:16px;">Client details</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Name", data.fullName)}
|
||
{_detail_row("Email", str(data.email))}
|
||
{_detail_row("Phone", data.phone)}
|
||
{_detail_row("Address", data.address)}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;text-transform:uppercase;margin-bottom:16px;">Service agreement</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Dog", data.dogName)}
|
||
{_detail_row("Breed", data.dogBreed)}
|
||
{_detail_row("Age", data.dogAge or "—")}
|
||
{_detail_row("Service", data.serviceType)}
|
||
{_detail_row("Start date", data.startDate)}
|
||
{_detail_row("Frequency", data.walkFrequency or "—")}
|
||
{notes_block}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;text-transform:uppercase;margin-bottom:16px;">Declarations confirmed</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8f7f4;border-radius:12px;margin-bottom:24px;">
|
||
<tr><td style="padding:24px 28px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_detail_row("Service terms", "Confirmed")}
|
||
{_detail_row("Cancellation policy", "Confirmed")}
|
||
{_detail_row("Payment terms", "Confirmed")}
|
||
{_detail_row("Emergency consent", "Confirmed")}
|
||
{_detail_row("Liability terms", "Confirmed")}
|
||
{_detail_row("Accuracy declaration", "Confirmed")}
|
||
</table>
|
||
{signature_block}
|
||
</td></tr>
|
||
</table>
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-top:1px solid #eeeee8;padding-top:20px;">
|
||
<tr><td>
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;font-weight:700;letter-spacing:0.08em;color:#ccc;text-transform:uppercase;margin-bottom:12px;">Session info</div>
|
||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||
{_meta_row("IP address", ip)}
|
||
{_meta_row("Browser", browser)}
|
||
{visit_time_row}
|
||
{form_time_row}
|
||
{referrer_row}
|
||
{page_row}
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
@app.post("/onboarding-submit")
|
||
async def submit_onboarding(data: OnboardingSubmission, request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
ip = _get_ip(request)
|
||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||
|
||
if _is_deploy_smoke(request):
|
||
logger.info("[%s] /onboarding-submit deploy-smoke bypass (no email, no db write)", request_id)
|
||
return {"ok": True, "request_id": request_id, "smoke": True}
|
||
|
||
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
||
_enforce_form_timing(request_id, data)
|
||
|
||
if _is_honeypot_triggered(data):
|
||
logger.warning(
|
||
"[%s] onboarding honeypot triggered for ip=%s email=%s page=%r",
|
||
request_id,
|
||
ip,
|
||
data.email,
|
||
data.page,
|
||
)
|
||
await admin_db.record_event(
|
||
event_type="onboarding_honeypot",
|
||
request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored",
|
||
detail={"page": data.page},
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"request_id": request_id,
|
||
"ignored": True,
|
||
}
|
||
|
||
_validate_onboarding_submission(request_id, data)
|
||
_normalize_onboarding_submission(data)
|
||
|
||
logger.info(
|
||
"[%s] /onboarding-submit: email=%s ip=%s browser=%r dog=%s services=%s page=%r",
|
||
request_id, data.email, ip, browser, data.dogName, data.servicesNeeded, data.page,
|
||
)
|
||
# PII intentionally NOT logged here — payload contains address, vet, medical notes, signature.
|
||
logger.debug("[%s] onboarding payload keys=%s", request_id, sorted(data.model_dump().keys()))
|
||
|
||
owner_html = owner_onboarding_email(data, ip, browser)
|
||
owner_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [OWNER_EMAIL],
|
||
"reply_to": data.email,
|
||
"subject": f"New GoodWalk onboarding — {data.fullName} ({data.dogName})",
|
||
"html": owner_html,
|
||
}
|
||
attachments: list[dict] = []
|
||
birthday_attachment = _birthday_ics_attachment(data.dogName, data.dogAge, data.fullName, request_id)
|
||
if birthday_attachment:
|
||
attachments.append(birthday_attachment)
|
||
if ONBOARDING_PDF_ATTACHMENT_ENABLED:
|
||
pdf_html = owner_onboarding_pdf_html(data)
|
||
pdf_attachment = await _signed_form_pdf_attachment(pdf_html, data.fullName, "onboarding", request_id)
|
||
if pdf_attachment:
|
||
attachments.append(pdf_attachment)
|
||
if attachments:
|
||
owner_payload["attachments"] = attachments
|
||
if OWNER_BCC:
|
||
owner_payload["bcc"] = [OWNER_BCC]
|
||
|
||
try:
|
||
await _send_email(
|
||
owner_payload,
|
||
label="owner_onboarding_email",
|
||
request_id=request_id,
|
||
)
|
||
except Exception as exc:
|
||
logger.error("[%s] onboarding email failed after retries: %s", request_id, exc, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={
|
||
"request_id": request_id,
|
||
"message": "The onboarding form could not be delivered. Please try again shortly.",
|
||
"error_type": type(exc).__name__,
|
||
},
|
||
)
|
||
|
||
await _register_email(str(data.email))
|
||
await _store_client_profile(str(data.email), {
|
||
"fullName": data.fullName,
|
||
"phone": data.phone,
|
||
"address": data.address,
|
||
"dogName": data.dogName,
|
||
"dogBreed": data.dogBreed,
|
||
"dogAge": data.dogAge,
|
||
"onboardingCompleted": True,
|
||
"onboardingSubmittedAt": datetime.now().isoformat(timespec="seconds"),
|
||
"onboardingSubmission": data.submissionSnapshot,
|
||
})
|
||
|
||
client_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [str(data.email)],
|
||
"reply_to": REPLY_TO,
|
||
"subject": f"Your Goodwalk onboarding is complete, {data.fullName.split()[0]}",
|
||
"html": _onboarding_confirmation_email_html(data),
|
||
}
|
||
if CLIENT_BCC:
|
||
client_payload["bcc"] = [CLIENT_BCC]
|
||
|
||
try:
|
||
await _send_email(
|
||
client_payload,
|
||
label="client_onboarding_confirmation_email",
|
||
request_id=request_id,
|
||
)
|
||
except Exception as exc:
|
||
logger.error(
|
||
"[%s] client onboarding confirmation email failed: %s",
|
||
request_id,
|
||
exc,
|
||
exc_info=True,
|
||
)
|
||
|
||
await admin_db.record_submission(
|
||
kind="onboarding",
|
||
email=str(data.email), full_name=data.fullName, phone=data.phone,
|
||
ip=ip, request_id=request_id, payload=data.model_dump(),
|
||
)
|
||
await admin_db.record_event(
|
||
event_type="onboarding_submitted",
|
||
request_id=request_id, actor_email=str(data.email), ip=ip, status="ok",
|
||
detail={"dog": data.dogName, "services": data.servicesNeeded},
|
||
)
|
||
|
||
return {
|
||
"ok": True,
|
||
"request_id": request_id,
|
||
}
|
||
|
||
|
||
@app.post("/contract-submit")
|
||
async def submit_contract(data: ContractSubmission, request: Request):
|
||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||
ip = _get_ip(request)
|
||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||
|
||
if _is_deploy_smoke(request):
|
||
logger.info("[%s] /contract-submit deploy-smoke bypass (no email, no db write)", request_id)
|
||
return {"ok": True, "request_id": request_id, "smoke": True}
|
||
|
||
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
||
_enforce_form_timing(request_id, data)
|
||
|
||
if _is_honeypot_triggered(data):
|
||
logger.warning(
|
||
"[%s] contract honeypot triggered for ip=%s email=%s page=%r",
|
||
request_id, ip, data.email, data.page,
|
||
)
|
||
await admin_db.record_event(
|
||
event_type="contract_honeypot",
|
||
request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored",
|
||
detail={"page": data.page},
|
||
)
|
||
return {"ok": True, "request_id": request_id, "ignored": True}
|
||
|
||
_validate_contract_submission(request_id, data)
|
||
_normalize_contract_submission(data)
|
||
|
||
logger.info(
|
||
"[%s] /contract-submit: email=%s ip=%s browser=%r dog=%s service=%s page=%r",
|
||
request_id, data.email, ip, browser, data.dogName, data.serviceType, data.page,
|
||
)
|
||
|
||
owner_html = owner_contract_email(data, ip, browser)
|
||
owner_payload = {
|
||
"from": FROM_EMAIL,
|
||
"to": [OWNER_EMAIL],
|
||
"reply_to": data.email,
|
||
"subject": f"New GoodWalk contract — {data.fullName} ({data.dogName}, {data.serviceType})",
|
||
"html": owner_html,
|
||
}
|
||
if CONTRACT_PDF_ATTACHMENT_ENABLED:
|
||
pdf_attachment = await _signed_form_pdf_attachment(owner_html, data.fullName, "contract", request_id)
|
||
if pdf_attachment:
|
||
owner_payload["attachments"] = [pdf_attachment]
|
||
if OWNER_BCC:
|
||
owner_payload["bcc"] = [OWNER_BCC]
|
||
|
||
try:
|
||
await _send_email(owner_payload, label="owner_contract_email", request_id=request_id)
|
||
except Exception as exc:
|
||
logger.error("[%s] contract email failed after retries: %s", request_id, exc, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={
|
||
"request_id": request_id,
|
||
"message": "The contract could not be delivered. Please try again shortly.",
|
||
"error_type": type(exc).__name__,
|
||
},
|
||
)
|
||
|
||
await _register_email(str(data.email))
|
||
await _store_client_profile(str(data.email), {
|
||
"fullName": data.fullName,
|
||
"phone": data.phone,
|
||
"address": data.address,
|
||
"dogName": data.dogName,
|
||
"dogBreed": data.dogBreed,
|
||
"dogAge": data.dogAge,
|
||
"contractCompleted": True,
|
||
})
|
||
|
||
await admin_db.record_submission(
|
||
kind="contract",
|
||
email=str(data.email), full_name=data.fullName, phone=data.phone,
|
||
ip=ip, request_id=request_id, payload=data.model_dump(),
|
||
)
|
||
await admin_db.record_event(
|
||
event_type="contract_submitted",
|
||
request_id=request_id, actor_email=str(data.email), ip=ip, status="ok",
|
||
detail={"dog": data.dogName, "service": data.serviceType, "startDate": data.startDate},
|
||
)
|
||
|
||
return {"ok": True, "request_id": request_id}
|