2026-05-02 09:08:31 +12:00
import asyncio
2026-05-18 22:25:43 +12:00
import base64
2026-05-02 11:24:11 +12:00
from collections import deque
2026-05-19 23:36:58 +12:00
from contextlib import asynccontextmanager
2026-05-11 21:02:24 +12:00
import json
2026-05-02 09:08:31 +12:00
import os
import random
2026-05-11 21:02:24 +12:00
import re
import secrets
2026-05-02 09:08:31 +12:00
import time
import uuid
2026-05-19 23:36:58 +12:00
from datetime import datetime , timedelta , timezone
2026-05-02 09:08:31 +12:00
from pathlib import Path
2026-05-18 22:25:43 +12:00
from typing import Any
2026-05-02 08:26:18 +12:00
import resend
from fastapi import FastAPI , HTTPException , Request
from fastapi . middleware . cors import CORSMiddleware
2026-05-19 23:36:58 +12:00
from fastapi . middleware . trustedhost import TrustedHostMiddleware
from fastapi . responses import JSONResponse , Response
from starlette . types import ASGIApp , Receive , Scope , Send
2026-05-02 08:26:18 +12:00
2026-05-18 22:25:43 +12:00
import db as admin_db
2026-05-19 23:36:58 +12:00
from mail_api . config import (
ALLOWED_EMAILS_FILE as _ALLOWED_EMAILS_FILE ,
2026-05-02 19:44:45 +12:00
APP_VERSION ,
2026-05-19 23:36:58 +12:00
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 ,
2026-05-04 23:47:26 +12:00
CLIENT_BCC ,
2026-05-19 23:36:58 +12:00
CLIENT_PROFILES_FILE as _CLIENT_PROFILES_FILE ,
CORS_ALLOWED_ORIGINS ,
CP_ADMIN_EMAILS ,
2026-05-26 08:30:08 +12:00
DEPLOY_SMOKE_SECRET ,
2026-05-19 23:36:58 +12:00
DEV_MODE ,
DRAFTS_FILE as _DRAFTS_FILE ,
EMAIL_SEND_TIMEOUT_SECONDS ,
2026-05-04 20:32:24 +12:00
ENABLE_GENERAL_ENQUIRIES ,
2026-05-02 11:24:11 +12:00
FORM_MAX_SECONDS ,
2026-05-19 23:36:58 +12:00
FORM_MIN_SECONDS ,
FROM_EMAIL ,
2026-05-26 08:30:08 +12:00
LEGACY_SEED_FILE as _LEGACY_SEED_FILE ,
2026-05-19 23:36:58 +12:00
LOGO_URL ,
MAX_REQUEST_BODY_BYTES ,
MAX_SEND_ATTEMPTS ,
OWNER_BCC ,
OWNER_EMAIL ,
2026-05-02 11:24:11 +12:00
RATE_LIMIT_MAX_PER_EMAIL ,
2026-05-19 23:36:58 +12:00
RATE_LIMIT_MAX_PER_IP ,
2026-05-02 11:24:11 +12:00
RATE_LIMIT_MIN_INTERVAL_SECONDS ,
2026-05-19 23:36:58 +12:00
RATE_LIMIT_WINDOW_SECONDS ,
REPLY_TO ,
STARTUP_TEST_RECIPIENT ,
TRUSTED_HOSTS ,
logger ,
)
from mail_api . models import (
BaseSubmission ,
BirthdayAutoSendRequest ,
BirthdayEmailRequest ,
BookingSubmission ,
2026-05-26 08:30:08 +12:00
ClientStatusUpdate ,
2026-05-19 23:36:58 +12:00
ContractSubmission ,
OnboardingSubmission ,
RenderMessageRequest ,
SendMessageRequest ,
WelcomePackEmailRequest ,
2026-05-02 09:08:31 +12:00
)
2026-05-02 08:26:18 +12:00
2026-05-19 23:36:58 +12:00
@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 )
2026-05-02 08:26:18 +12:00
2026-05-11 21:02:24 +12:00
# ── Auth state ───────────────────────────────────────────────────────────────
2026-05-19 23:36:58 +12:00
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 )
2026-05-18 22:25:43 +12:00
def _load_allowed_emails_from_file ( ) - > set [ str ] :
2026-05-11 21:02:24 +12:00
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
2026-05-18 22:25:43 +12:00
def _save_allowed_emails_file ( emails : set [ str ] ) - > None :
2026-05-11 21:02:24 +12:00
try :
2026-05-19 23:36:58 +12:00
_write_pii_json ( _ALLOWED_EMAILS_FILE , { " emails " : sorted ( emails ) } )
2026-05-11 21:02:24 +12:00
except Exception as exc :
2026-05-18 22:25:43 +12:00
logger . warning ( " Could not save allowed_emails file: %s " , exc )
2026-05-11 21:02:24 +12:00
2026-05-18 22:25:43 +12:00
def _load_client_profiles_from_file ( ) - > dict [ str , dict ] :
2026-05-11 21:02:24 +12:00
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 { }
2026-05-18 22:25:43 +12:00
def _save_client_profiles_file ( profiles : dict ) - > None :
2026-05-11 21:02:24 +12:00
try :
2026-05-19 23:36:58 +12:00
_write_pii_json ( _CLIENT_PROFILES_FILE , profiles )
2026-05-11 21:02:24 +12:00
except Exception as exc :
2026-05-18 22:25:43 +12:00
logger . warning ( " Could not save client_profiles file: %s " , exc )
2026-05-11 21:02:24 +12:00
2026-05-18 22:25:43 +12:00
def _load_drafts_from_file ( ) - > dict :
2026-05-11 21:02:24 +12:00
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 { }
2026-05-18 22:25:43 +12:00
def _save_drafts_file ( drafts : dict ) - > None :
2026-05-11 21:02:24 +12:00
try :
2026-05-19 23:36:58 +12:00
_write_pii_json ( _DRAFTS_FILE , drafts )
2026-05-11 21:02:24 +12:00
except Exception as exc :
2026-05-18 22:25:43 +12:00
logger . warning ( " Could not save drafts file: %s " , exc )
2026-05-11 21:02:24 +12:00
2026-05-19 23:36:58 +12:00
async def _save_active_sessions_async ( ) - > None :
""" Persist live sessions to admin_kv so they survive container restarts.
2026-05-18 22:25:43 +12:00
2026-05-19 23:36:58 +12:00
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 )
2026-05-18 22:25:43 +12:00
2026-05-19 23:36:58 +12:00
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 { }
2026-05-11 21:02:24 +12:00
2026-05-18 22:25:43 +12:00
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 )
2026-05-26 08:30:08 +12:00
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 ) ,
)
2026-05-18 22:25:43 +12:00
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 ( ) )
2026-05-11 21:02:24 +12:00
_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
2026-05-18 22:25:43 +12:00
_client_profiles : dict [ str , dict ] = _load_client_profiles_from_file ( )
_drafts : dict [ str , dict ] = _load_drafts_from_file ( ) # email -> {onboarding: {...}, contract: {...}}
2026-05-11 21:02:24 +12:00
_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 ( )
2026-05-18 22:25:43 +12:00
_birthday_auto_task : asyncio . Task | None = None
2026-05-11 21:02:24 +12:00
logger . info ( " Auth: loaded %d allowed email(s) " , len ( _allowed_emails ) )
2026-05-18 22:25:43 +12:00
async def _require_session_email ( request : Request ) - > str :
auth_header = request . headers . get ( " Authorization " , " " )
2026-05-19 23:36:58 +12:00
token = auth_header . removeprefix ( " Bearer " ) . strip ( )
2026-05-18 22:25:43 +12:00
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 )
2026-05-19 23:36:58 +12:00
if email not in CP_ADMIN_EMAILS :
2026-05-18 22:25:43 +12:00
raise HTTPException ( status_code = 403 , detail = " Owner access required. " )
return email
2026-05-11 21:02:24 +12:00
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 )
2026-05-18 22:25:43 +12:00
snapshot = sorted ( _allowed_emails )
2026-05-19 23:36:58 +12:00
await asyncio . to_thread ( _save_allowed_emails_file , set ( _allowed_emails ) )
2026-05-18 22:25:43 +12:00
await _persist_admin_state ( " allowed_emails " , { " emails " : snapshot } )
2026-05-11 21:02:24 +12:00
logger . info ( " Auth: registered new allowed email: %s " , normalized )
2026-05-26 08:30:08 +12:00
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 " }
2026-05-11 21:02:24 +12:00
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 , { } )
2026-05-18 22:25:43 +12:00
merged = {
k : v
for k , v in { * * existing , * * profile } . items ( )
if v is not None and not ( isinstance ( v , str ) and v == " " )
}
2026-05-11 21:02:24 +12:00
if merged != existing :
_client_profiles [ normalized ] = merged
2026-05-18 22:25:43 +12:00
snapshot = dict ( _client_profiles )
2026-05-19 23:36:58 +12:00
await asyncio . to_thread ( _save_client_profiles_file , snapshot )
2026-05-18 22:25:43 +12:00
await _persist_admin_state ( " client_profiles " , snapshot )
2026-05-11 21:02:24 +12:00
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 ( %d s 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 %d s after %d failures " ,
request_id , ip , AUTH_IP_BLOCK_DURATION , len ( failures ) ,
)
2026-05-19 23:36:58 +12:00
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 ) )
2026-05-02 08:26:18 +12:00
app . add_middleware (
CORSMiddleware ,
2026-05-19 23:36:58 +12:00
allow_origins = list ( CORS_ALLOWED_ORIGINS ) ,
2026-05-02 09:08:31 +12:00
allow_methods = [ " POST " , " GET " ] ,
2026-05-19 23:36:58 +12:00
allow_headers = [ " Authorization " , " Content-Type " , " X-Requested-With " ] ,
allow_credentials = False ,
max_age = 600 ,
2026-05-02 08:26:18 +12:00
)
2026-05-02 09:08:31 +12:00
@app.middleware ( " http " )
async def _request_logging_middleware ( request : Request , call_next ) :
request_id = uuid . uuid4 ( ) . hex [ : 8 ]
request . state . request_id = request_id
started = time . monotonic ( )
try :
response = await call_next ( request )
except Exception :
elapsed_ms = ( time . monotonic ( ) - started ) * 1000
logger . exception (
" [ %s ] %s %s crashed after %.0f ms " ,
request_id , request . method , request . url . path , elapsed_ms ,
)
raise
elapsed_ms = ( time . monotonic ( ) - started ) * 1000
logger . info (
" [ %s ] %s %s → %d ( %.0f ms) " ,
request_id , request . method , request . url . path , response . status_code , elapsed_ms ,
)
response . headers [ " X-Request-ID " ] = request_id
return response
2026-05-02 08:26:18 +12:00
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_ip ( request : Request ) - > str :
forwarded = request . headers . get ( " x-forwarded-for " )
if forwarded :
return forwarded . split ( " , " ) [ 0 ] . strip ( )
return request . client . host if request . client else " unknown "
2026-05-26 08:30:08 +12:00
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 )
2026-05-02 11:24:11 +12:00
_submit_attempts_by_ip : dict [ str , deque [ float ] ] = { }
_submit_attempts_by_email : dict [ str , deque [ float ] ] = { }
_submit_rate_limit_lock = asyncio . Lock ( )
def _trimmed ( value : str ) - > str :
return value . strip ( )
def _prune_attempts ( attempts : deque [ float ] , now : float , window_seconds : int ) - > None :
while attempts and now - attempts [ 0 ] > window_seconds :
attempts . popleft ( )
def _seconds_until_allowed ( last_attempt_at : float , now : float , min_interval_seconds : int ) - > int :
retry_after = max ( 1 , int ( min_interval_seconds - ( now - last_attempt_at ) ) )
return retry_after
async def _enforce_submit_rate_limits ( request_id : str , ip : str , email : str ) - > None :
now = time . monotonic ( )
normalized_email = email . strip ( ) . lower ( )
async with _submit_rate_limit_lock :
ip_attempts = _submit_attempts_by_ip . setdefault ( ip , deque ( ) )
email_attempts = _submit_attempts_by_email . setdefault ( normalized_email , deque ( ) )
_prune_attempts ( ip_attempts , now , RATE_LIMIT_WINDOW_SECONDS )
_prune_attempts ( email_attempts , now , RATE_LIMIT_WINDOW_SECONDS )
if ip_attempts and now - ip_attempts [ - 1 ] < RATE_LIMIT_MIN_INTERVAL_SECONDS :
retry_after = _seconds_until_allowed ( ip_attempts [ - 1 ] , now , RATE_LIMIT_MIN_INTERVAL_SECONDS )
logger . warning (
" [ %s ] rate limited: ip= %s submitted again after %.1f s (minimum %s s) " ,
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 %s s " ,
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 %s s " ,
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 )
2026-05-11 21:02:24 +12:00
def _enforce_form_timing ( request_id : str , data : BaseSubmission ) - > None :
2026-05-02 11:24:11 +12:00
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 ( %.2f s < %s s) " ,
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 ( %.0f s > %s s) " ,
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. " ,
)
2026-05-11 21:02:24 +12:00
def _is_honeypot_triggered ( data : BaseSubmission ) - > bool :
2026-05-02 11:24:11 +12:00
return bool ( _trimmed ( data . website ) )
2026-05-04 20:32:24 +12:00
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 ) ]
2026-05-06 15:50:01 +12:00
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 )
2026-05-04 20:32:24 +12:00
if _is_general_enquiry ( data ) :
data . petName = " "
data . location = " "
data . services = [ ]
2026-05-11 21:02:24 +12:00
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 )
2026-05-02 08:26:18 +12:00
def _parse_ua ( ua : str ) - > str :
if not ua :
return " Unknown "
browsers = [ ( " Edg/ " , " Edge " ) , ( " OPR/ " , " Opera " ) , ( " Chrome/ " , " Chrome " ) ,
( " Firefox/ " , " Firefox " ) , ( " Safari/ " , " Safari " ) ]
systems = [ ( " Windows NT 10 " , " Windows 10/11 " ) , ( " Windows NT 6 " , " Windows 8 " ) ,
( " Mac OS X " , " macOS " ) , ( " iPhone " , " iPhone " ) , ( " iPad " , " iPad " ) ,
( " Android " , " Android " ) , ( " Linux " , " Linux " ) ]
browser = next ( ( n for p , n in browsers if p in ua ) , " Unknown browser " )
system = next ( ( n for p , n in systems if p in ua ) , " Unknown OS " )
return f " { browser } on { system } "
def _detail_row ( label : str , value : str ) - > str :
if not value :
return " "
return f """
<tr>
<td style= " padding:8px 0;color:#888;font-size:13px;white-space:nowrap;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top;width:130px; " > { label } </td>
<td style= " padding:8px 0 8px 16px;color:#213021;font-size:14px;font-weight:500;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top; " > { value } </td>
</tr> """
def _meta_row ( label : str , value : str ) - > str :
if not value :
return " "
return f """
<tr>
<td style= " padding:5px 0;color:#aaa;font-size:12px;white-space:nowrap;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top;width:100px; " > { label } </td>
<td style= " padding:5px 0 5px 16px;color:#666;font-size:12px;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top;word-break:break-all; " > { value } </td>
</tr> """
2026-05-06 15:50:01 +12:00
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 )
2026-05-02 08:26:18 +12:00
# ── 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 :
2026-05-04 20:32:24 +12:00
is_general = _is_general_enquiry ( data )
2026-05-02 08:26:18 +12:00
services_text = " , " . join ( data . services ) if data . services else " Not specified "
2026-05-04 20:32:24 +12:00
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 "
2026-05-02 08:26:18 +12:00
return f """ <!DOCTYPE html>
<html lang= " en " >
<head>
<meta charset= " utf-8 " >
<meta name= " viewport " content= " width=device-width,initial-scale=1 " >
<title>We received your enquiry</title>
</head>
<body style= " margin:0;padding:0;background:#f2f2f0; " >
<table width= " 100% " cellpadding= " 0 " cellspacing= " 0 " role= " presentation "
style= " background:#f2f2f0;padding:40px 16px; " >
<tr><td align= " center " >
<table width= " 600 " cellpadding= " 0 " cellspacing= " 0 " role= " presentation "
style= " max-width:600px;width:100%;border-radius:16px;overflow:hidden;
box-shadow:0 4px 24px rgba(0,0,0,0.08); " >
2026-05-04 20:32:24 +12:00
{ _logo_header ( subtitle = logo_subtitle ) }
2026-05-02 08:26:18 +12:00
<!-- Body -->
<tr>
<td style= " background:#ffffff;padding:48px 48px 40px; " >
<h1 style= " margin:0 0 8px;font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:26px;font-weight:700;color:#213021;line-height:1.2; " >
Thanks, { data . fullName . split ( ) [ 0 ] } ! 🐾
</h1>
<p style= " margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:16px;color:#555;line-height:1.65; " >
2026-05-04 20:32:24 +12:00
{ intro_html }
2026-05-02 08:26:18 +12:00
</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 " >
2026-05-04 20:32:24 +12:00
{ " " . join ( enquiry_summary_rows ) }
2026-05-02 08:26:18 +12:00
</table>
</td>
</tr>
</table>
<!-- What ' s next -->
<table width= " 100% " cellpadding= " 0 " cellspacing= " 0 " role= " presentation "
style= " border-left:3px solid #FFD100;margin-bottom:36px; " >
<tr>
<td style= " padding:4px 0 4px 20px; " >
<div style= " font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:13px;font-weight:700;color:#213021;margin-bottom:6px; " >
What happens next?
</div>
<div style= " font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:14px;color:#666;line-height:1.6; " >
2026-05-04 20:32:24 +12:00
{ next_steps_html }
2026-05-02 08:26:18 +12:00
</div>
</td>
</tr>
</table>
<p style= " margin:0;font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:14px;color:#888;line-height:1.6; " >
2026-05-04 16:30:05 +12:00
Questions? Just reply to this email or reach us at 022 642 1011.
2026-05-02 08:26:18 +12:00
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style= " background:#213021;padding:24px 48px;text-align:center; " >
<div style= " font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:12px;color:#5a8a5a;line-height:1.6; " >
GoodWalk · Auckland, New Zealand<br>
<a href= " https://www.goodwalk.co.nz " style= " color:#7aaa7a;text-decoration:none; " >
goodwalk.co.nz
</a>
</div>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html> """
def owner_email ( data : BookingSubmission , ip : str , browser : str ) - > str :
2026-05-04 20:32:24 +12:00
is_general = _is_general_enquiry ( data )
2026-05-02 08:26:18 +12:00
services_text = " , " . join ( data . services ) if data . services else " — "
now = datetime . now ( )
submitted_at = now . strftime ( " %d % b % Y at % I: % M % p " ) . lstrip ( " 0 " )
2026-05-02 19:44:45 +12:00
first_name = data . fullName . split ( ) [ 0 ] if data . fullName . strip ( ) else " them "
2026-05-04 20:32:24 +12:00
email_title = " New GoodWalk Enquiry " if is_general else " New GoodWalk Lead "
2026-05-02 08:26:18 +12:00
2026-05-04 20:32:24 +12:00
message_label = " Message " if is_general else " About the dog "
2026-05-02 08:26:18 +12:00
message_block = f """
<tr>
<td colspan= " 2 " style= " padding:16px 0 0; " >
<div style= " font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
2026-05-04 20:32:24 +12:00
text-transform:uppercase;margin-bottom:8px; " > { message_label } </div>
2026-05-02 08:26:18 +12:00
<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; " >
2026-05-04 20:32:24 +12:00
📩 New enquiry!
2026-05-02 08:26:18 +12:00
</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 " "
2026-05-06 15:50:01 +12:00
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 ) )
2026-05-04 20:32:24 +12:00
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 ) ,
]
)
2026-05-02 08:26:18 +12:00
return f """ <!DOCTYPE html>
<html lang= " en " >
<head>
<meta charset= " utf-8 " >
<meta name= " viewport " content= " width=device-width,initial-scale=1 " >
2026-05-06 15:50:01 +12:00
<meta name= " color-scheme " content= " light only " >
<meta name= " supported-color-schemes " content= " light " >
2026-05-04 20:32:24 +12:00
<title> { email_title } </title>
2026-05-06 15:50:01 +12:00
<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>
2026-05-02 08:26:18 +12:00
</head>
2026-05-06 15:50:01 +12:00
<body class= " gw-owner-body " style= " margin:0;padding:0;background:#f2f2f0;color:#213021; " >
2026-05-02 08:26:18 +12:00
2026-05-06 15:50:01 +12:00
<table width= " 100% " cellpadding= " 0 " cellspacing= " 0 " role= " presentation " bgcolor= " #f2f2f0 "
2026-05-02 08:26:18 +12:00
style= " background:#f2f2f0;padding:40px 16px; " >
<tr><td align= " center " >
2026-05-06 15:50:01 +12:00
<table class= " gw-owner-shell " width= " 600 " cellpadding= " 0 " cellspacing= " 0 " role= " presentation " bgcolor= " #ffffff "
2026-05-02 08:26:18 +12:00
style= " max-width:600px;width:100%;border-radius:16px;overflow:hidden;
2026-05-06 15:50:01 +12:00
box-shadow:0 4px 24px rgba(0,0,0,0.08);background:#ffffff; " >
2026-05-02 08:26:18 +12:00
{ _logo_header ( badge_html = badge ) }
<!-- Body -->
<tr>
2026-05-06 15:50:01 +12:00
<td bgcolor= " #ffffff " style= " background:#ffffff;padding:40px 48px 36px;color:#213021; " >
2026-05-02 08:26:18 +12:00
2026-05-02 19:44:45 +12:00
<!-- Quick contact -->
2026-05-06 15:50:01 +12:00
<table class= " gw-owner-dark-panel " width= " 100% " cellpadding= " 0 " cellspacing= " 0 " role= " presentation " bgcolor= " #213021 "
2026-05-02 19:44:45 +12:00
style= " background:#213021;border-radius:12px;margin-bottom:28px; " >
<tr>
2026-05-06 15:50:01 +12:00
<td bgcolor= " #213021 " style= " padding:22px 24px;background:#213021; " >
2026-05-02 19:44:45 +12:00
<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>
2026-05-06 15:50:01 +12:00
<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>
2026-05-02 19:44:45 +12:00
</div>
<div style= " font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:12px;color:#b7cbb7;line-height:1.6; " >
Tap and hold the address to copy on iPhone, or tap below to open a new email.
</div>
</td>
</tr>
</table>
2026-05-02 08:26:18 +12:00
<!-- Owner details -->
<div style= " font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
text-transform:uppercase;margin-bottom:16px; " >Owner details</div>
<table width= " 100% " cellpadding= " 0 " cellspacing= " 0 " role= " presentation "
style= " background:#f8f7f4;border-radius:12px;margin-bottom:28px; " >
<tr><td style= " padding:24px 28px; " >
<table width= " 100% " cellpadding= " 0 " cellspacing= " 0 " role= " presentation " >
<tr>
<td style= " padding:6px 0;font-size:13px;color:#888;width:80px;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top; " >Name</td>
<td style= " padding:6px 0 6px 16px;font-size:15px;font-weight:600;
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
' Segoe UI ' ,sans-serif;vertical-align:top; " > { data . fullName } </td>
</tr>
<tr>
<td style= " padding:6px 0;font-size:13px;color:#888;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top; " >Email</td>
<td style= " padding:6px 0 6px 16px;font-size:14px;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top; " >
<a href= " mailto: { data . email } " style= " color:#213021;font-weight:500;
text-decoration:none; " > { data . email } </a>
</td>
</tr>
<tr>
<td style= " padding:6px 0;font-size:13px;color:#888;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top; " >Phone</td>
<td style= " padding:6px 0 6px 16px;font-size:14px;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
vertical-align:top; " >
<a href= " tel: { data . phone } " style= " color:#213021;font-weight:500;
text-decoration:none; " > { data . phone } </a>
</td>
</tr>
</table>
</td></tr>
</table>
<!-- Dog & service details -->
<div style= " font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
2026-05-04 20:32:24 +12:00
text-transform:uppercase;margin-bottom:16px; " > { detail_heading } </div>
2026-05-02 08:26:18 +12:00
<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 " >
2026-05-04 20:32:24 +12:00
{ " " . join ( detail_rows ) }
2026-05-02 08:26:18 +12:00
{ message_block }
</table>
</td></tr>
</table>
<!-- CTA buttons -->
<table cellpadding= " 0 " cellspacing= " 0 " role= " presentation " style= " margin-bottom:32px; " >
<tr>
<td style= " padding-right:12px; " >
<a href= " mailto: { data . email } "
style= " display:inline-block;background:#213021;color:#FFD100;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:14px;font-weight:600;text-decoration:none;
border-radius:8px;padding:12px 24px; " >
2026-05-02 19:44:45 +12:00
Email { first_name }
2026-05-02 08:26:18 +12:00
</a>
</td>
<td>
<a href= " tel: { data . phone } "
style= " display:inline-block;background:#f8f7f4;color:#213021;
font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:14px;font-weight:600;text-decoration:none;
border-radius:8px;padding:12px 24px;border:1px solid #e0e0d8; " >
Call { data . phone }
</a>
</td>
</tr>
</table>
<!-- Session info -->
<table width= " 100% " cellpadding= " 0 " cellspacing= " 0 " role= " presentation "
style= " border-top:1px solid #eeeee8;padding-top:20px; " >
<tr><td>
<div style= " font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#ccc;
text-transform:uppercase;margin-bottom:12px; " >Session info</div>
<table width= " 100% " cellpadding= " 0 " cellspacing= " 0 " role= " presentation " >
{ _meta_row ( " IP address " , ip ) }
{ _meta_row ( " Browser " , browser ) }
2026-05-06 15:50:01 +12:00
{ visit_time_row }
{ page_time_row }
{ active_time_row }
{ form_time_row }
{ step_changes_row }
2026-05-02 08:26:18 +12:00
{ referrer_row }
{ page_row }
2026-05-06 15:50:01 +12:00
{ journey_row }
2026-05-02 08:26:18 +12:00
</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; " >
2026-05-04 20:32:24 +12:00
Sent automatically by GoodWalk enquiry form
2026-05-02 08:26:18 +12:00
</div>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html> """
2026-05-11 21:02:24 +12:00
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> """
2026-05-18 22:25:43 +12:00
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 "
2026-05-19 23:36:58 +12:00
f " DTSTAMP: { datetime . now ( timezone . utc ) . strftime ( ' % Y % m %d T % H % M % SZ ' ) } \r \n "
2026-05-18 22:25:43 +12:00
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 " ) ,
}
2026-05-26 08:30:08 +12:00
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> """
2026-05-19 23:36:58 +12:00
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 " ) ,
}
2026-05-02 09:08:31 +12:00
# ── Sending with retries ─────────────────────────────────────────────────────
async def _send_email ( payload : dict , label : str , request_id : str ) - > dict :
2026-05-11 21:02:24 +12:00
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 " }
2026-05-02 09:08:31 +12:00
last_exc : Exception | None = None
for attempt in range ( 1 , MAX_SEND_ATTEMPTS + 1 ) :
started = time . monotonic ( )
try :
2026-05-19 23:36:58 +12:00
result = await asyncio . wait_for (
asyncio . to_thread ( resend . Emails . send , payload ) ,
timeout = EMAIL_SEND_TIMEOUT_SECONDS ,
)
2026-05-02 09:08:31 +12:00
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 , %.0f ms, 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 , %.0f ms): %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 %.2f s " , request_id , label , backoff )
await asyncio . sleep ( backoff )
assert last_exc is not None
raise last_exc
2026-05-11 21:02:24 +12:00
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
2026-05-06 15:50:01 +12:00
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 "
2026-05-11 21:02:24 +12:00
sample = _build_startup_test_submission ( )
2026-05-06 15:50:01 +12:00
payload = {
" from " : FROM_EMAIL ,
" to " : [ STARTUP_TEST_RECIPIENT ] ,
2026-05-11 21:02:24 +12:00
" 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 } ) " ) ,
2026-05-06 15:50:01 +12:00
}
await _send_email ( payload , label = " startup_test_email " , request_id = request_id )
2026-05-02 09:08:31 +12:00
# ── Routes ───────────────────────────────────────────────────────────────────
2026-05-19 23:36:58 +12:00
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 )
2026-05-06 15:50:01 +12:00
async def _startup_mail_check ( ) - > None :
2026-05-18 22:25:43 +12:00
global _birthday_auto_task , _allowed_emails , _client_profiles , _drafts
2026-05-19 23:36:58 +12:00
# 0. Boot-time smoke tests so silent failures surface immediately.
await _startup_smoke_pdf ( )
await _startup_verify_schema ( )
2026-05-18 22:25:43 +12:00
# 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
2026-05-19 23:36:58 +12:00
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 ) ,
)
2026-05-18 22:25:43 +12:00
except Exception :
logger . exception ( " Admin state refresh from postgres failed; using JSON snapshot " )
2026-05-26 08:30:08 +12:00
# 3. Merge any shipped legacy seed (add-only — never clobbers live entries).
await _merge_legacy_seed_if_present ( )
2026-05-06 15:50:01 +12:00
try :
await _send_startup_test_email ( )
except Exception :
logger . exception ( " Startup test email failed " )
2026-05-18 22:25:43 +12:00
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
2026-05-06 15:50:01 +12:00
2026-05-02 09:08:31 +12:00
@app.get ( " /health " )
async def health ( ) - > dict :
return { " status " : " ok " }
2026-05-02 08:26:18 +12:00
2026-05-11 21:02:24 +12:00
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> """
2026-05-18 22:25:43 +12:00
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> """
2026-05-19 23:36:58 +12:00
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> """
2026-05-18 22:25:43 +12:00
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 )
2026-05-11 21:02:24 +12:00
_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 ,
)
2026-05-19 23:36:58 +12:00
await admin_db . record_event (
event_type = " auth_code_requested " ,
request_id = request_id , actor_email = email , ip = ip , status = " ok " ,
)
2026-05-11 21:02:24 +12:00
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 }
2026-05-19 23:36:58 +12:00
await _save_active_sessions_async ( )
2026-05-11 21:02:24 +12:00
logger . info ( " [ %s ] auth: session created for email= %s " , request_id , email )
2026-05-19 23:36:58 +12:00
await admin_db . record_event (
event_type = " auth_login " ,
request_id = request_id , actor_email = email , ip = ip , status = " ok " ,
)
2026-05-11 21:02:24 +12:00
return { " ok " : True , " token " : token , " email " : email }
@app.get ( " /auth/verify " )
async def auth_verify ( request : Request ) :
2026-05-18 22:25:43 +12:00
email = await _require_session_email ( request )
2026-05-11 21:02:24 +12:00
profile = _client_profiles . get ( email , { } )
draft = _drafts . get ( email , { } )
2026-05-19 23:36:58 +12:00
return {
" ok " : True ,
" email " : email ,
" profile " : profile ,
" draft " : draft ,
" cpAdmin " : email in CP_ADMIN_EMAILS ,
" ownerEmail " : OWNER_EMAIL ,
}
2026-05-11 21:02:24 +12:00
@app.post ( " /auth/logout " )
async def auth_logout ( request : Request ) :
auth_header = request . headers . get ( " Authorization " , " " )
token = auth_header . removeprefix ( " Bearer " ) . strip ( )
2026-05-19 23:36:58 +12:00
logged_out_email = None
2026-05-11 21:02:24 +12:00
if token :
async with _auth_lock :
2026-05-19 23:36:58 +12:00
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 " ,
)
2026-05-11 21:02:24 +12:00
return { " ok " : True }
@app.post ( " /auth/save-draft " )
async def auth_save_draft ( request : Request ) :
2026-05-18 22:25:43 +12:00
email = await _require_session_email ( request )
2026-05-11 21:02:24 +12:00
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 )
2026-05-19 23:36:58 +12:00
await asyncio . to_thread ( _save_drafts_file , snapshot )
2026-05-18 22:25:43 +12:00
await _persist_admin_state ( " drafts " , snapshot )
2026-05-11 21:02:24 +12:00
logger . info ( " Draft saved: email= %s form= %s " , email , form )
return { " ok " : True }
2026-05-18 22:25:43 +12:00
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 \n We ' 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 \n We ' 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 \n A 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 \n If 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 \n Reply to this email or hit the button below to take it up. Offer is limited and won ' t be around long. " ,
" default_highlight " : " 20 % o ff 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 }
2026-05-26 08:30:08 +12:00
@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 }
2026-05-18 22:25:43 +12:00
@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 ] )
2026-05-19 23:36:58 +12:00
owner_email = await _require_owner_email ( request )
2026-05-18 22:25:43 +12:00
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 ) )
2026-05-19 23:36:58 +12:00
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 ,
} ,
)
2026-05-18 22:25:43 +12:00
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 " : " " ,
}
2026-05-26 23:30:22 +12:00
# 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 }
2026-05-18 22:25:43 +12:00
2026-05-19 23:36:58 +12:00
@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 }
2026-05-18 22:25:43 +12:00
@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
2026-05-26 08:30:08 +12:00
if not _client_is_reachable ( profile ) :
continue
2026-05-18 22:25:43 +12:00
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
2026-05-26 08:30:08 +12:00
if not _client_is_reachable ( profile ) :
continue
2026-05-18 22:25:43 +12:00
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
2026-05-26 08:30:08 +12:00
lifecycle = profile . get ( " lifecycle " ) if isinstance ( profile . get ( " lifecycle " ) , dict ) else None
2026-05-18 22:25:43 +12:00
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 " ,
2026-05-26 08:30:08 +12:00
" lifecycle " : lifecycle or { " status " : " active " , " reason " : " " , " changedAt " : " " , " changedBy " : " " } ,
2026-05-18 22:25:43 +12:00
" 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
2026-05-26 08:30:08 +12:00
if not _client_is_reachable ( profile ) :
continue
2026-05-18 22:25:43 +12:00
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 ] )
2026-05-19 23:36:58 +12:00
owner_email = await _require_owner_email ( request )
2026-05-18 22:25:43 +12:00
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 )
2026-05-19 23:36:58 +12:00
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 ) ,
} ,
)
2026-05-18 22:25:43 +12:00
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 ] )
2026-05-19 23:36:58 +12:00
owner_email = await _require_owner_email ( request )
2026-05-18 22:25:43 +12:00
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__ ,
} ,
)
2026-05-19 23:36:58 +12:00
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 ) } ,
)
2026-05-18 22:25:43 +12:00
return { " ok " : True , " sentAt " : datetime . now ( ) . isoformat ( timespec = " seconds " ) , " preview " : bool ( data . preview ) }
2026-05-26 08:30:08 +12:00
@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 }
2026-05-18 22:25:43 +12:00
@app.post ( " /owner/birthday-auto-send " )
async def owner_birthday_auto_send ( data : BirthdayAutoSendRequest , request : Request ) :
2026-05-19 23:36:58 +12:00
owner_email = await _require_owner_email ( request )
2026-05-18 22:25:43 +12:00
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 ,
} )
2026-05-19 23:36:58 +12:00
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 ) } ,
)
2026-05-18 22:25:43 +12:00
return { " ok " : True , " enabled " : data . enabled }
2026-05-02 08:26:18 +12:00
@app.post ( " /submit " )
async def submit_booking ( data : BookingSubmission , request : Request ) :
2026-05-02 09:08:31 +12:00
request_id = getattr ( request . state , " request_id " , uuid . uuid4 ( ) . hex [ : 8 ] )
2026-05-02 08:26:18 +12:00
ip = _get_ip ( request )
browser = _parse_ua ( request . headers . get ( " user-agent " , " " ) )
2026-05-26 08:30:08 +12:00
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 }
2026-05-02 11:24:11 +12:00
await _enforce_submit_rate_limits ( request_id , ip , str ( data . email ) )
_enforce_form_timing ( request_id , data )
if _is_honeypot_triggered ( data ) :
logger . warning (
" [ %s ] honeypot triggered for ip= %s email= %s page= %r " ,
request_id ,
ip ,
data . email ,
data . page ,
)
2026-05-19 23:36:58 +12:00
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 } ,
)
2026-05-02 11:24:11 +12:00
return {
" ok " : True ,
" request_id " : request_id ,
" ignored " : True ,
}
2026-05-04 20:32:24 +12:00
_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 ,
)
2026-05-19 23:36:58 +12:00
# PII intentionally NOT logged here — payload contains submitter contact details.
logger . debug ( " [ %s ] booking payload keys= %s " , request_id , sorted ( data . model_dump ( ) . keys ( ) ) )
2026-05-04 20:32:24 +12:00
2026-05-02 09:08:31 +12:00
failures : list [ dict ] = [ ]
2026-05-02 08:26:18 +12:00
2026-05-04 23:47:26 +12:00
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 ]
2026-05-02 08:26:18 +12:00
try :
2026-05-02 09:08:31 +12:00
await _send_email (
2026-05-04 23:47:26 +12:00
client_payload ,
2026-05-02 09:08:31 +12:00
label = " client_email " ,
request_id = request_id ,
)
2026-05-02 08:26:18 +12:00
except Exception as exc :
2026-05-02 09:08:31 +12:00
failures . append ( {
" label " : " client_email " ,
" error_type " : type ( exc ) . __name__ ,
" error " : str ( exc ) ,
" status " : getattr ( exc , " status_code " , None ) or getattr ( exc , " code " , None ) ,
} )
2026-05-02 08:26:18 +12:00
2026-05-04 23:47:26 +12:00
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 ]
2026-05-02 08:26:18 +12:00
try :
2026-05-02 09:08:31 +12:00
await _send_email (
2026-05-04 23:47:26 +12:00
owner_payload ,
2026-05-02 09:08:31 +12:00
label = " owner_email " ,
request_id = request_id ,
)
2026-05-02 08:26:18 +12:00
except Exception as exc :
2026-05-02 09:08:31 +12:00
failures . append ( {
" label " : " owner_email " ,
" error_type " : type ( exc ) . __name__ ,
" error " : str ( exc ) ,
" status " : getattr ( exc , " status_code " , None ) or getattr ( exc , " code " , None ) ,
} )
2026-05-02 08:26:18 +12:00
2026-05-02 09:08:31 +12:00
if len ( failures ) == 2 :
logger . error ( " [ %s ] both emails failed after retries: %s " , request_id , failures )
raise HTTPException (
status_code = 502 ,
detail = {
" request_id " : request_id ,
" message " : " Both confirmation and notification emails failed to send. Please try again shortly. " ,
" failures " : failures ,
} ,
)
if failures :
logger . warning ( " [ %s ] partial failure: %s " , request_id , failures )
2026-05-11 21:02:24 +12:00
await _register_email ( str ( data . email ) )
2026-05-18 22:25:43 +12:00
enquiry_at = datetime . now ( ) . isoformat ( timespec = " seconds " )
2026-05-11 21:02:24 +12:00
await _store_client_profile ( str ( data . email ) , {
" fullName " : data . fullName ,
" phone " : data . phone ,
" dogName " : data . petName ,
2026-05-18 22:25:43 +12:00
" 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 ,
} ,
2026-05-11 21:02:24 +12:00
} )
2026-05-19 23:36:58 +12:00
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 ] ,
} ,
)
2026-05-02 09:08:31 +12:00
return {
" ok " : True ,
" request_id " : request_id ,
" partial_failures " : [ f [ " label " ] for f in failures ] ,
}
2026-05-11 21:02:24 +12:00
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 " , " " ) )
2026-05-26 08:30:08 +12:00
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 }
2026-05-11 21:02:24 +12:00
await _enforce_submit_rate_limits ( request_id , ip , str ( data . email ) )
_enforce_form_timing ( request_id , data )
if _is_honeypot_triggered ( data ) :
logger . warning (
" [ %s ] onboarding honeypot triggered for ip= %s email= %s page= %r " ,
request_id ,
ip ,
data . email ,
data . page ,
)
2026-05-19 23:36:58 +12:00
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 } ,
)
2026-05-11 21:02:24 +12:00
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 ,
)
2026-05-19 23:36:58 +12:00
# 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 ( ) ) )
2026-05-11 21:02:24 +12:00
2026-05-19 23:36:58 +12:00
owner_html = owner_onboarding_email ( data , ip , browser )
2026-05-11 21:02:24 +12:00
owner_payload = {
" from " : FROM_EMAIL ,
" to " : [ OWNER_EMAIL ] ,
" reply_to " : data . email ,
" subject " : f " New GoodWalk onboarding — { data . fullName } ( { data . dogName } ) " ,
2026-05-19 23:36:58 +12:00
" html " : owner_html ,
2026-05-11 21:02:24 +12:00
}
2026-05-19 23:36:58 +12:00
attachments : list [ dict ] = [ ]
2026-05-18 22:25:43 +12:00
birthday_attachment = _birthday_ics_attachment ( data . dogName , data . dogAge , data . fullName , request_id )
if birthday_attachment :
2026-05-19 23:36:58 +12:00
attachments . append ( birthday_attachment )
if ONBOARDING_PDF_ATTACHMENT_ENABLED :
2026-05-26 08:30:08 +12:00
pdf_html = owner_onboarding_pdf_html ( data )
pdf_attachment = await _signed_form_pdf_attachment ( pdf_html , data . fullName , " onboarding " , request_id )
2026-05-19 23:36:58 +12:00
if pdf_attachment :
attachments . append ( pdf_attachment )
if attachments :
owner_payload [ " attachments " ] = attachments
2026-05-11 21:02:24 +12:00
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 ,
2026-05-18 22:25:43 +12:00
" onboardingSubmittedAt " : datetime . now ( ) . isoformat ( timespec = " seconds " ) ,
" onboardingSubmission " : data . submissionSnapshot ,
2026-05-11 21:02:24 +12:00
} )
2026-05-19 23:36:58 +12:00
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 } ,
)
2026-05-11 21:02:24 +12:00
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 " , " " ) )
2026-05-26 08:30:08 +12:00
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 }
2026-05-11 21:02:24 +12:00
await _enforce_submit_rate_limits ( request_id , ip , str ( data . email ) )
_enforce_form_timing ( request_id , data )
if _is_honeypot_triggered ( data ) :
logger . warning (
" [ %s ] contract honeypot triggered for ip= %s email= %s page= %r " ,
request_id , ip , data . email , data . page ,
)
2026-05-19 23:36:58 +12:00
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 } ,
)
2026-05-11 21:02:24 +12:00
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 ,
)
2026-05-19 23:36:58 +12:00
owner_html = owner_contract_email ( data , ip , browser )
2026-05-11 21:02:24 +12:00
owner_payload = {
" from " : FROM_EMAIL ,
" to " : [ OWNER_EMAIL ] ,
" reply_to " : data . email ,
" subject " : f " New GoodWalk contract — { data . fullName } ( { data . dogName } , { data . serviceType } ) " ,
2026-05-19 23:36:58 +12:00
" html " : owner_html ,
2026-05-11 21:02:24 +12:00
}
2026-05-19 23:36:58 +12:00
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 ]
2026-05-11 21:02:24 +12:00
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 ,
} )
2026-05-19 23:36:58 +12:00
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 } ,
)
2026-05-11 21:02:24 +12:00
return { " ok " : True , " request_id " : request_id }