2026-05-02 09:08:31 +12:00
import asyncio
2026-05-02 11:24:11 +12:00
from collections import deque
2026-05-11 21:02:24 +12:00
import json
2026-05-02 08:26:18 +12:00
import logging
2026-05-02 09:08:31 +12:00
import logging . handlers
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 sys
import time
import uuid
2026-05-02 08:26:18 +12:00
from datetime import datetime
2026-05-02 09:08:31 +12:00
from pathlib import Path
2026-05-02 08:26:18 +12:00
import resend
from fastapi import FastAPI , HTTPException , Request
from fastapi . middleware . cors import CORSMiddleware
from pydantic import BaseModel , EmailStr
2026-05-02 09:08:31 +12:00
# ── Logging ──────────────────────────────────────────────────────────────────
def _setup_logging ( ) - > logging . Logger :
log_dir = Path ( os . environ . get ( " LOG_DIR " , " logs " ) )
log_dir . mkdir ( parents = True , exist_ok = True )
log_file = log_dir / " mail-api.log "
fmt = logging . Formatter (
" %(asctime)s %(levelname)-8s %(name)s : %(message)s " ,
2026-05-02 19:44:45 +12:00
datefmt = " %d / % m/ % Y % H: % M: % S % Z " ,
2026-05-02 09:08:31 +12:00
)
root = logging . getLogger ( )
root . setLevel ( logging . DEBUG )
for handler in list ( root . handlers ) :
root . removeHandler ( handler )
console = logging . StreamHandler ( sys . stdout )
console . setLevel ( logging . INFO )
console . setFormatter ( fmt )
root . addHandler ( console )
rotating = logging . handlers . RotatingFileHandler (
log_file , maxBytes = 2_000_000 , backupCount = 5 , encoding = " utf-8 "
)
rotating . setLevel ( logging . DEBUG )
rotating . setFormatter ( fmt )
root . addHandler ( rotating )
log = logging . getLogger ( " mail-api " )
log . info ( " Logging initialised → console=INFO, file= %s (DEBUG, rotating) " , log_file )
return log
logger = _setup_logging ( )
# ── Configuration ────────────────────────────────────────────────────────────
2026-05-11 21:02:24 +12:00
DEV_MODE = os . environ . get ( " DEV_MODE " , " " ) . strip ( ) . lower ( ) in { " 1 " , " true " , " yes " }
2026-05-02 09:08:31 +12:00
REQUIRED_ENV = {
" RESEND_API_KEY " : " API key from https://resend.com/api-keys " ,
" OWNER_EMAIL " : " Email address that receives new lead notifications " ,
}
def _load_config ( ) - > dict :
2026-05-11 21:02:24 +12:00
if DEV_MODE :
return {
" resend_api_key " : os . environ . get ( " RESEND_API_KEY " , " dev " ) ,
" owner_email " : os . environ . get ( " OWNER_EMAIL " , " dev@localhost " ) ,
" from_email " : os . environ . get ( " FROM_EMAIL " , " GoodWalk <bookings@goodwalk.co.nz> " ) ,
" reply_to " : os . environ . get ( " REPLY_TO " , " aless@goodwalk.co.nz " ) ,
" owner_bcc " : " " ,
" client_bcc " : " " ,
" enable_general_enquiries " : False ,
" max_attempts " : 3 ,
" form_min_seconds " : 1 ,
" form_max_seconds " : 7200 ,
" rate_limit_window_seconds " : 900 ,
" rate_limit_max_per_ip " : 50 ,
" rate_limit_max_per_email " : 50 ,
" rate_limit_min_interval_seconds " : 1 ,
}
2026-05-02 09:08:31 +12:00
missing = [ ( name , hint ) for name , hint in REQUIRED_ENV . items ( ) if not os . environ . get ( name ) ]
if missing :
lines = [
" " ,
" Mail API cannot start — required environment variables are not set: " ,
" " ,
]
for name , hint in missing :
lines . append ( f " • { name } ( { hint } ) " )
lines + = [
" " ,
" Set them in your shell and try again. For example, in PowerShell: " ,
" " ,
]
for name , _ in missing :
lines . append ( f ' $env: { name } = " ... " ' )
lines . append ( " " )
message = " \n " . join ( lines )
logger . critical ( " Startup aborted: missing env vars: %s " , [ n for n , _ in missing ] )
print ( message , file = sys . stderr )
sys . exit ( 1 )
return {
" resend_api_key " : os . environ [ " RESEND_API_KEY " ] ,
" owner_email " : os . environ [ " OWNER_EMAIL " ] ,
" from_email " : os . environ . get ( " FROM_EMAIL " , " GoodWalk <bookings@goodwalk.co.nz> " ) ,
" reply_to " : os . environ . get ( " REPLY_TO " , " aless@goodwalk.co.nz " ) ,
2026-05-04 23:47:26 +12:00
" owner_bcc " : os . environ . get ( " OWNER_BCC " , " example@example.com " ) . strip ( ) ,
" client_bcc " : os . environ . get ( " CLIENT_BCC " , " " ) . strip ( ) ,
2026-05-04 20:32:24 +12:00
" enable_general_enquiries " : os . environ . get ( " ENABLE_GENERAL_ENQUIRIES " , " false " ) . strip ( ) . lower ( ) in { " 1 " , " true " , " yes " , " on " , " enabled " } ,
2026-05-02 09:08:31 +12:00
" max_attempts " : max ( 1 , int ( os . environ . get ( " MAIL_MAX_ATTEMPTS " , " 3 " ) ) ) ,
2026-05-02 11:24:11 +12:00
" form_min_seconds " : max ( 1 , int ( os . environ . get ( " FORM_MIN_SECONDS " , " 4 " ) ) ) ,
" form_max_seconds " : max ( 60 , int ( os . environ . get ( " FORM_MAX_SECONDS " , " 7200 " ) ) ) ,
" rate_limit_window_seconds " : max ( 60 , int ( os . environ . get ( " RATE_LIMIT_WINDOW_SECONDS " , " 900 " ) ) ) ,
" rate_limit_max_per_ip " : max ( 1 , int ( os . environ . get ( " RATE_LIMIT_MAX_PER_IP " , " 5 " ) ) ) ,
" rate_limit_max_per_email " : max ( 1 , int ( os . environ . get ( " RATE_LIMIT_MAX_PER_EMAIL " , " 3 " ) ) ) ,
" rate_limit_min_interval_seconds " : max ( 1 , int ( os . environ . get ( " RATE_LIMIT_MIN_INTERVAL_SECONDS " , " 20 " ) ) ) ,
2026-05-02 09:08:31 +12:00
}
_config = _load_config ( )
2026-05-02 19:44:45 +12:00
APP_VERSION = os . environ . get ( " APP_VERSION " , " unknown " )
2026-05-11 21:02:24 +12:00
AUTH_CODE_TTL_SECONDS = max ( 60 , int ( os . environ . get ( " AUTH_CODE_TTL_SECONDS " , " 600 " ) ) )
AUTH_SESSION_TTL_SECONDS = max ( 3600 , int ( os . environ . get ( " AUTH_SESSION_TTL_SECONDS " , str ( 7 * 24 * 3600 ) ) ) )
AUTH_CODE_MAX_ATTEMPTS = 5
AUTH_CODE_REQUESTS_PER_HOUR = 5
AUTH_IP_MAX_FAILURES = max ( 3 , int ( os . environ . get ( " AUTH_IP_MAX_FAILURES " , " 10 " ) ) )
AUTH_IP_FAILURE_WINDOW = max ( 60 , int ( os . environ . get ( " AUTH_IP_FAILURE_WINDOW " , " 600 " ) ) )
AUTH_IP_BLOCK_DURATION = max ( 60 , int ( os . environ . get ( " AUTH_IP_BLOCK_DURATION " , " 3600 " ) ) )
_ALLOWED_EMAILS_FILE = Path ( os . environ . get ( " DATA_DIR " , " data " ) ) / " allowed_emails.json "
_CLIENT_PROFILES_FILE = Path ( os . environ . get ( " DATA_DIR " , " data " ) ) / " client_profiles.json "
_DRAFTS_FILE = Path ( os . environ . get ( " DATA_DIR " , " data " ) ) / " drafts.json "
2026-05-02 09:08:31 +12:00
resend . api_key = _config [ " resend_api_key " ]
OWNER_EMAIL = _config [ " owner_email " ]
2026-05-04 23:47:26 +12:00
OWNER_BCC = _config [ " owner_bcc " ]
CLIENT_BCC = _config [ " client_bcc " ]
2026-05-02 09:08:31 +12:00
FROM_EMAIL = _config [ " from_email " ]
REPLY_TO = _config [ " reply_to " ]
2026-05-04 20:32:24 +12:00
ENABLE_GENERAL_ENQUIRIES = _config [ " enable_general_enquiries " ]
2026-05-02 09:08:31 +12:00
MAX_SEND_ATTEMPTS = _config [ " max_attempts " ]
2026-05-02 11:24:11 +12:00
FORM_MIN_SECONDS = _config [ " form_min_seconds " ]
FORM_MAX_SECONDS = _config [ " form_max_seconds " ]
RATE_LIMIT_WINDOW_SECONDS = _config [ " rate_limit_window_seconds " ]
RATE_LIMIT_MAX_PER_IP = _config [ " rate_limit_max_per_ip " ]
RATE_LIMIT_MAX_PER_EMAIL = _config [ " rate_limit_max_per_email " ]
RATE_LIMIT_MIN_INTERVAL_SECONDS = _config [ " rate_limit_min_interval_seconds " ]
2026-05-02 09:08:31 +12:00
2026-05-02 12:39:55 +12:00
LOGO_URL = " https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png "
2026-05-02 09:08:31 +12:00
logger . info (
2026-05-04 23:47:26 +12:00
" Mail API config: version= %r timezone= %r from= %r reply_to= %r owner= %r owner_bcc= %r client_bcc= %r general_enquiries= %r max_attempts= %d form_min= %s s form_max= %s s rate_window= %s s per_ip= %d per_email= %d min_interval= %s s " ,
2026-05-02 19:44:45 +12:00
APP_VERSION ,
os . environ . get ( " TZ " , " system-default " ) ,
2026-05-02 11:24:11 +12:00
FROM_EMAIL ,
REPLY_TO ,
OWNER_EMAIL ,
2026-05-04 23:47:26 +12:00
OWNER_BCC ,
CLIENT_BCC ,
2026-05-04 20:32:24 +12:00
ENABLE_GENERAL_ENQUIRIES ,
2026-05-02 11:24:11 +12:00
MAX_SEND_ATTEMPTS ,
FORM_MIN_SECONDS ,
FORM_MAX_SECONDS ,
RATE_LIMIT_WINDOW_SECONDS ,
RATE_LIMIT_MAX_PER_IP ,
RATE_LIMIT_MAX_PER_EMAIL ,
RATE_LIMIT_MIN_INTERVAL_SECONDS ,
2026-05-02 09:08:31 +12:00
)
2026-05-02 08:26:18 +12:00
2026-05-02 09:08:31 +12:00
app = FastAPI ( title = " GoodWalk Mail API " )
2026-05-06 15:50:01 +12:00
STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC . lower ( ) != " example@example.com " else " "
2026-05-02 08:26:18 +12:00
2026-05-11 21:02:24 +12:00
# ── Auth state ───────────────────────────────────────────────────────────────
def _load_allowed_emails ( ) - > set [ str ] :
seed = { e . strip ( ) . lower ( ) for e in os . environ . get ( " ALLOWED_EMAILS " , " " ) . split ( " , " ) if e . strip ( ) }
try :
if _ALLOWED_EMAILS_FILE . exists ( ) :
data = json . loads ( _ALLOWED_EMAILS_FILE . read_text ( encoding = " utf-8 " ) )
seed . update ( e . lower ( ) for e in data . get ( " emails " , [ ] ) if isinstance ( e , str ) )
except Exception as exc :
logger . warning ( " Could not load allowed_emails file: %s " , exc )
return seed
def _save_allowed_emails_sync ( emails : set [ str ] ) - > None :
try :
_ALLOWED_EMAILS_FILE . parent . mkdir ( parents = True , exist_ok = True )
_ALLOWED_EMAILS_FILE . write_text (
json . dumps ( { " emails " : sorted ( emails ) } , indent = 2 ) , encoding = " utf-8 "
)
except Exception as exc :
logger . warning ( " Could not save allowed_emails: %s " , exc )
def _load_client_profiles ( ) - > dict [ str , dict ] :
try :
if _CLIENT_PROFILES_FILE . exists ( ) :
return json . loads ( _CLIENT_PROFILES_FILE . read_text ( encoding = " utf-8 " ) )
except Exception as exc :
logger . warning ( " Could not load client_profiles file: %s " , exc )
return { }
def _save_client_profiles_sync ( profiles : dict ) - > None :
try :
_CLIENT_PROFILES_FILE . parent . mkdir ( parents = True , exist_ok = True )
_CLIENT_PROFILES_FILE . write_text ( json . dumps ( profiles , indent = 2 ) , encoding = " utf-8 " )
except Exception as exc :
logger . warning ( " Could not save client_profiles: %s " , exc )
def _load_drafts ( ) - > dict :
try :
if _DRAFTS_FILE . exists ( ) :
return json . loads ( _DRAFTS_FILE . read_text ( encoding = " utf-8 " ) )
except Exception as exc :
logger . warning ( " Could not load drafts file: %s " , exc )
return { }
def _save_drafts_sync ( drafts : dict ) - > None :
try :
_DRAFTS_FILE . parent . mkdir ( parents = True , exist_ok = True )
_DRAFTS_FILE . write_text ( json . dumps ( drafts , indent = 2 ) , encoding = " utf-8 " )
except Exception as exc :
logger . warning ( " Could not save drafts: %s " , exc )
_allowed_emails : set [ str ] = _load_allowed_emails ( )
_pending_codes : dict [ str , dict ] = { } # email -> {code, expires_at, attempts}
_active_sessions : dict [ str , dict ] = { } # token -> {email, expires_at}
_code_requests : dict [ str , deque ] = { } # email -> deque of monotonic timestamps
_client_profiles : dict [ str , dict ] = _load_client_profiles ( )
_drafts : dict [ str , dict ] = _load_drafts ( ) # email -> {onboarding: {...}, contract: {...}}
_auth_failures_by_ip : dict [ str , deque ] = { } # ip -> deque of failure timestamps
_blocked_ips : dict [ str , float ] = { } # ip -> unblock_at (monotonic)
_auth_lock = asyncio . Lock ( )
logger . info ( " Auth: loaded %d allowed email(s) " , len ( _allowed_emails ) )
async def _register_email ( email : str ) - > None :
normalized = email . strip ( ) . lower ( )
if not normalized :
return
async with _auth_lock :
if normalized not in _allowed_emails :
_allowed_emails . add ( normalized )
await asyncio . to_thread ( _save_allowed_emails_sync , set ( _allowed_emails ) )
logger . info ( " Auth: registered new allowed email: %s " , normalized )
async def _store_client_profile ( email : str , profile : dict ) - > None :
normalized = email . strip ( ) . lower ( )
if not normalized :
return
async with _auth_lock :
existing = _client_profiles . get ( normalized , { } )
merged = { k : v for k , v in { * * existing , * * profile } . items ( ) if v }
if merged != existing :
_client_profiles [ normalized ] = merged
await asyncio . to_thread ( _save_client_profiles_sync , dict ( _client_profiles ) )
def _check_ip_blocked ( ip : str , request_id : str ) - > None :
now = time . monotonic ( )
unblock_at = _blocked_ips . get ( ip )
if unblock_at is not None :
if now < unblock_at :
remaining = int ( unblock_at - now )
logger . warning ( " [ %s ] auth: blocked ip= %s ( %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-02 08:26:18 +12:00
app . add_middleware (
CORSMiddleware ,
allow_origins = [ " * " ] ,
2026-05-02 09:08:31 +12:00
allow_methods = [ " POST " , " GET " ] ,
2026-05-02 08:26:18 +12:00
allow_headers = [ " * " ] ,
)
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-11 21:02:24 +12:00
class BaseSubmission ( BaseModel ) :
2026-05-02 08:26:18 +12:00
fullName : str
email : EmailStr
phone : str
2026-05-02 11:24:11 +12:00
website : str = " "
formStartedAt : int | None = None
2026-05-06 15:50:01 +12:00
visitStartedAt : int | None = None
pageEnteredAt : int | None = None
firstInteractionAt : int | None = None
sendClickedAt : int | None = None
2026-05-02 08:26:18 +12:00
referrer : str = " "
page : str = " "
2026-05-11 21:02:24 +12:00
class BookingSubmission ( BaseSubmission ) :
enquiryType : str = " booking "
petName : str = " "
location : str = " "
message : str = " "
services : list [ str ] = [ ]
stepChanges : int = 0
journey : list [ str ] = [ ]
class OnboardingSubmission ( BaseSubmission ) :
address : str
dogName : str
dogBreed : str
dogAge : str = " "
servicesNeeded : list [ str ] = [ ]
temperament : str = " "
medicalNotes : str = " "
accessInstructions : str = " "
vetName : str
vetPhone : str
emergencyContactName : str
emergencyContactPhone : str
councilRegistrationConfirmed : bool = False
vaccinationsConfirmed : bool = False
emergencyVetConsent : bool = False
termsAccepted : bool = False
signatureDataUrl : str
class ContractSubmission ( BaseSubmission ) :
address : str
dogName : str
dogBreed : str
dogAge : str = " "
serviceType : str
startDate : str
walkFrequency : str = " "
additionalNotes : str = " "
agreeServiceTerms : bool = False
agreeCancellation : bool = False
agreePayment : bool = False
agreeEmergency : bool = False
agreeLiability : bool = False
agreeAccuracy : bool = False
signatureDataUrl : str
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-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-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 :
result = await asyncio . to_thread ( resend . Emails . send , payload )
elapsed_ms = ( time . monotonic ( ) - started ) * 1000
email_id = result . get ( " id " ) if isinstance ( result , dict ) else None
logger . info (
" [ %s ] %s sent to %s (attempt %d / %d , %.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-06 15:50:01 +12:00
@app.on_event ( " startup " )
async def _startup_mail_check ( ) - > None :
try :
await _send_startup_test_email ( )
except Exception :
logger . exception ( " Startup test email failed " )
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> """
_EMAIL_RE = re . compile ( r ' ^[^ \ s@]+@[^ \ s@]+ \ .[^ \ s@]+$ ' )
@app.post ( " /auth/request-code " )
async def auth_request_code ( request : Request ) :
request_id = getattr ( request . state , " request_id " , uuid . uuid4 ( ) . hex [ : 8 ] )
ip = _get_ip ( request )
body = await request . json ( )
email = str ( body . get ( " email " , " " ) ) . strip ( ) . lower ( )
async with _auth_lock :
_check_ip_blocked ( ip , request_id )
if not email or not _EMAIL_RE . match ( email ) :
raise HTTPException ( status_code = 400 , detail = " Please enter a valid email address. " )
if email not in _allowed_emails :
logger . info ( " [ %s ] auth: unknown email= %s ip= %s " , request_id , email , ip )
async with _auth_lock :
_record_auth_failure ( ip , request_id , " unknown_email " )
raise HTTPException (
status_code = 403 ,
detail = " We don’ t have your email on file. Please use the address you used when enquiring with Goodwalk, or contact us at info@goodwalk.co.nz. " ,
)
now = time . monotonic ( )
async with _auth_lock :
requests = _code_requests . setdefault ( email , deque ( ) )
while requests and now - requests [ 0 ] > 3600 :
requests . popleft ( )
if len ( requests ) > = AUTH_CODE_REQUESTS_PER_HOUR :
raise HTTPException ( status_code = 429 , detail = " Too many code requests. Please wait before trying again. " )
requests . append ( now )
code = str ( secrets . randbelow ( 900000 ) + 100000 )
_pending_codes [ email ] = { " code " : code , " expires_at " : time . time ( ) + AUTH_CODE_TTL_SECONDS , " attempts " : 0 }
logger . info ( " [ %s ] auth: code issued for email= %s " , request_id , email )
if DEV_MODE :
logger . warning ( " [DEV] auth code for %s : %s " , email , code )
else :
await _send_email (
{ " from " : FROM_EMAIL , " to " : [ email ] , " subject " : " Your Goodwalk login code " , " html " : _auth_code_email ( email , code ) } ,
label = " auth_code_email " ,
request_id = request_id ,
)
return { " ok " : True }
@app.post ( " /auth/verify-code " )
async def auth_verify_code ( request : Request ) :
request_id = getattr ( request . state , " request_id " , uuid . uuid4 ( ) . hex [ : 8 ] )
ip = _get_ip ( request )
body = await request . json ( )
email = str ( body . get ( " email " , " " ) ) . strip ( ) . lower ( )
code = str ( body . get ( " code " , " " ) ) . strip ( )
async with _auth_lock :
_check_ip_blocked ( ip , request_id )
pending = _pending_codes . get ( email )
if not pending :
_record_auth_failure ( ip , request_id , " no_pending_code " )
raise HTTPException ( status_code = 400 , detail = " No code found for this email. Please request a new one. " )
if time . time ( ) > pending [ " expires_at " ] :
_pending_codes . pop ( email , None )
_record_auth_failure ( ip , request_id , " expired_code " )
raise HTTPException ( status_code = 400 , detail = " Your code has expired. Please request a new one. " )
pending [ " attempts " ] + = 1
if pending [ " attempts " ] > AUTH_CODE_MAX_ATTEMPTS :
_pending_codes . pop ( email , None )
_record_auth_failure ( ip , request_id , " max_attempts_exceeded " )
raise HTTPException ( status_code = 400 , detail = " Too many incorrect attempts. Please request a new code. " )
if pending [ " code " ] != code :
remaining = max ( 0 , AUTH_CODE_MAX_ATTEMPTS - pending [ " attempts " ] )
_record_auth_failure ( ip , request_id , " wrong_code " )
raise HTTPException ( status_code = 400 , detail = f " Incorrect code. { remaining } attempt { ' s ' if remaining != 1 else ' ' } remaining. " )
_pending_codes . pop ( email , None )
token = secrets . token_urlsafe ( 32 )
_active_sessions [ token ] = { " email " : email , " expires_at " : time . time ( ) + AUTH_SESSION_TTL_SECONDS }
logger . info ( " [ %s ] auth: session created for email= %s " , request_id , email )
return { " ok " : True , " token " : token , " email " : email }
@app.get ( " /auth/verify " )
async def auth_verify ( request : Request ) :
auth_header = request . headers . get ( " Authorization " , " " )
token = auth_header . removeprefix ( " Bearer " ) . strip ( )
if not token :
raise HTTPException ( status_code = 401 , detail = " No token provided. " )
async with _auth_lock :
session = _active_sessions . get ( token )
if not session :
raise HTTPException ( status_code = 401 , detail = " Invalid session. " )
if time . time ( ) > session [ " expires_at " ] :
_active_sessions . pop ( token , None )
raise HTTPException ( status_code = 401 , detail = " Session expired. Please sign in again. " )
email = session [ " email " ]
profile = _client_profiles . get ( email , { } )
draft = _drafts . get ( email , { } )
return { " ok " : True , " email " : email , " profile " : profile , " draft " : draft }
@app.post ( " /auth/logout " )
async def auth_logout ( request : Request ) :
auth_header = request . headers . get ( " Authorization " , " " )
token = auth_header . removeprefix ( " Bearer " ) . strip ( )
if token :
async with _auth_lock :
_active_sessions . pop ( token , None )
return { " ok " : True }
@app.post ( " /auth/save-draft " )
async def auth_save_draft ( request : Request ) :
auth_header = request . headers . get ( " Authorization " , " " )
token = auth_header . removeprefix ( " Bearer " ) . strip ( )
if not token :
raise HTTPException ( status_code = 401 , detail = " No token provided. " )
async with _auth_lock :
session = _active_sessions . get ( token )
if not session or time . time ( ) > session [ " expires_at " ] :
raise HTTPException ( status_code = 401 , detail = " Invalid or expired session. " )
email = session [ " email " ]
body = await request . json ( )
form = str ( body . get ( " form " , " " ) ) . strip ( )
data = body . get ( " data " , { } )
if form not in ( " onboarding " , " contract " ) :
raise HTTPException ( status_code = 400 , detail = " form must be ' onboarding ' or ' contract ' . " )
if not isinstance ( data , dict ) :
raise HTTPException ( status_code = 400 , detail = " data must be an object. " )
async with _auth_lock :
user_drafts = _drafts . setdefault ( email , { } )
user_drafts [ form ] = data
snapshot = dict ( _drafts )
await asyncio . to_thread ( _save_drafts_sync , snapshot )
logger . info ( " Draft saved: email= %s form= %s " , email , form )
return { " ok " : True }
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-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 ,
)
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 ,
)
logger . debug ( " [ %s ] full payload: %s " , request_id , data . model_dump ( ) )
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 ) )
await _store_client_profile ( str ( data . email ) , {
" fullName " : data . fullName ,
" phone " : data . phone ,
" dogName " : data . petName ,
} )
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 " , " " ) )
await _enforce_submit_rate_limits ( request_id , ip , str ( data . email ) )
_enforce_form_timing ( request_id , data )
if _is_honeypot_triggered ( data ) :
logger . warning (
" [ %s ] onboarding honeypot triggered for ip= %s email= %s page= %r " ,
request_id ,
ip ,
data . email ,
data . page ,
)
return {
" ok " : True ,
" request_id " : request_id ,
" ignored " : True ,
}
_validate_onboarding_submission ( request_id , data )
_normalize_onboarding_submission ( data )
logger . info (
" [ %s ] /onboarding-submit: email= %s ip= %s browser= %r dog= %s services= %s page= %r " ,
request_id , data . email , ip , browser , data . dogName , data . servicesNeeded , data . page ,
)
logger . debug ( " [ %s ] onboarding payload: %s " , request_id , data . model_dump ( ) )
owner_payload = {
" from " : FROM_EMAIL ,
" to " : [ OWNER_EMAIL ] ,
" reply_to " : data . email ,
" subject " : f " New GoodWalk onboarding — { data . fullName } ( { data . dogName } ) " ,
" html " : owner_onboarding_email ( data , ip , browser ) ,
}
if OWNER_BCC :
owner_payload [ " bcc " ] = [ OWNER_BCC ]
try :
await _send_email (
owner_payload ,
label = " owner_onboarding_email " ,
request_id = request_id ,
)
except Exception as exc :
logger . error ( " [ %s ] onboarding email failed after retries: %s " , request_id , exc , exc_info = True )
raise HTTPException (
status_code = 502 ,
detail = {
" request_id " : request_id ,
" message " : " The onboarding form could not be delivered. Please try again shortly. " ,
" error_type " : type ( exc ) . __name__ ,
} ,
)
await _register_email ( str ( data . email ) )
await _store_client_profile ( str ( data . email ) , {
" fullName " : data . fullName ,
" phone " : data . phone ,
" address " : data . address ,
" dogName " : data . dogName ,
" dogBreed " : data . dogBreed ,
" dogAge " : data . dogAge ,
" onboardingCompleted " : True ,
} )
return {
" ok " : True ,
" request_id " : request_id ,
}
@app.post ( " /contract-submit " )
async def submit_contract ( data : ContractSubmission , request : Request ) :
request_id = getattr ( request . state , " request_id " , uuid . uuid4 ( ) . hex [ : 8 ] )
ip = _get_ip ( request )
browser = _parse_ua ( request . headers . get ( " user-agent " , " " ) )
await _enforce_submit_rate_limits ( request_id , ip , str ( data . email ) )
_enforce_form_timing ( request_id , data )
if _is_honeypot_triggered ( data ) :
logger . warning (
" [ %s ] contract honeypot triggered for ip= %s email= %s page= %r " ,
request_id , ip , data . email , data . page ,
)
return { " ok " : True , " request_id " : request_id , " ignored " : True }
_validate_contract_submission ( request_id , data )
_normalize_contract_submission ( data )
logger . info (
" [ %s ] /contract-submit: email= %s ip= %s browser= %r dog= %s service= %s page= %r " ,
request_id , data . email , ip , browser , data . dogName , data . serviceType , data . page ,
)
owner_payload = {
" from " : FROM_EMAIL ,
" to " : [ OWNER_EMAIL ] ,
" reply_to " : data . email ,
" subject " : f " New GoodWalk contract — { data . fullName } ( { data . dogName } , { data . serviceType } ) " ,
" html " : owner_contract_email ( data , ip , browser ) ,
}
if OWNER_BCC :
owner_payload [ " bcc " ] = [ OWNER_BCC ]
try :
await _send_email ( owner_payload , label = " owner_contract_email " , request_id = request_id )
except Exception as exc :
logger . error ( " [ %s ] contract email failed after retries: %s " , request_id , exc , exc_info = True )
raise HTTPException (
status_code = 502 ,
detail = {
" request_id " : request_id ,
" message " : " The contract could not be delivered. Please try again shortly. " ,
" error_type " : type ( exc ) . __name__ ,
} ,
)
await _register_email ( str ( data . email ) )
await _store_client_profile ( str ( data . email ) , {
" fullName " : data . fullName ,
" phone " : data . phone ,
" address " : data . address ,
" dogName " : data . dogName ,
" dogBreed " : data . dogBreed ,
" dogAge " : data . dogAge ,
" contractCompleted " : True ,
} )
return { " ok " : True , " request_id " : request_id }