diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 168dde5..90de19e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "WebFetch(domain:raw.githubusercontent.com)", "Bash(node \"C:\\\\Users\\\\mattc\\\\.claude\\\\plugins\\\\cache\\\\impeccable\\\\impeccable\\\\3.1.1\\\\skills\\\\impeccable\\\\scripts\\\\load-context.mjs\")", "Bash(cat \"C:\\\\Users\\\\mattc\\\\.claude\\\\plugins\\\\cache\\\\impeccable\\\\impeccable\\\\3.1.1\\\\skills\\\\impeccable\\\\reference\\\\distill.md\" 2>&1 | head -100)", - "Read(//c/Users/mattc/.claude/plugins/cache/impeccable/impeccable/3.1.1/skills/impeccable/reference/**)" + "Read(//c/Users/mattc/.claude/plugins/cache/impeccable/impeccable/3.1.1/skills/impeccable/reference/**)", + "Bash(node *)" ] } } diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 79f8402..cac9354 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -8,17 +8,19 @@ container, routed by Host header at nginx: | Hostname | Purpose | |-----------------------------|--------------------------------------------| | `goodwalk.co.nz` / `www.…` | Public marketing site | -| `onboarding.goodwalk.co.nz` | New-client onboarding flow | -| `admin.goodwalk.co.nz` | Owner admin dashboard (Aless only) | +| `clients.goodwalk.co.nz` | New-client onboarding + contract portal | +| `cp.goodwalk.co.nz` | Owner admin dashboard (Aless only) | -The admin host needs its own TLS certificate at -`/etc/letsencrypt/live/admin.goodwalk.co.nz/`. Issue it once before the first -nginx reload, e.g.: - -```bash -docker compose -p nginx -f /docker/nginx/docker-compose.yml exec nginx \ - certbot certonly --webroot -w /var/www/certbot -d admin.goodwalk.co.nz -``` +The shared nginx container reads TLS material from its host bind mount at +`/docker/certbot/conf`, exposed inside the container as `/etc/letsencrypt`. +The deploy scripts now auto-bootstrap any missing certificates referenced by +the shared nginx config, including `clients.goodwalk.co.nz`, +`cp.goodwalk.co.nz`, and the legacy HTTPS redirect aliases +`onboarding.goodwalk.co.nz` and `admin.goodwalk.co.nz`. They do that by +temporarily loading an HTTP-only nginx config and running `certbot/certbot` +against the mounted ACME webroot. The only prerequisite is that each +hostname's DNS A record already points at the droplet and port `80` is +reachable. The dashboard's data (`client_profiles`, `allowed_emails`, `drafts`) lives in the shared postgres database alongside the marketing site content, in a single @@ -47,7 +49,7 @@ The production server currently runs multiple separate Docker Compose projects: - Main public site WordPress stack: - project: `goodwalkconz` - path: `/docker/wordpress/goodwalk.co.nz` -- Onboarding WordPress stack: +- Legacy onboarding WordPress stack: - project: `onboardinggoodwalkconz` - path: `/docker/wordpress/onboarding.goodwalk.co.nz` - Shared nginx: @@ -92,7 +94,7 @@ containers untouched. - Repo-local SSH config used by the deployment script. - [nginx/goodwalk.co.nz.svelte.conf.example](nginx/goodwalk.co.nz.svelte.conf.example) - Example shared-nginx config for routing the main public site to the new - Svelte app and mail API, including the onboarding subdomain. + Svelte app and mail API, including the `clients` and `cp` subdomains. ## First-time server preparation @@ -113,7 +115,7 @@ mkdir -p /docker/goodwalk-svelte It is created from [deploy.env.template](deploy.env.template). Current template contents: ```env -APP_VERSION=4.0.0 +APP_VERSION=4.0.1 ENABLE_GENERAL_ENQUIRIES=false PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false @@ -126,6 +128,8 @@ POSTGRES_PASSWORD_URLENCODED=gw_Pg_7Jm9%21Qx4%23Ld2%40Vr8 RESEND_API_KEY=replace-me OWNER_EMAIL=replace-me +SECONDARY_CP_EMAIL= +SECONDARY_CP_EMAILS= FROM_EMAIL=GoodWalk REPLY_TO=aless@goodwalk.co.nz MAIL_API_DATA_DIR=/app/data @@ -136,6 +140,7 @@ RATE_LIMIT_WINDOW_SECONDS=900 RATE_LIMIT_MAX_PER_IP=5 RATE_LIMIT_MAX_PER_EMAIL=3 RATE_LIMIT_MIN_INTERVAL_SECONDS=20 +EMAIL_SEND_TIMEOUT_SECONDS=20 ``` After the first deploy, edit `/docker/goodwalk-svelte/.env` on the server and replace: @@ -143,6 +148,14 @@ After the first deploy, edit `/docker/goodwalk-svelte/.env` on the server and re - `RESEND_API_KEY=replace-me` - `OWNER_EMAIL=replace-me` +Optional CP dashboard admins: + +- `SECONDARY_CP_EMAIL=person@example.com` +- `SECONDARY_CP_EMAILS=bob@smith.com;bobsmith2@smith.com` + +`OWNER_EMAIL` always keeps CP access. The secondary values are optional and may be +semicolon-, comma-, or whitespace-separated. + Frontend flags: - `PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false` keeps the sticky mobile booking CTA hidden. @@ -157,6 +170,17 @@ docker network ls | grep webnet Your server already uses `webnet`, so this should already be present. +5. Confirm the shared nginx compose mounts still point at the host certbot + paths expected by the deploy scripts: + +```yaml +- /docker/certbot/conf:/etc/letsencrypt:ro +- /docker/certbot/www:/var/www/certbot:ro +``` + +The scripts inspect the running `nginx` container to derive those host paths +before checking or issuing certificates. + ## First deploy From Windows PowerShell in the repo root: @@ -293,8 +317,10 @@ nginx/goodwalk.co.nz.svelte.conf.example Important: - `deploy.ps1` now copies the repo nginx config to `/docker/nginx/conf.d/goodwalk.co.nz.conf` and reloads the shared nginx container as part of deployment. - The repo nginx config uses Docker's internal resolver so future app/mail container rebuilds will not leave nginx pinned to stale upstream IPs. -- The same nginx config now also routes `onboarding.goodwalk.co.nz` to the Svelte app and `/api/onboarding-submit` to the shared mail API. -- Before cutover, confirm the server has a valid certificate for `onboarding.goodwalk.co.nz`, or adjust the onboarding certificate paths in the nginx config to match your cert layout. +- The same nginx config now also routes `clients.goodwalk.co.nz` to the Svelte app and `/api/onboarding-submit` to the shared mail API. +- The owner dashboard is now served on `cp.goodwalk.co.nz`. +- `onboarding.goodwalk.co.nz` and `admin.goodwalk.co.nz` should be kept only as redirect aliases once their DNS and TLS are in place. +- The deploy script will attempt to issue any missing certificates for `clients.goodwalk.co.nz`, `cp.goodwalk.co.nz`, and `onboarding.goodwalk.co.nz` before the final nginx reload. Manual nginx commands, if you ever need them: diff --git a/Dockerfile b/Dockerfile index 444a149..de11366 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG APP_VERSION=4.0.0 +ARG APP_VERSION=4.0.1 FROM node:22-alpine AS builder ARG APP_VERSION diff --git a/data/drafts.json b/data/drafts.json new file mode 100644 index 0000000..a5ffa16 --- /dev/null +++ b/data/drafts.json @@ -0,0 +1,57 @@ +{ + "info@goodwalk.co.nz": { + "onboarding": { + "currentStep": 5, + "ownerFirstName": "test@test", + "ownerLastName": "test@test", + "email": "test@test.com", + "phone": "test", + "address": "test", + "dogName": "test", + "dogLastName": "test", + "dogBreed": "test", + "dogDateOfBirth": "2026-05-19", + "servicesNeeded": [ + "Tiny Gang Pack Walks" + ], + "temperament": "test", + "accessInstructions": "", + "vetName": "test", + "vetAddress": "test", + "vetPhone": "test", + "emergencyContactName": "test", + "emergencyContactPhone": "test", + "isVaccinated": "yes", + "hasFoodAllergies": "no", + "foodAllergiesDetail": "", + "hasEnvAllergies": "no", + "envAllergiesDetail": "", + "onSpecialDiet": "no", + "specialDietDetail": "", + "onMedication": "no", + "medicationDetail": "", + "wellSocialised": "yes", + "dogsInteractedWeekly": 1, + "visitsBeach": "no", + "visitsDogParks": "no", + "dogParksFrequency": "", + "biteHistory": "no", + "reactiveToDogs": "no", + "reactiveToAnimals": "no", + "reactiveToChildren": "no", + "reactiveToPeople": "no", + "isDesexed": "yes", + "isRegistered": "yes", + "leashTrained": "yes", + "recallRating": 5, + "ranAwayBefore": "no", + "carBehaviour": "test", + "knownCommands": "test", + "additionalNotes": "test", + "socialMediaAccount": "test", + "howDidYouHear": "test", + "emergencyVetConsent": true, + "termsAccepted": true + } + } +} \ No newline at end of file diff --git a/deploy.env.template b/deploy.env.template index 261b091..17d3d7b 100644 --- a/deploy.env.template +++ b/deploy.env.template @@ -1,4 +1,4 @@ -APP_VERSION=4.0.0 +APP_VERSION=4.0.1 TZ=Pacific/Auckland POSTGRES_DB=goodwalk @@ -8,6 +8,8 @@ POSTGRES_PASSWORD_URLENCODED=gw_Pg_7Jm9%21Qx4%23Ld2%40Vr8 RESEND_API_KEY=re_hcDByLp8_HEBW93wDirr7o9g16FgCeYNF OWNER_EMAIL=info@goodwalk.co.nz +SECONDARY_CP_EMAIL=mattcohen0@gmail.com +SECONDARY_CP_EMAILS= OWNER_BCC=mattcohen0@gmail.com CLIENT_BCC=mattcohen0@gmail.com FROM_EMAIL=GoodWalk @@ -23,3 +25,10 @@ RATE_LIMIT_WINDOW_SECONDS=900 RATE_LIMIT_MAX_PER_IP=5 RATE_LIMIT_MAX_PER_EMAIL=3 RATE_LIMIT_MIN_INTERVAL_SECONDS=20 +EMAIL_SEND_TIMEOUT_SECONDS=20 + +# Security hardening — sensible defaults are in mail_api/config.py. +# Override only if the public domains change or you need to allow extra origins. +# CORS_ALLOWED_ORIGINS=https://goodwalk.co.nz,https://www.goodwalk.co.nz,https://clients.goodwalk.co.nz,https://cp.goodwalk.co.nz +# TRUSTED_HOSTS=goodwalk.co.nz,www.goodwalk.co.nz,clients.goodwalk.co.nz,cp.goodwalk.co.nz,localhost,127.0.0.1 +# MAX_REQUEST_BODY_BYTES=2097152 diff --git a/deploy.ps1 b/deploy.ps1 index 8f09b4f..b87dddd 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -180,10 +180,19 @@ function Invoke-SiteCheck { try { $response = Invoke-WebRequest -Uri $Url -MaximumRedirection 5 -TimeoutSec 30 - Write-Host "[deploy] Site responded with HTTP $($response.StatusCode)" + if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400) { + Write-Host "[deploy] Site responded with HTTP $($response.StatusCode)" + return + } + throw "Unexpected HTTP $($response.StatusCode) from $Url" } catch { - Write-Warning "Production site check failed: $($_.Exception.Message)" + $message = "Post-deploy site check failed: $($_.Exception.Message). Verify URL: $Url" + if ($SkipSiteCheck) { + Write-Warning $message + } else { + throw $message + } } } @@ -265,8 +274,9 @@ Write-Host ' - No global Docker prune/stop/delete commands are used.' Write-Host ' - Shared nginx will be updated and reloaded with the Docker-DNS-based config.' Write-Host ' - Subdomains served by this stack:' Write-Host ' goodwalk.co.nz / www.goodwalk.co.nz (marketing)' -Write-Host ' onboarding.goodwalk.co.nz (client onboarding)' -Write-Host ' admin.goodwalk.co.nz (owner admin dashboard)' +Write-Host ' clients.goodwalk.co.nz (client onboarding + contracts)' +Write-Host ' cp.goodwalk.co.nz (owner admin dashboard)' +Write-Host ' onboarding/admin remain legacy redirect aliases' if ($SeedAdminData) { Write-Host ' - Admin data seed: mail-api will OVERWRITE postgres admin_kv from the JSON volume.' } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d98c476..a0dd725 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,10 +3,10 @@ services: build: context: . args: - APP_VERSION: ${APP_VERSION:-4.0.0} + APP_VERSION: ${APP_VERSION:-4.0.1} container_name: goodwalk_svelte_app environment: - APP_VERSION: ${APP_VERSION:-4.0.0} + APP_VERSION: ${APP_VERSION:-4.0.1} DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk} NODE_ENV: production PORT: 3000 @@ -27,14 +27,16 @@ services: build: context: ./mail-api args: - APP_VERSION: ${APP_VERSION:-4.0.0} + APP_VERSION: ${APP_VERSION:-4.0.1} container_name: goodwalk_svelte_mail_api depends_on: - db environment: - APP_VERSION: ${APP_VERSION:-4.0.0} + APP_VERSION: ${APP_VERSION:-4.0.1} RESEND_API_KEY: ${RESEND_API_KEY} OWNER_EMAIL: ${OWNER_EMAIL} + SECONDARY_CP_EMAIL: ${SECONDARY_CP_EMAIL:-} + SECONDARY_CP_EMAILS: ${SECONDARY_CP_EMAILS:-} OWNER_BCC: ${OWNER_BCC:-} CLIENT_BCC: ${CLIENT_BCC:-} FROM_EMAIL: ${FROM_EMAIL:-GoodWalk } @@ -51,6 +53,9 @@ services: RATE_LIMIT_MAX_PER_IP: ${RATE_LIMIT_MAX_PER_IP:-5} RATE_LIMIT_MAX_PER_EMAIL: ${RATE_LIMIT_MAX_PER_EMAIL:-3} RATE_LIMIT_MIN_INTERVAL_SECONDS: ${RATE_LIMIT_MIN_INTERVAL_SECONDS:-20} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} + TRUSTED_HOSTS: ${TRUSTED_HOSTS:-} + MAX_REQUEST_BODY_BYTES: ${MAX_REQUEST_BODY_BYTES:-} PYTHONUNBUFFERED: '1' TZ: ${TZ:-Pacific/Auckland} expose: @@ -58,6 +63,14 @@ services: volumes: - mail_api_data:${MAIL_API_DATA_DIR:-/app/data} restart: unless-stopped + healthcheck: + # Hits /health via the container's own loopback so TrustedHostMiddleware + # (allowed_hosts) does not need to know about the bridge-network IP. + test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).status == 200 else 1)\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s networks: - default - webnet diff --git a/docker-compose.yml b/docker-compose.yml index 6d84ad0..b9b41a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,9 @@ services: build: context: . args: - APP_VERSION: ${APP_VERSION:-4.0.0} + APP_VERSION: ${APP_VERSION:-4.0.1} environment: - APP_VERSION: ${APP_VERSION:-4.0.0} + APP_VERSION: ${APP_VERSION:-4.0.1} DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk} NODE_ENV: production PORT: ${APP_PORT:-3000} @@ -21,9 +21,9 @@ services: build: context: ./mail-api args: - APP_VERSION: ${APP_VERSION:-4.0.0} + APP_VERSION: ${APP_VERSION:-4.0.1} environment: - APP_VERSION: ${APP_VERSION:-4.0.0} + APP_VERSION: ${APP_VERSION:-4.0.1} RESEND_API_KEY: ${RESEND_API_KEY} OWNER_EMAIL: ${OWNER_EMAIL} OWNER_BCC: ${OWNER_BCC:-} diff --git a/logs/mail-api.log b/logs/mail-api.log index ef8ce9b..cbdf08f 100644 --- a/logs/mail-api.log +++ b/logs/mail-api.log @@ -34,3 +34,96 @@ 18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [14ac39b2] GET /owner/pending-onboarding → 200 (0ms) 18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [01acd4c8] GET /auth/verify → 200 (0ms) 18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [9a3b3304] GET /owner/pending-onboarding → 200 (0ms) +19/05/2026 06:05:18 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 06:05:18 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +19/05/2026 06:05:18 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +19/05/2026 06:05:18 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 11:08:15 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 11:08:15 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +19/05/2026 11:08:15 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +19/05/2026 11:08:15 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 11:28:27 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 11:28:27 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 11:28:27 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +19/05/2026 11:28:28 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 11:28:34 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 11:28:34 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 11:28:34 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +19/05/2026 11:28:34 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 11:36:54 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 11:36:54 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 11:36:54 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +19/05/2026 11:36:55 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 11:37:03 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 11:37:03 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 11:37:03 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +19/05/2026 11:37:03 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 11:53:00 New Zealand Standard Time INFO mail-api: [664ade9a] auth: code issued for email=info@goodwalk.co.nz +19/05/2026 11:53:00 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 200354 +19/05/2026 11:53:00 New Zealand Standard Time INFO mail-api: [664ade9a] POST /auth/request-code → 200 (4ms) +19/05/2026 11:54:09 New Zealand Standard Time INFO mail-api: [89885efc] auth: code issued for email=info@goodwalk.co.nz +19/05/2026 11:54:09 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 672952 +19/05/2026 11:54:09 New Zealand Standard Time INFO mail-api: [89885efc] POST /auth/request-code → 200 (1ms) +19/05/2026 11:54:17 New Zealand Standard Time INFO mail-api: [a8f63301] auth: session created for email=info@goodwalk.co.nz +19/05/2026 11:54:17 New Zealand Standard Time INFO mail-api: [a8f63301] POST /auth/verify-code → 200 (1ms) +19/05/2026 11:54:17 New Zealand Standard Time INFO mail-api: [a55d43ec] GET /auth/verify → 200 (1ms) +19/05/2026 11:54:22 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 11:54:22 New Zealand Standard Time INFO mail-api: [4f6216a6] POST /auth/save-draft → 200 (12ms) +19/05/2026 11:54:30 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 11:54:30 New Zealand Standard Time INFO mail-api: [8e9fc8f9] POST /auth/save-draft → 200 (2ms) +19/05/2026 11:54:40 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 11:54:40 New Zealand Standard Time INFO mail-api: [3664fac8] POST /auth/save-draft → 200 (2ms) +19/05/2026 11:54:54 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 11:54:54 New Zealand Standard Time INFO mail-api: [44f478bd] POST /auth/save-draft → 200 (2ms) +19/05/2026 11:55:30 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 11:55:30 New Zealand Standard Time INFO mail-api: [2f362178] POST /auth/save-draft → 200 (2ms) +19/05/2026 11:58:50 New Zealand Standard Time INFO mail-api: [f6201361] GET /auth/verify → 200 (1ms) +19/05/2026 11:59:10 New Zealand Standard Time INFO mail-api: [2009256f] GET /auth/verify → 200 (0ms) +19/05/2026 11:59:12 New Zealand Standard Time INFO mail-api: [586443ba] GET /auth/verify → 200 (0ms) +19/05/2026 11:59:23 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 11:59:23 New Zealand Standard Time INFO mail-api: [3d4c66fa] POST /auth/save-draft → 200 (2ms) +19/05/2026 11:59:35 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 11:59:35 New Zealand Standard Time INFO mail-api: [f3254bc3] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:00:04 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:00:04 New Zealand Standard Time INFO mail-api: [536cbb34] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:00:24 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:00:24 New Zealand Standard Time INFO mail-api: [9eeeab2a] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:00:46 New Zealand Standard Time INFO mail-api: [210b3442] GET /auth/verify → 200 (0ms) +19/05/2026 12:02:08 New Zealand Standard Time INFO mail-api: [8036af11] GET /auth/verify → 200 (0ms) +19/05/2026 12:02:10 New Zealand Standard Time INFO mail-api: [5d6362f3] GET /auth/verify → 200 (0ms) +19/05/2026 12:02:49 New Zealand Standard Time INFO mail-api: [f7be21d6] GET /auth/verify → 200 (1ms) +19/05/2026 12:03:05 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:03:05 New Zealand Standard Time INFO mail-api: [b94d0e50] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:03:20 New Zealand Standard Time INFO mail-api: [b7cdc5fc] GET /auth/verify → 200 (0ms) +19/05/2026 12:03:33 New Zealand Standard Time INFO mail-api: [e2f2cafc] GET /auth/verify → 200 (1ms) +19/05/2026 12:03:41 New Zealand Standard Time INFO mail-api: [9d174f3c] GET /auth/verify → 200 (0ms) +19/05/2026 12:03:49 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:03:49 New Zealand Standard Time INFO mail-api: [bf06d193] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:04:23 New Zealand Standard Time INFO mail-api: [0d971ecb] GET /auth/verify → 200 (0ms) +19/05/2026 12:04:29 New Zealand Standard Time INFO mail-api: [0ac2045c] GET /auth/verify → 200 (0ms) +19/05/2026 12:04:53 New Zealand Standard Time INFO mail-api: [f53afe82] GET /auth/verify → 200 (1ms) +19/05/2026 12:05:04 New Zealand Standard Time INFO mail-api: [a8af4e31] GET /auth/verify → 200 (1ms) +19/05/2026 12:05:09 New Zealand Standard Time INFO mail-api: [72b6b2a6] GET /auth/verify → 200 (0ms) +19/05/2026 12:05:21 New Zealand Standard Time INFO mail-api: [e2489ceb] GET /auth/verify → 200 (0ms) +19/05/2026 12:05:31 New Zealand Standard Time INFO mail-api: [68abdad1] GET /auth/verify → 200 (0ms) +19/05/2026 12:05:37 New Zealand Standard Time INFO mail-api: [b878ea5f] GET /auth/verify → 200 (0ms) +19/05/2026 12:05:48 New Zealand Standard Time INFO mail-api: [8dc7536f] GET /auth/verify → 200 (0ms) +19/05/2026 12:07:50 New Zealand Standard Time INFO mail-api: [1631f302] GET /auth/verify → 200 (0ms) +19/05/2026 12:08:05 New Zealand Standard Time INFO mail-api: [864b59cf] GET /auth/verify → 200 (0ms) +19/05/2026 12:09:31 New Zealand Standard Time INFO mail-api: [d7044384] GET /auth/verify → 200 (0ms) +19/05/2026 12:09:47 New Zealand Standard Time INFO mail-api: [5db32466] GET /auth/verify → 200 (0ms) +19/05/2026 12:09:51 New Zealand Standard Time INFO mail-api: [f4ba7f01] GET /auth/verify → 200 (0ms) +19/05/2026 12:11:04 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:11:04 New Zealand Standard Time INFO mail-api: [c08fe213] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:11:11 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:11:11 New Zealand Standard Time INFO mail-api: [54224bbc] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:11:18 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:11:18 New Zealand Standard Time INFO mail-api: [7b3c3b07] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:11:34 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:11:34 New Zealand Standard Time INFO mail-api: [78a70cba] POST /auth/save-draft → 200 (2ms) +19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding +19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: [36fcde7c] POST /auth/save-draft → 200 (3ms) +19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: [dcc57378] POST /auth/save-draft → 200 (4ms) +19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: [0f97ff9b] POST /auth/logout → 200 (1ms) +19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: [ab3757e1] POST /auth/logout → 200 (0ms) diff --git a/mail-api/Dockerfile b/mail-api/Dockerfile index 6f225d5..d54b0f7 100644 --- a/mail-api/Dockerfile +++ b/mail-api/Dockerfile @@ -1,4 +1,4 @@ -ARG APP_VERSION=4.0.0 +ARG APP_VERSION=4.0.1 FROM python:3.12-slim ARG APP_VERSION @@ -10,10 +10,20 @@ LABEL org.opencontainers.image.version="${APP_VERSION}" COPY requirements.txt . RUN apt-get update \ - && apt-get install -y --no-install-recommends tzdata \ + && apt-get install -y --no-install-recommends \ + tzdata \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libharfbuzz0b \ + libfribidi0 \ + libcairo2 \ + shared-mime-info \ + fonts-dejavu-core \ + fonts-liberation \ && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir -r requirements.txt -COPY main.py . +COPY main.py db.py ./ +COPY mail_api ./mail_api CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/mail-api/__pycache__/db.cpython-314.pyc b/mail-api/__pycache__/db.cpython-314.pyc index d702c20..94231e0 100644 Binary files a/mail-api/__pycache__/db.cpython-314.pyc and b/mail-api/__pycache__/db.cpython-314.pyc differ diff --git a/mail-api/__pycache__/main.cpython-314.pyc b/mail-api/__pycache__/main.cpython-314.pyc index acbfe4d..55a85fa 100644 Binary files a/mail-api/__pycache__/main.cpython-314.pyc and b/mail-api/__pycache__/main.cpython-314.pyc differ diff --git a/mail-api/data/client_profiles.json b/mail-api/data/client_profiles.json index 978b6f6..2bcb828 100644 --- a/mail-api/data/client_profiles.json +++ b/mail-api/data/client_profiles.json @@ -3,12 +3,12 @@ "fullName": "Matt Test", "phone": "02124347477", "dogName": "Geoffrey", - "welcomePackSentAt": "2026-05-18T20:37:14", + "welcomePackSentAt": "2026-05-19T22:59:53", "welcomePackOffer": { "serviceType": "test", "priceDetails": "45", "startDate": "2026-05-18", - "sentAt": "2026-05-18T20:37:14" + "sentAt": "2026-05-19T22:59:53" } }, "natalie@desseinparke.com": { diff --git a/mail-api/db.py b/mail-api/db.py index ada2a44..d04a141 100644 --- a/mail-api/db.py +++ b/mail-api/db.py @@ -79,11 +79,188 @@ async def _ensure_schema() -> None: value jsonb not null, updated_at timestamptz not null default now() ); + + create table if not exists events ( + id bigserial primary key, + created_at timestamptz not null default now(), + request_id text, + event_type text not null, + actor_email text, + ip text, + status text, + detail jsonb + ); + create index if not exists events_created_at_idx on events (created_at desc); + create index if not exists events_event_type_idx on events (event_type); + create index if not exists events_actor_email_idx on events (actor_email); + + create table if not exists submissions ( + id bigserial primary key, + created_at timestamptz not null default now(), + request_id text, + kind text not null, + email text not null, + full_name text, + phone text, + ip text, + payload jsonb not null + ); + create index if not exists submissions_created_at_idx on submissions (created_at desc); + create index if not exists submissions_email_idx on submissions (email); + create index if not exists submissions_kind_idx on submissions (kind); """ ) _schema_ensured = True +async def record_event( + *, + event_type: str, + request_id: str | None = None, + actor_email: str | None = None, + ip: str | None = None, + status: str | None = None, + detail: dict | None = None, +) -> None: + """Append a single business event to the events table. Best-effort: + failures are logged and swallowed so they never block request handling.""" + try: + pool = await get_pool() + if pool is None: + return + await _ensure_schema() + payload = json.dumps(detail or {}) + async with pool.acquire() as conn: + await conn.execute( + """ + insert into events (request_id, event_type, actor_email, ip, status, detail) + values ($1, $2, $3, $4, $5, $6::jsonb) + """, + request_id, event_type, actor_email, ip, status, payload, + ) + except Exception as exc: + logger.warning("record_event(%s) failed: %s", event_type, exc) + + +async def record_submission( + *, + kind: str, + email: str, + full_name: str | None, + phone: str | None, + ip: str | None, + request_id: str | None, + payload: dict, +) -> None: + """Persist a contact-form submission (booking / onboarding / contract).""" + try: + pool = await get_pool() + if pool is None: + return + await _ensure_schema() + async with pool.acquire() as conn: + await conn.execute( + """ + insert into submissions (request_id, kind, email, full_name, phone, ip, payload) + values ($1, $2, $3, $4, $5, $6, $7::jsonb) + """, + request_id, kind, email, full_name, phone, ip, json.dumps(payload), + ) + except Exception as exc: + logger.warning("record_submission(%s) failed: %s", kind, exc) + + +async def list_events( + *, + limit: int = 100, + before_id: int | None = None, + event_type: str | None = None, + actor_email: str | None = None, +) -> list[dict]: + pool = await get_pool() + if pool is None: + return [] + await _ensure_schema() + clauses: list[str] = [] + params: list[Any] = [] + if before_id is not None: + params.append(before_id) + clauses.append(f"id < ${len(params)}") + if event_type: + params.append(event_type) + clauses.append(f"event_type = ${len(params)}") + if actor_email: + params.append(actor_email.strip().lower()) + clauses.append(f"actor_email = ${len(params)}") + where = ("where " + " and ".join(clauses)) if clauses else "" + params.append(max(1, min(500, limit))) + sql = ( + f"select id, created_at, request_id, event_type, actor_email, ip, status, detail " + f"from events {where} order by id desc limit ${len(params)}" + ) + async with pool.acquire() as conn: + rows = await conn.fetch(sql, *params) + return [ + { + "id": r["id"], + "createdAt": r["created_at"].isoformat() if r["created_at"] else None, + "requestId": r["request_id"], + "eventType": r["event_type"], + "actorEmail": r["actor_email"], + "ip": r["ip"], + "status": r["status"], + "detail": (json.loads(r["detail"]) if isinstance(r["detail"], (str, bytes, bytearray)) else r["detail"]) or {}, + } + for r in rows + ] + + +async def list_submissions( + *, + limit: int = 100, + before_id: int | None = None, + kind: str | None = None, + email: str | None = None, +) -> list[dict]: + pool = await get_pool() + if pool is None: + return [] + await _ensure_schema() + clauses: list[str] = [] + params: list[Any] = [] + if before_id is not None: + params.append(before_id) + clauses.append(f"id < ${len(params)}") + if kind: + params.append(kind) + clauses.append(f"kind = ${len(params)}") + if email: + params.append(email.strip().lower()) + clauses.append(f"email = ${len(params)}") + where = ("where " + " and ".join(clauses)) if clauses else "" + params.append(max(1, min(500, limit))) + sql = ( + f"select id, created_at, request_id, kind, email, full_name, phone, ip, payload " + f"from submissions {where} order by id desc limit ${len(params)}" + ) + async with pool.acquire() as conn: + rows = await conn.fetch(sql, *params) + return [ + { + "id": r["id"], + "createdAt": r["created_at"].isoformat() if r["created_at"] else None, + "requestId": r["request_id"], + "kind": r["kind"], + "email": r["email"], + "fullName": r["full_name"], + "phone": r["phone"], + "ip": r["ip"], + "payload": (json.loads(r["payload"]) if isinstance(r["payload"], (str, bytes, bytearray)) else r["payload"]) or {}, + } + for r in rows + ] + + async def get_kv(key: str) -> Any | None: pool = await get_pool() if pool is None: diff --git a/mail-api/logs/mail-api.log b/mail-api/logs/mail-api.log index f6582ef..e3d6c0a 100644 --- a/mail-api/logs/mail-api.log +++ b/mail-api/logs/mail-api.log @@ -1377,3 +1377,62 @@ resend.exceptions.ResendError: API key is invalid 18/05/2026 22:22:05 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s 18/05/2026 22:22:05 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) 18/05/2026 22:22:05 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 22:50:51 New Zealand Standard Time INFO mail-api: [26b6b10d] POST /auth/request-code → 400 (0ms) +19/05/2026 22:50:52 New Zealand Standard Time INFO mail-api: [e939d522] POST /auth/request-code → 400 (0ms) +19/05/2026 22:50:56 New Zealand Standard Time INFO mail-api: [6fe39f49] POST /auth/request-code → 400 (0ms) +19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 22:57:42 New Zealand Standard Time INFO mail-api: [0677fc9e] auth: code issued for email=info@goodwalk.co.nz +19/05/2026 22:57:42 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 237030 +19/05/2026 22:57:42 New Zealand Standard Time INFO mail-api: [0677fc9e] POST /auth/request-code → 200 (3ms) +19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [19adbd31] auth: session created for email=info@goodwalk.co.nz +19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [19adbd31] POST /auth/verify-code → 200 (2ms) +19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [40f46274] GET /auth/verify → 200 (1ms) +19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [83064e1b] GET /owner/pending-onboarding → 200 (1ms) +19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [b24a0321] GET /owner/all-clients → 200 (5ms) +19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [ad234ec3] GET /owner/birthdays → 200 (5ms) +19/05/2026 22:57:56 New Zealand Standard Time INFO mail-api: [8bbcb582] GET /owner/activity → 200 (1ms) +19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [11032d19] GET /auth/verify → 200 (0ms) +19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [6e3af292] GET /owner/pending-onboarding → 200 (1ms) +19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [ffa04e1b] GET /owner/birthdays → 200 (1ms) +19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [fc51922d] GET /owner/all-clients → 200 (1ms) +19/05/2026 22:57:58 New Zealand Standard Time INFO mail-api: [1219dba0] GET /owner/activity → 200 (0ms) +19/05/2026 22:57:59 New Zealand Standard Time INFO mail-api: [755e00f2] GET /owner/activity → 200 (0ms) +19/05/2026 22:57:59 New Zealand Standard Time INFO mail-api: [230cd479] GET /owner/activity → 200 (0ms) +19/05/2026 22:58:01 New Zealand Standard Time INFO mail-api: [b0c81ebf] GET /owner/message-templates → 200 (1ms) +19/05/2026 22:58:01 New Zealand Standard Time INFO mail-api: [da344121] POST /owner/render-message → 200 (1ms) +19/05/2026 22:58:04 New Zealand Standard Time INFO mail-api: [4ecf56ff] GET /owner/activity → 200 (0ms) +19/05/2026 22:58:30 New Zealand Standard Time INFO mail-api: [ed0b1740] GET /owner/client-enquiry → 200 (1ms) +19/05/2026 22:58:33 New Zealand Standard Time INFO mail-api: [d06c7a3b] GET /owner/client-enquiry → 200 (0ms) +19/05/2026 22:58:54 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=bulk_message to=['info@goodwalk.co.nz'] subject='A note from Goodwalk' +19/05/2026 22:58:54 New Zealand Standard Time INFO mail-api: [272f52a5] bulk message sent: template=general recipients=1 +19/05/2026 22:58:54 New Zealand Standard Time INFO mail-api: [272f52a5] POST /owner/send-message → 200 (18ms) +19/05/2026 22:59:33 New Zealand Standard Time INFO mail-api: [f52b4acc] GET /owner/activity → 200 (0ms) +19/05/2026 22:59:53 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=welcome_pack_email to=['mattcohen0@gmail.com'] subject='Welcome to the pack | Goodwalk' +19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [a82128ad] welcome pack sent: email=mattcohen0@gmail.com service=test start=2026-05-18 +19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [a82128ad] POST /owner/send-welcome-pack → 200 (13ms) +19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [9e00fe59] GET /owner/all-clients → 200 (0ms) +19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +19/05/2026 23:32:02 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint') +19/05/2026 23:32:02 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded +19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +19/05/2026 23:32:33 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint') +19/05/2026 23:32:33 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded +19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s +19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +19/05/2026 23:32:40 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint') +19/05/2026 23:32:40 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded +19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address diff --git a/mail-api/mail_api/__init__.py b/mail-api/mail_api/__init__.py new file mode 100644 index 0000000..89847ab --- /dev/null +++ b/mail-api/mail_api/__init__.py @@ -0,0 +1 @@ +"""Mail API package — split out of the legacy single-file main.py.""" diff --git a/mail-api/mail_api/__pycache__/__init__.cpython-314.pyc b/mail-api/mail_api/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..64cc6ce Binary files /dev/null and b/mail-api/mail_api/__pycache__/__init__.cpython-314.pyc differ diff --git a/mail-api/mail_api/__pycache__/config.cpython-314.pyc b/mail-api/mail_api/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..817fde9 Binary files /dev/null and b/mail-api/mail_api/__pycache__/config.cpython-314.pyc differ diff --git a/mail-api/mail_api/__pycache__/models.cpython-314.pyc b/mail-api/mail_api/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..080be52 Binary files /dev/null and b/mail-api/mail_api/__pycache__/models.cpython-314.pyc differ diff --git a/mail-api/mail_api/config.py b/mail-api/mail_api/config.py new file mode 100644 index 0000000..8077f48 --- /dev/null +++ b/mail-api/mail_api/config.py @@ -0,0 +1,257 @@ +"""Runtime configuration for the mail API. + +Loads environment variables once at import time, validates required values, +and exposes a frozen :class:`Settings` dataclass plus module-level constants +for compatibility with code that still imports the legacy names. +""" + +from __future__ import annotations + +import logging +import logging.handlers +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +import resend + + +# ── 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", + datefmt="%d/%m/%Y %H:%M:%S %Z", + ) + + 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() + + +# ── Settings ───────────────────────────────────────────────────────────────── + +DEV_MODE = os.environ.get("DEV_MODE", "").strip().lower() in {"1", "true", "yes"} + +REQUIRED_ENV = { + "RESEND_API_KEY": "API key from https://resend.com/api-keys", + "OWNER_EMAIL": "Email address that receives new lead notifications", +} + + +def _parse_email_env_list(*values: str | None) -> list[str]: + emails: list[str] = [] + seen: set[str] = set() + for raw in values: + if not raw: + continue + for part in re.split(r"[;,\s]+", raw.strip()): + normalized = part.strip().lower() + if normalized and normalized not in seen: + seen.add(normalized) + emails.append(normalized) + return emails + + +@dataclass(frozen=True) +class Settings: + resend_api_key: str + owner_email: str + cp_admin_emails: tuple[str, ...] + from_email: str + reply_to: str + owner_bcc: str + client_bcc: str + enable_general_enquiries: bool + max_attempts: int + form_min_seconds: int + form_max_seconds: int + rate_limit_window_seconds: int + rate_limit_max_per_ip: int + rate_limit_max_per_email: int + rate_limit_min_interval_seconds: int + email_send_timeout_seconds: int + + +def _load_settings() -> Settings: + if DEV_MODE: + owner_email = os.environ.get("OWNER_EMAIL", "dev@localhost").strip().lower() + return Settings( + resend_api_key=os.environ.get("RESEND_API_KEY", "dev"), + owner_email=owner_email, + cp_admin_emails=tuple(_parse_email_env_list( + owner_email, + os.environ.get("SECONDARY_CP_EMAIL"), + os.environ.get("SECONDARY_CP_EMAILS"), + )), + from_email=os.environ.get("FROM_EMAIL", "GoodWalk "), + reply_to=os.environ.get("REPLY_TO", "info@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, + email_send_timeout_seconds=20, + ) + + 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) + + owner_email = os.environ["OWNER_EMAIL"].strip().lower() + return Settings( + resend_api_key=os.environ["RESEND_API_KEY"], + owner_email=owner_email, + cp_admin_emails=tuple(_parse_email_env_list( + owner_email, + os.environ.get("SECONDARY_CP_EMAIL"), + os.environ.get("SECONDARY_CP_EMAILS"), + )), + from_email=os.environ.get("FROM_EMAIL", "GoodWalk "), + reply_to=os.environ.get("REPLY_TO", "info@goodwalk.co.nz"), + owner_bcc=os.environ.get("OWNER_BCC", "example@example.com").strip(), + client_bcc=os.environ.get("CLIENT_BCC", "").strip(), + enable_general_enquiries=os.environ.get("ENABLE_GENERAL_ENQUIRIES", "false").strip().lower() + in {"1", "true", "yes", "on", "enabled"}, + max_attempts=max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))), + 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"))), + email_send_timeout_seconds=max(5, int(os.environ.get("EMAIL_SEND_TIMEOUT_SECONDS", "20"))), + ) + + +settings = _load_settings() +resend.api_key = settings.resend_api_key + +APP_VERSION = os.environ.get("APP_VERSION", "unknown") +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"))) +BIRTHDAY_CHECK_INTERVAL_SECONDS = max(3600, int(os.environ.get("BIRTHDAY_CHECK_INTERVAL_SECONDS", str(12 * 3600)))) + + +def _split_csv_env(name: str, default: str) -> tuple[str, ...]: + # Treat empty-string env (e.g. compose `${VAR:-}`) the same as "unset" so + # an unset compose key falls back to the default, not to an empty allowlist. + raw = (os.environ.get(name) or "").strip() or default + parts = [p.strip() for p in raw.split(",")] + return tuple(p for p in parts if p) + + +CORS_ALLOWED_ORIGINS = _split_csv_env( + "CORS_ALLOWED_ORIGINS", + "https://goodwalk.co.nz,https://www.goodwalk.co.nz,https://clients.goodwalk.co.nz,https://cp.goodwalk.co.nz", +) +TRUSTED_HOSTS = _split_csv_env( + "TRUSTED_HOSTS", + "goodwalk.co.nz,www.goodwalk.co.nz,clients.goodwalk.co.nz,cp.goodwalk.co.nz,localhost,127.0.0.1", +) +# Hard cap on request body size. Signed contracts include a base64 PNG of the +# signature (~30 KB) plus form fields, so 2 MB is a generous ceiling. +MAX_REQUEST_BODY_BYTES = max(64 * 1024, int((os.environ.get("MAX_REQUEST_BODY_BYTES") or str(2 * 1024 * 1024)).strip() or str(2 * 1024 * 1024))) + +_DATA_DIR = Path(os.environ.get("DATA_DIR", "data")) +ALLOWED_EMAILS_FILE = _DATA_DIR / "allowed_emails.json" +CLIENT_PROFILES_FILE = _DATA_DIR / "client_profiles.json" +DRAFTS_FILE = _DATA_DIR / "drafts.json" + +LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png" + +# ── Legacy module constants (kept for compatibility with the existing main.py) ── + +OWNER_EMAIL = settings.owner_email +CP_ADMIN_EMAILS = set(settings.cp_admin_emails) +OWNER_BCC = settings.owner_bcc +CLIENT_BCC = settings.client_bcc +FROM_EMAIL = settings.from_email +REPLY_TO = settings.reply_to +ENABLE_GENERAL_ENQUIRIES = settings.enable_general_enquiries +MAX_SEND_ATTEMPTS = settings.max_attempts +FORM_MIN_SECONDS = settings.form_min_seconds +FORM_MAX_SECONDS = settings.form_max_seconds +RATE_LIMIT_WINDOW_SECONDS = settings.rate_limit_window_seconds +RATE_LIMIT_MAX_PER_IP = settings.rate_limit_max_per_ip +RATE_LIMIT_MAX_PER_EMAIL = settings.rate_limit_max_per_email +RATE_LIMIT_MIN_INTERVAL_SECONDS = settings.rate_limit_min_interval_seconds +EMAIL_SEND_TIMEOUT_SECONDS = settings.email_send_timeout_seconds + +# Owner-BCC placeholder used by deploy.env.template; treat it as "unset" for the smoke email. +STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else "" + +logger.info( + "Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r cp_admins=%r owner_bcc=%r client_bcc=%r general_enquiries=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss send_timeout=%ss", + APP_VERSION, + os.environ.get("TZ", "system-default"), + FROM_EMAIL, + REPLY_TO, + OWNER_EMAIL, + sorted(CP_ADMIN_EMAILS), + OWNER_BCC, + CLIENT_BCC, + ENABLE_GENERAL_ENQUIRIES, + 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, + EMAIL_SEND_TIMEOUT_SECONDS, +) diff --git a/mail-api/mail_api/models.py b/mail-api/mail_api/models.py new file mode 100644 index 0000000..438fcca --- /dev/null +++ b/mail-api/mail_api/models.py @@ -0,0 +1,117 @@ +"""Pydantic request/response models for the mail API.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, EmailStr + + +class BaseSubmission(BaseModel): + fullName: str + email: EmailStr + phone: str + website: str = "" + formStartedAt: int | None = None + visitStartedAt: int | None = None + pageEnteredAt: int | None = None + firstInteractionAt: int | None = None + sendClickedAt: int | None = None + referrer: str = "" + page: str = "" + + +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 + submissionSnapshot: dict[str, Any] = {} + + +class WelcomePackEmailRequest(BaseModel): + email: EmailStr + serviceType: str + priceDetails: str + startDate: str + preview: bool = False + + +class BirthdayEmailRequest(BaseModel): + email: EmailStr + preview: bool = False + + +class BirthdayAutoSendRequest(BaseModel): + email: EmailStr + enabled: bool + + +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 + + +class RenderMessageRequest(BaseModel): + templateId: str + heading: str = "" + body: str = "" + ctaLabel: str = "" + ctaUrl: str = "" + subHeading: str = "" + highlightText: str = "" + signOff: str = "" + footerNote: str = "" + fontId: str = "system" + + +class SendMessageRequest(BaseModel): + templateId: str + subject: str + heading: str = "" + body: str = "" + ctaLabel: str = "" + ctaUrl: str = "" + subHeading: str = "" + highlightText: str = "" + signOff: str = "" + footerNote: str = "" + fontId: str = "system" + recipients: list[EmailStr] = [] + preview: bool = False diff --git a/mail-api/main.py b/mail-api/main.py index e0f791f..41ae6a2 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -1,188 +1,104 @@ import asyncio import base64 from collections import deque +from contextlib import asynccontextmanager import json -import logging -import logging.handlers import os import random import re import secrets -import sys import time import uuid -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any import resend from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response -from pydantic import BaseModel, EmailStr +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.responses import JSONResponse, Response +from starlette.types import ASGIApp, Receive, Scope, Send import db as admin_db - - -# ── 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", - datefmt="%d/%m/%Y %H:%M:%S %Z", - ) - - 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 ──────────────────────────────────────────────────────────── - -DEV_MODE = os.environ.get("DEV_MODE", "").strip().lower() in {"1", "true", "yes"} - -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: - 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 "), - "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, - } - 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 "), - "reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"), - "owner_bcc": os.environ.get("OWNER_BCC", "example@example.com").strip(), - "client_bcc": os.environ.get("CLIENT_BCC", "").strip(), - "enable_general_enquiries": os.environ.get("ENABLE_GENERAL_ENQUIRIES", "false").strip().lower() in {"1", "true", "yes", "on", "enabled"}, - "max_attempts": max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))), - "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"))), - } - - -_config = _load_config() -APP_VERSION = os.environ.get("APP_VERSION", "unknown") -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"))) -BIRTHDAY_CHECK_INTERVAL_SECONDS = max(3600, int(os.environ.get("BIRTHDAY_CHECK_INTERVAL_SECONDS", str(12 * 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" -resend.api_key = _config["resend_api_key"] -OWNER_EMAIL = _config["owner_email"] -OWNER_BCC = _config["owner_bcc"] -CLIENT_BCC = _config["client_bcc"] -FROM_EMAIL = _config["from_email"] -REPLY_TO = _config["reply_to"] -ENABLE_GENERAL_ENQUIRIES = _config["enable_general_enquiries"] -MAX_SEND_ATTEMPTS = _config["max_attempts"] -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"] - -LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png" - -logger.info( - "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=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss", +from mail_api.config import ( + ALLOWED_EMAILS_FILE as _ALLOWED_EMAILS_FILE, APP_VERSION, - os.environ.get("TZ", "system-default"), - FROM_EMAIL, - REPLY_TO, - OWNER_EMAIL, - OWNER_BCC, + AUTH_CODE_MAX_ATTEMPTS, + AUTH_CODE_REQUESTS_PER_HOUR, + AUTH_CODE_TTL_SECONDS, + AUTH_IP_BLOCK_DURATION, + AUTH_IP_FAILURE_WINDOW, + AUTH_IP_MAX_FAILURES, + AUTH_SESSION_TTL_SECONDS, + BIRTHDAY_CHECK_INTERVAL_SECONDS, CLIENT_BCC, + CLIENT_PROFILES_FILE as _CLIENT_PROFILES_FILE, + CORS_ALLOWED_ORIGINS, + CP_ADMIN_EMAILS, + DEV_MODE, + DRAFTS_FILE as _DRAFTS_FILE, + EMAIL_SEND_TIMEOUT_SECONDS, ENABLE_GENERAL_ENQUIRIES, - MAX_SEND_ATTEMPTS, - FORM_MIN_SECONDS, FORM_MAX_SECONDS, - RATE_LIMIT_WINDOW_SECONDS, - RATE_LIMIT_MAX_PER_IP, + FORM_MIN_SECONDS, + FROM_EMAIL, + LOGO_URL, + MAX_REQUEST_BODY_BYTES, + MAX_SEND_ATTEMPTS, + OWNER_BCC, + OWNER_EMAIL, RATE_LIMIT_MAX_PER_EMAIL, + RATE_LIMIT_MAX_PER_IP, RATE_LIMIT_MIN_INTERVAL_SECONDS, + RATE_LIMIT_WINDOW_SECONDS, + REPLY_TO, + STARTUP_TEST_RECIPIENT, + TRUSTED_HOSTS, + logger, +) +from mail_api.models import ( + BaseSubmission, + BirthdayAutoSendRequest, + BirthdayEmailRequest, + BookingSubmission, + ContractSubmission, + OnboardingSubmission, + RenderMessageRequest, + SendMessageRequest, + WelcomePackEmailRequest, ) -app = FastAPI(title="GoodWalk Mail API") -STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else "" + +@asynccontextmanager +async def _lifespan(app: FastAPI): + await _startup_mail_check() + try: + yield + finally: + await _shutdown_background_tasks() + + +app = FastAPI(title="GoodWalk Mail API", lifespan=_lifespan) # ── Auth state ─────────────────────────────────────────────────────────────── +def _write_pii_json(path: Path, payload: object) -> None: + """Atomically write a JSON file and chmod it owner-only (0600). + + The chmod is best-effort: it is a no-op on Windows, but on the Linux + Docker host it ensures the file with PII is unreadable by other users. + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") + try: + os.chmod(tmp, 0o600) + except OSError: + pass + os.replace(tmp, path) + + def _load_allowed_emails_from_file() -> set[str]: seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()} try: @@ -196,10 +112,7 @@ def _load_allowed_emails_from_file() -> set[str]: def _save_allowed_emails_file(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" - ) + _write_pii_json(_ALLOWED_EMAILS_FILE, {"emails": sorted(emails)}) except Exception as exc: logger.warning("Could not save allowed_emails file: %s", exc) @@ -215,8 +128,7 @@ def _load_client_profiles_from_file() -> dict[str, dict]: def _save_client_profiles_file(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") + _write_pii_json(_CLIENT_PROFILES_FILE, profiles) except Exception as exc: logger.warning("Could not save client_profiles file: %s", exc) @@ -232,25 +144,42 @@ def _load_drafts_from_file() -> dict: def _save_drafts_file(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") + _write_pii_json(_DRAFTS_FILE, drafts) except Exception as exc: logger.warning("Could not save drafts file: %s", exc) -# Backwards-compatible synchronous wrappers used by asyncio.to_thread call sites -# elsewhere in the module. They write to the JSON file. Postgres persistence is -# done asynchronously alongside the file write — see _persist_admin_state. -def _save_allowed_emails_sync(emails: set[str]) -> None: - _save_allowed_emails_file(emails) +async def _save_active_sessions_async() -> None: + """Persist live sessions to admin_kv so they survive container restarts. + + Snapshot filters out expired entries before writing. Best-effort — + failure is logged but does not block the auth flow (memory remains + authoritative for the current process). + """ + now = time.time() + snapshot = {tok: s for tok, s in _active_sessions.items() if s.get("expires_at", 0) > now} + try: + await admin_db.set_kv("active_sessions", snapshot) + except Exception as exc: + logger.warning("Could not persist active_sessions: %s", exc) -def _save_client_profiles_sync(profiles: dict) -> None: - _save_client_profiles_file(profiles) - - -def _save_drafts_sync(drafts: dict) -> None: - _save_drafts_file(drafts) +async def _load_active_sessions_async() -> dict[str, dict]: + if not admin_db.is_enabled(): + return {} + try: + data = await admin_db.get_kv("active_sessions") + if not isinstance(data, dict): + return {} + now = time.time() + return { + tok: s + for tok, s in data.items() + if isinstance(s, dict) and isinstance(s.get("expires_at"), (int, float)) and s["expires_at"] > now + } + except Exception as exc: + logger.warning("Could not load active_sessions from admin_kv: %s", exc) + return {} async def _persist_admin_state(key: str, value: Any) -> None: @@ -344,7 +273,7 @@ logger.info("Auth: loaded %d allowed email(s)", len(_allowed_emails)) async def _require_session_email(request: Request) -> str: auth_header = request.headers.get("Authorization", "") - token = auth_header.removeprefix("Bearer ").strip() or _trimmed(request.query_params.get("token", "")) + token = auth_header.removeprefix("Bearer ").strip() if not token: raise HTTPException(status_code=401, detail="No token provided.") @@ -361,7 +290,7 @@ async def _require_session_email(request: Request) -> str: async def _require_owner_email(request: Request) -> str: email = await _require_session_email(request) - if email != OWNER_EMAIL.strip().lower(): + if email not in CP_ADMIN_EMAILS: raise HTTPException(status_code=403, detail="Owner access required.") return email @@ -374,7 +303,7 @@ async def _register_email(email: str) -> None: if normalized not in _allowed_emails: _allowed_emails.add(normalized) snapshot = sorted(_allowed_emails) - await asyncio.to_thread(_save_allowed_emails_sync, set(_allowed_emails)) + await asyncio.to_thread(_save_allowed_emails_file, set(_allowed_emails)) await _persist_admin_state("allowed_emails", {"emails": snapshot}) logger.info("Auth: registered new allowed email: %s", normalized) @@ -393,7 +322,7 @@ async def _store_client_profile(email: str, profile: dict) -> None: if merged != existing: _client_profiles[normalized] = merged snapshot = dict(_client_profiles) - await asyncio.to_thread(_save_client_profiles_sync, snapshot) + await asyncio.to_thread(_save_client_profiles_file, snapshot) await _persist_admin_state("client_profiles", snapshot) def _check_ip_blocked(ip: str, request_id: str) -> None: @@ -427,11 +356,73 @@ def _record_auth_failure(ip: str, request_id: str, reason: str) -> None: ) +class _BodySizeLimitMiddleware: + """Reject requests whose Content-Length exceeds MAX_REQUEST_BODY_BYTES. + + Defence-in-depth alongside nginx ``client_max_body_size``. Streaming + requests without a Content-Length header are tracked byte-by-byte and + short-circuited if they overflow the cap. + """ + + def __init__(self, app: ASGIApp, max_bytes: int) -> None: + self.app = app + self.max_bytes = max_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + declared = headers.get("content-length") + if declared is not None: + try: + if int(declared) > self.max_bytes: + await _send_413(send) + return + except ValueError: + pass + + received = 0 + overflowed = False + + async def _wrapped_receive(): + nonlocal received, overflowed + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > self.max_bytes: + overflowed = True + return {"type": "http.disconnect"} + return message + + if overflowed: + await _send_413(send) + return + await self.app(scope, _wrapped_receive, send) + + +async def _send_413(send: Send) -> None: + await send({ + "type": "http.response.start", + "status": 413, + "headers": [(b"content-type", b"application/json")], + }) + await send({ + "type": "http.response.body", + "body": b'{"detail":"Request body too large."}', + }) + + +app.add_middleware(_BodySizeLimitMiddleware, max_bytes=MAX_REQUEST_BODY_BYTES) +app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(TRUSTED_HOSTS)) app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=list(CORS_ALLOWED_ORIGINS), allow_methods=["POST", "GET"], - allow_headers=["*"], + allow_headers=["Authorization", "Content-Type", "X-Requested-With"], + allow_credentials=False, + max_age=600, ) @@ -458,87 +449,6 @@ async def _request_logging_middleware(request: Request, call_next): return response -class BaseSubmission(BaseModel): - fullName: str - email: EmailStr - phone: str - website: str = "" - formStartedAt: int | None = None - visitStartedAt: int | None = None - pageEnteredAt: int | None = None - firstInteractionAt: int | None = None - sendClickedAt: int | None = None - referrer: str = "" - page: str = "" - - -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 - submissionSnapshot: dict[str, Any] = {} - - -class WelcomePackEmailRequest(BaseModel): - email: EmailStr - serviceType: str - priceDetails: str - startDate: str - preview: bool = False - - -class BirthdayEmailRequest(BaseModel): - email: EmailStr - preview: bool = False - - -class BirthdayAutoSendRequest(BaseModel): - email: EmailStr - enabled: bool - - -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 - - # ── Helpers ────────────────────────────────────────────────────────────────── def _get_ip(request: Request) -> str: @@ -1550,7 +1460,7 @@ def _birthday_ics_attachment(dog_name: str, dog_birth_date: str, owner_name: str "METHOD:PUBLISH\r\n" "BEGIN:VEVENT\r\n" f"UID:{uuid.uuid4()}@goodwalk.co.nz\r\n" - f"DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}\r\n" + f"DTSTAMP:{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}\r\n" f"DTSTART;VALUE=DATE:{starts_on.strftime('%Y%m%d')}\r\n" f"DTEND;VALUE=DATE:{ends_on.strftime('%Y%m%d')}\r\n" "RRULE:FREQ=YEARLY\r\n" @@ -1567,6 +1477,32 @@ def _birthday_ics_attachment(dog_name: str, dog_birth_date: str, owner_name: str } +def _render_pdf_sync(html: str) -> bytes: + from weasyprint import HTML # imported lazily so unit tests don't require the native libs + return HTML(string=html).write_pdf() + + +# Feature flags — flip to True to attach a PDF copy of the signed form to the owner email. +# Kept as in-code booleans (not env vars) so the contract path stays off until explicitly enabled. +CONTRACT_PDF_ATTACHMENT_ENABLED = False +ONBOARDING_PDF_ATTACHMENT_ENABLED = True + + +async def _signed_form_pdf_attachment(html: str, full_name: str, kind: str, request_id: str) -> dict | None: + safe_name = re.sub(r"[^a-z0-9]+", "-", _trimmed(full_name).lower()).strip("-") or "client" + try: + pdf_bytes = await asyncio.to_thread(_render_pdf_sync, html) + except Exception as exc: + logger.error("[%s] %s PDF generation failed: %s", request_id, kind, exc, exc_info=True) + return None + + logger.info("[%s] %s PDF generated: %d bytes", request_id, kind, len(pdf_bytes)) + return { + "filename": f"goodwalk-{kind}-{safe_name}.pdf", + "content": base64.b64encode(pdf_bytes).decode("ascii"), + } + + # ── Sending with retries ───────────────────────────────────────────────────── async def _send_email(payload: dict, label: str, request_id: str) -> dict: @@ -1581,7 +1517,10 @@ async def _send_email(payload: dict, label: str, request_id: str) -> dict: for attempt in range(1, MAX_SEND_ATTEMPTS + 1): started = time.monotonic() try: - result = await asyncio.to_thread(resend.Emails.send, payload) + result = await asyncio.wait_for( + asyncio.to_thread(resend.Emails.send, payload), + timeout=EMAIL_SEND_TIMEOUT_SECONDS, + ) elapsed_ms = (time.monotonic() - started) * 1000 email_id = result.get("id") if isinstance(result, dict) else None logger.info( @@ -1669,10 +1608,48 @@ async def _send_startup_test_email() -> None: # ── Routes ─────────────────────────────────────────────────────────────────── -@app.on_event("startup") +async def _startup_smoke_pdf() -> None: + """Import WeasyPrint and run a trivial render to surface native-lib issues + (libpango/cairo/etc.) at boot rather than on the first PDF request.""" + try: + await asyncio.to_thread(_render_pdf_sync, "ok") + logger.info("Startup smoke: WeasyPrint OK — PDF attachments available") + except Exception as exc: + logger.error("Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (%s)", exc) + + +async def _startup_verify_schema() -> None: + """Force schema creation at boot and verify the new tables exist so the + activity log isn't silently empty if CREATE permission is missing.""" + if not admin_db.is_enabled(): + logger.warning("Startup smoke: postgres disabled — activity/submissions will NOT be recorded") + return + try: + pool = await admin_db.get_pool() + if pool is None: + logger.warning("Startup smoke: postgres pool unavailable — activity/submissions will NOT be recorded") + return + await admin_db._ensure_schema() # idempotent + async with pool.acquire() as conn: + row = await conn.fetchrow( + "select to_regclass('public.events') as ev, to_regclass('public.submissions') as sub" + ) + if row and row["ev"] and row["sub"]: + logger.info("Startup smoke: pg tables OK — events + submissions ready") + else: + logger.error("Startup smoke: pg tables MISSING (events=%s submissions=%s) — check CREATE perms", + row["ev"] if row else None, row["sub"] if row else None) + except Exception as exc: + logger.error("Startup smoke: pg schema verify FAILED (%s)", exc) + + async def _startup_mail_check() -> None: global _birthday_auto_task, _allowed_emails, _client_profiles, _drafts + # 0. Boot-time smoke tests so silent failures surface immediately. + await _startup_smoke_pdf() + await _startup_verify_schema() + # 1. Seed postgres from JSON if admin_kv is empty (one-time migration). await _seed_admin_state_from_json_if_needed() @@ -1691,8 +1668,13 @@ async def _startup_mail_check() -> None: db_drafts = await _load_drafts_async() if isinstance(db_drafts, dict): _drafts = db_drafts - logger.info("Admin state refreshed from postgres: clients=%d emails=%d drafts=%d", - len(_client_profiles), len(_allowed_emails), len(_drafts)) + db_sessions = await _load_active_sessions_async() + if db_sessions: + _active_sessions.update(db_sessions) + logger.info( + "Admin state refreshed from postgres: clients=%d emails=%d drafts=%d sessions=%d", + len(_client_profiles), len(_allowed_emails), len(_drafts), len(_active_sessions), + ) except Exception: logger.exception("Admin state refresh from postgres failed; using JSON snapshot") @@ -1704,7 +1686,6 @@ async def _startup_mail_check() -> None: _birthday_auto_task = asyncio.create_task(_birthday_auto_sender_loop()) -@app.on_event("shutdown") async def _shutdown_background_tasks() -> None: global _birthday_auto_task if _birthday_auto_task is not None: @@ -1834,6 +1815,102 @@ def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str, """ +def _onboarding_confirmation_email_html(data: OnboardingSubmission) -> str: + first_name = data.fullName.split()[0] if data.fullName.strip() else "there" + dog_name = _trimmed(data.dogName) + service_names = [service.strip() for service in data.servicesNeeded if isinstance(service, str) and service.strip()] + service_summary = ", ".join(service_names[:2]) if service_names else "your selected service" + if len(service_names) > 2: + service_summary += f" + {len(service_names) - 2} more" + + onboarding_url = "https://clients.goodwalk.co.nz/" + badge_html = ( + '
Submitted
" + ) + + return f""" + + + + + Your onboarding has been submitted + + + + +
+ + {_logo_header(badge_html=badge_html, subtitle="Your onboarding details are safely with us")} + + + + + + +
+

+ Thanks, {first_name}. Your onboarding is complete. +

+

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

+ + + +
+
+ Snapshot +
+
+
+
Owner
+
{data.fullName}
+
+
+
Dog
+
{dog_name or 'Details submitted'}
+
+
+
Services
+
{service_summary}
+
+
+
+ + + + + +
+
+ What happens next? +
+
+ We’ll review your submission and come back to you if we need anything clarified. + If you need to check your details again, use the button below to sign back in with a one-time code. +
+
+ + + Review your submission + + +

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

+
+
+ Goodwalk · Auckland, New Zealand +
+
+
+ +""" + + def _birthday_email_html(client_name: str, dog_name: str) -> str: first_name = client_name.split()[0] if client_name.strip() else "there" dog_name_clean = dog_name.strip() or "your dog" @@ -2024,6 +2101,10 @@ async def auth_request_code(request: Request): request_id=request_id, ) + await admin_db.record_event( + event_type="auth_code_requested", + request_id=request_id, actor_email=email, ip=ip, status="ok", + ) return {"ok": True} @@ -2065,7 +2146,12 @@ async def auth_verify_code(request: Request): token = secrets.token_urlsafe(32) _active_sessions[token] = {"email": email, "expires_at": time.time() + AUTH_SESSION_TTL_SECONDS} + await _save_active_sessions_async() logger.info("[%s] auth: session created for email=%s", request_id, email) + await admin_db.record_event( + event_type="auth_login", + request_id=request_id, actor_email=email, ip=ip, status="ok", + ) return {"ok": True, "token": token, "email": email} @@ -2074,16 +2160,30 @@ async def auth_verify(request: Request): email = await _require_session_email(request) profile = _client_profiles.get(email, {}) draft = _drafts.get(email, {}) - return {"ok": True, "email": email, "profile": profile, "draft": draft} + return { + "ok": True, + "email": email, + "profile": profile, + "draft": draft, + "cpAdmin": email in CP_ADMIN_EMAILS, + "ownerEmail": OWNER_EMAIL, + } @app.post("/auth/logout") async def auth_logout(request: Request): auth_header = request.headers.get("Authorization", "") token = auth_header.removeprefix("Bearer ").strip() + logged_out_email = None if token: async with _auth_lock: - _active_sessions.pop(token, None) + existing = _active_sessions.pop(token, None) + logged_out_email = existing.get("email") if isinstance(existing, dict) else None + await _save_active_sessions_async() + await admin_db.record_event( + event_type="auth_logout", + actor_email=logged_out_email, ip=_get_ip(request), status="ok", + ) return {"ok": True} @@ -2105,7 +2205,7 @@ async def auth_save_draft(request: Request): user_drafts[form] = data snapshot = dict(_drafts) - await asyncio.to_thread(_save_drafts_sync, snapshot) + await asyncio.to_thread(_save_drafts_file, snapshot) await _persist_admin_state("drafts", snapshot) logger.info("Draft saved: email=%s form=%s", email, form) return {"ok": True} @@ -2581,35 +2681,6 @@ def _render_message_html( """ -class RenderMessageRequest(BaseModel): - templateId: str - heading: str = "" - body: str = "" - ctaLabel: str = "" - ctaUrl: str = "" - subHeading: str = "" - highlightText: str = "" - signOff: str = "" - footerNote: str = "" - fontId: str = "system" - - -class SendMessageRequest(BaseModel): - templateId: str - subject: str - heading: str = "" - body: str = "" - ctaLabel: str = "" - ctaUrl: str = "" - subHeading: str = "" - highlightText: str = "" - signOff: str = "" - footerNote: str = "" - fontId: str = "system" - recipients: list[EmailStr] = [] - preview: bool = False - - @app.get("/owner/message-templates") async def owner_message_templates(request: Request): await _require_owner_email(request) @@ -2660,7 +2731,7 @@ async def owner_render_message(data: RenderMessageRequest, request: Request): @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]) - await _require_owner_email(request) + owner_email = await _require_owner_email(request) if data.templateId not in MESSAGE_TEMPLATES: raise HTTPException(status_code=400, detail="Unknown template.") @@ -2718,6 +2789,16 @@ async def owner_send_message(data: SendMessageRequest, request: Request): raise HTTPException(status_code=502, detail={"request_id": request_id, "message": "The message could not be sent."}) logger.info("[%s] bulk message sent: template=%s recipients=%d", request_id, data.templateId, len(recipient_emails)) + await admin_db.record_event( + event_type="owner_message_sent", + request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", + detail={ + "templateId": data.templateId, + "subject": subject, + "recipientCount": len(recipient_emails), + "recipients": recipient_emails, + }, + ) return {"ok": True, "recipientCount": len(recipient_emails)} @@ -2749,6 +2830,54 @@ async def owner_client_enquiry(request: Request): return {"ok": True, "enquiry": enquiry} +@app.get("/owner/activity") +async def owner_activity(request: Request): + await _require_owner_email(request) + qp = request.query_params + try: + limit = int(qp.get("limit", "100")) + except ValueError: + limit = 100 + before_id = qp.get("beforeId") or qp.get("before_id") + try: + before_id_int = int(before_id) if before_id else None + except ValueError: + before_id_int = None + event_type = _trimmed(qp.get("eventType", "")) or None + actor_email = _trimmed(qp.get("actorEmail", "")) or None + events = await admin_db.list_events( + limit=limit, + before_id=before_id_int, + event_type=event_type, + actor_email=actor_email, + ) + return {"ok": True, "events": events} + + +@app.get("/owner/submissions") +async def owner_submissions(request: Request): + await _require_owner_email(request) + qp = request.query_params + try: + limit = int(qp.get("limit", "100")) + except ValueError: + limit = 100 + before_id = qp.get("beforeId") or qp.get("before_id") + try: + before_id_int = int(before_id) if before_id else None + except ValueError: + before_id_int = None + kind = _trimmed(qp.get("kind", "")) or None + email_filter = _trimmed(qp.get("email", "")) or None + rows = await admin_db.list_submissions( + limit=limit, + before_id=before_id_int, + kind=kind, + email=email_filter, + ) + return {"ok": True, "submissions": rows} + + @app.get("/owner/pending-onboarding") async def owner_pending_onboarding(request: Request): await _require_owner_email(request) @@ -3017,7 +3146,7 @@ async def owner_birthday_ics(request: Request): @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]) - await _require_owner_email(request) + owner_email = await _require_owner_email(request) email = str(data.email).strip().lower() profile = _client_profiles.get(email, {}) @@ -3080,13 +3209,22 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques }) logger.info("[%s] welcome pack sent: email=%s service=%s start=%s", request_id, email, data.serviceType, data.startDate) + await admin_db.record_event( + event_type="owner_welcome_pack_sent", + request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", + detail={ + "recipient": email, + "serviceType": _trimmed(data.serviceType), + "startDate": _trimmed(data.startDate), + }, + ) return {"ok": True, "sentAt": sent_at} @app.post("/owner/send-birthday-email") async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request): request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) - await _require_owner_email(request) + owner_email = await _require_owner_email(request) email = str(data.email).strip().lower() profile = _client_profiles.get(email, {}) @@ -3109,12 +3247,17 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request }, ) + await admin_db.record_event( + event_type="owner_birthday_email_sent", + request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", + detail={"recipient": email, "preview": bool(data.preview)}, + ) return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": bool(data.preview)} @app.post("/owner/birthday-auto-send") async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Request): - await _require_owner_email(request) + owner_email = await _require_owner_email(request) email = str(data.email).strip().lower() profile = _client_profiles.get(email, {}) @@ -3128,6 +3271,11 @@ async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Reque "birthdayAutoSend": data.enabled, }) + await admin_db.record_event( + event_type="owner_birthday_auto_toggled", + actor_email=owner_email, ip=_get_ip(request), status="ok", + detail={"clientEmail": email, "enabled": bool(data.enabled)}, + ) return {"ok": True, "enabled": data.enabled} @@ -3148,6 +3296,11 @@ async def submit_booking(data: BookingSubmission, request: Request): data.email, data.page, ) + await admin_db.record_event( + event_type="booking_honeypot", + request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored", + detail={"page": data.page}, + ) return { "ok": True, "request_id": request_id, @@ -3164,7 +3317,8 @@ async def submit_booking(data: BookingSubmission, request: Request): "[%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()) + # PII intentionally NOT logged here — payload contains submitter contact details. + logger.debug("[%s] booking payload keys=%s", request_id, sorted(data.model_dump().keys())) failures: list[dict] = [] @@ -3259,6 +3413,23 @@ async def submit_booking(data: BookingSubmission, request: Request): }, }) + await admin_db.record_submission( + kind="booking", + email=str(data.email), full_name=data.fullName, phone=data.phone, + ip=ip, request_id=request_id, payload=data.model_dump(), + ) + await admin_db.record_event( + event_type="booking_submitted", + request_id=request_id, actor_email=str(data.email), ip=ip, + status="partial" if failures else "ok", + detail={ + "enquiryType": data.enquiryType, + "dog": data.petName, + "services": data.services, + "failures": [f["label"] for f in failures], + }, + ) + return { "ok": True, "request_id": request_id, @@ -3465,6 +3636,11 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request): data.email, data.page, ) + await admin_db.record_event( + event_type="onboarding_honeypot", + request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored", + detail={"page": data.page}, + ) return { "ok": True, "request_id": request_id, @@ -3478,18 +3654,27 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request): "[%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()) + # PII intentionally NOT logged here — payload contains address, vet, medical notes, signature. + logger.debug("[%s] onboarding payload keys=%s", request_id, sorted(data.model_dump().keys())) + owner_html = owner_onboarding_email(data, ip, browser) owner_payload = { "from": FROM_EMAIL, "to": [OWNER_EMAIL], "reply_to": data.email, "subject": f"New GoodWalk onboarding — {data.fullName} ({data.dogName})", - "html": owner_onboarding_email(data, ip, browser), + "html": owner_html, } + attachments: list[dict] = [] birthday_attachment = _birthday_ics_attachment(data.dogName, data.dogAge, data.fullName, request_id) if birthday_attachment: - owner_payload["attachments"] = [birthday_attachment] + attachments.append(birthday_attachment) + if ONBOARDING_PDF_ATTACHMENT_ENABLED: + pdf_attachment = await _signed_form_pdf_attachment(owner_html, data.fullName, "onboarding", request_id) + if pdf_attachment: + attachments.append(pdf_attachment) + if attachments: + owner_payload["attachments"] = attachments if OWNER_BCC: owner_payload["bcc"] = [OWNER_BCC] @@ -3523,6 +3708,41 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request): "onboardingSubmission": data.submissionSnapshot, }) + client_payload = { + "from": FROM_EMAIL, + "to": [str(data.email)], + "reply_to": REPLY_TO, + "subject": f"Your Goodwalk onboarding is complete, {data.fullName.split()[0]}", + "html": _onboarding_confirmation_email_html(data), + } + if CLIENT_BCC: + client_payload["bcc"] = [CLIENT_BCC] + + try: + await _send_email( + client_payload, + label="client_onboarding_confirmation_email", + request_id=request_id, + ) + except Exception as exc: + logger.error( + "[%s] client onboarding confirmation email failed: %s", + request_id, + exc, + exc_info=True, + ) + + await admin_db.record_submission( + kind="onboarding", + email=str(data.email), full_name=data.fullName, phone=data.phone, + ip=ip, request_id=request_id, payload=data.model_dump(), + ) + await admin_db.record_event( + event_type="onboarding_submitted", + request_id=request_id, actor_email=str(data.email), ip=ip, status="ok", + detail={"dog": data.dogName, "services": data.servicesNeeded}, + ) + return { "ok": True, "request_id": request_id, @@ -3543,6 +3763,11 @@ async def submit_contract(data: ContractSubmission, request: Request): "[%s] contract honeypot triggered for ip=%s email=%s page=%r", request_id, ip, data.email, data.page, ) + await admin_db.record_event( + event_type="contract_honeypot", + request_id=request_id, actor_email=str(data.email), ip=ip, status="ignored", + detail={"page": data.page}, + ) return {"ok": True, "request_id": request_id, "ignored": True} _validate_contract_submission(request_id, data) @@ -3553,13 +3778,18 @@ async def submit_contract(data: ContractSubmission, request: Request): request_id, data.email, ip, browser, data.dogName, data.serviceType, data.page, ) + owner_html = owner_contract_email(data, ip, browser) owner_payload = { "from": FROM_EMAIL, "to": [OWNER_EMAIL], "reply_to": data.email, "subject": f"New GoodWalk contract — {data.fullName} ({data.dogName}, {data.serviceType})", - "html": owner_contract_email(data, ip, browser), + "html": owner_html, } + if CONTRACT_PDF_ATTACHMENT_ENABLED: + pdf_attachment = await _signed_form_pdf_attachment(owner_html, data.fullName, "contract", request_id) + if pdf_attachment: + owner_payload["attachments"] = [pdf_attachment] if OWNER_BCC: owner_payload["bcc"] = [OWNER_BCC] @@ -3587,4 +3817,15 @@ async def submit_contract(data: ContractSubmission, request: Request): "contractCompleted": True, }) + await admin_db.record_submission( + kind="contract", + email=str(data.email), full_name=data.fullName, phone=data.phone, + ip=ip, request_id=request_id, payload=data.model_dump(), + ) + await admin_db.record_event( + event_type="contract_submitted", + request_id=request_id, actor_email=str(data.email), ip=ip, status="ok", + detail={"dog": data.dogName, "service": data.serviceType, "startDate": data.startDate}, + ) + return {"ok": True, "request_id": request_id} diff --git a/mail-api/requirements.txt b/mail-api/requirements.txt index 75e5e51..8830482 100644 --- a/mail-api/requirements.txt +++ b/mail-api/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]>=0.32 resend>=2.0 pydantic[email]>=2.10 asyncpg>=0.30 +weasyprint>=63 diff --git a/nginx/goodwalk.co.nz.svelte.conf.example b/nginx/goodwalk.co.nz.svelte.conf.example index d3bbf60..52829b9 100644 --- a/nginx/goodwalk.co.nz.svelte.conf.example +++ b/nginx/goodwalk.co.nz.svelte.conf.example @@ -16,7 +16,7 @@ server { server { listen 80; - server_name onboarding.goodwalk.co.nz; + server_name clients.goodwalk.co.nz onboarding.goodwalk.co.nz; location /.well-known/acme-challenge/ { root /var/www/certbot; @@ -24,13 +24,13 @@ server { } location / { - return 301 https://onboarding.goodwalk.co.nz$request_uri; + return 301 https://clients.goodwalk.co.nz$request_uri; } } server { listen 80; - server_name admin.goodwalk.co.nz; + server_name cp.goodwalk.co.nz admin.goodwalk.co.nz; location /.well-known/acme-challenge/ { root /var/www/certbot; @@ -38,7 +38,7 @@ server { } location / { - return 301 https://admin.goodwalk.co.nz$request_uri; + return 301 https://cp.goodwalk.co.nz$request_uri; } } @@ -70,6 +70,8 @@ server { add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; + client_max_body_size 2m; gzip on; gzip_vary on; @@ -113,6 +115,16 @@ server { return 404; } + location = /api/health { + access_log off; + set $goodwalk_mail_api goodwalk_svelte_mail_api:8000; + proxy_pass http://$goodwalk_mail_api/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 2s; + proxy_read_timeout 5s; + } + location /api/submit { if (-f /etc/nginx/conf.d/maintenance.flag) { return 503; @@ -152,6 +164,16 @@ server { ssl_certificate /etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem; + return 301 https://clients.goodwalk.co.nz$request_uri; +} + +server { + listen 443 ssl; + server_name clients.goodwalk.co.nz; + + ssl_certificate /etc/letsencrypt/live/clients.goodwalk.co.nz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/clients.goodwalk.co.nz/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; @@ -163,6 +185,8 @@ server { add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; + client_max_body_size 2m; gzip on; gzip_vary on; @@ -176,6 +200,16 @@ server { deny all; } + location = /api/health { + access_log off; + set $goodwalk_mail_api goodwalk_svelte_mail_api:8000; + proxy_pass http://$goodwalk_mail_api/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 2s; + proxy_read_timeout 5s; + } + location /api/onboarding-submit { if (-f /etc/nginx/conf.d/maintenance.flag) { return 503; @@ -222,10 +256,10 @@ server { server { listen 443 ssl; - server_name admin.goodwalk.co.nz; + server_name cp.goodwalk.co.nz; - ssl_certificate /etc/letsencrypt/live/admin.goodwalk.co.nz/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/admin.goodwalk.co.nz/privkey.pem; + ssl_certificate /etc/letsencrypt/live/cp.goodwalk.co.nz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cp.goodwalk.co.nz/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; @@ -238,7 +272,9 @@ server { add_header X-Frame-Options DENY always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; add_header X-Robots-Tag "noindex, nofollow" always; + client_max_body_size 2m; gzip on; gzip_vary on; @@ -252,6 +288,16 @@ server { deny all; } + location = /api/health { + access_log off; + set $goodwalk_mail_api goodwalk_svelte_mail_api:8000; + proxy_pass http://$goodwalk_mail_api/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 2s; + proxy_read_timeout 5s; + } + # Auth endpoints proxied to mail-api (verify / login / logout). location /api/auth/ { set $goodwalk_mail_api goodwalk_svelte_mail_api:8000; diff --git a/nginx/nginx.conf b/nginx/nginx.conf index b43f292..76610c3 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -14,14 +14,27 @@ server { server { listen 80; - server_name onboarding.goodwalk.co.nz; + server_name clients.goodwalk.co.nz onboarding.goodwalk.co.nz; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { - return 301 https://$host$request_uri; + return 301 https://clients.goodwalk.co.nz$request_uri; + } +} + +server { + listen 80; + server_name cp.goodwalk.co.nz admin.goodwalk.co.nz; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://cp.goodwalk.co.nz$request_uri; } } @@ -72,6 +85,18 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; + return 301 https://clients.goodwalk.co.nz$request_uri; +} + +server { + listen 443 ssl; + server_name clients.goodwalk.co.nz; + + ssl_certificate /etc/letsencrypt/live/clients.goodwalk.co.nz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/clients.goodwalk.co.nz/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; @@ -89,6 +114,78 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /api/auth/ { + rewrite ^/api/auth/(.*)$ /auth/$1 break; + proxy_pass http://mail-api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://app:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +server { + listen 443 ssl; + server_name admin.goodwalk.co.nz; + + ssl_certificate /etc/letsencrypt/live/admin.goodwalk.co.nz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.goodwalk.co.nz/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + return 301 https://cp.goodwalk.co.nz$request_uri; +} + +server { + listen 443 ssl; + server_name cp.goodwalk.co.nz; + + ssl_certificate /etc/letsencrypt/live/cp.goodwalk.co.nz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cp.goodwalk.co.nz/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + add_header X-Robots-Tag "noindex, nofollow" always; + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + + location /api/auth/ { + rewrite ^/api/auth/(.*)$ /auth/$1 break; + proxy_pass http://mail-api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/owner/ { + rewrite ^/api/owner/(.*)$ /owner/$1 break; + proxy_pass http://mail-api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { proxy_pass http://app:3000; proxy_http_version 1.1; diff --git a/package-lock.json b/package-lock.json index 74ab5cc..b0eb352 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gw-svelte", - "version": "4.0.0", + "version": "4.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gw-svelte", - "version": "4.0.0", + "version": "4.0.1", "dependencies": { "canvas-confetti": "^1.9.4", "pg": "^8.13.1" diff --git a/package.json b/package.json index da6ac6c..cf9c349 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gw-svelte", - "version": "4.0.0", + "version": "4.0.1", "private": true, "type": "module", "scripts": { diff --git a/scripts/deploy-from-git.sh b/scripts/deploy-from-git.sh index 62653bc..e8b5ca1 100644 --- a/scripts/deploy-from-git.sh +++ b/scripts/deploy-from-git.sh @@ -43,6 +43,108 @@ fail() { exit 1 } +get_mount_source_for_destination() { + local container_id="$1" + local destination="$2" + + docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$container_id" \ + | awk -F'|' -v wanted="$destination" '$2 == wanted { print $1; exit }' +} + +write_acme_bootstrap_config() { + local source_config="$1" + local target_config="$2" + shift 2 + + awk -v skip_domains="$*" ' + BEGIN { + split(skip_domains, items, " ") + for (i in items) { + if (items[i] != "") skip[items[i]] = 1 + } + in_server = 0 + depth = 0 + block = "" + is_ssl = 0 + has_skipped_domain = 0 + } + function emit_block() { + if (!(is_ssl && has_skipped_domain)) { + printf "%s", block + } + in_server = 0 + depth = 0 + block = "" + is_ssl = 0 + has_skipped_domain = 0 + } + { + line = $0 ORS + + if (!in_server) { + if ($0 ~ /^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/) { + in_server = 1 + depth = 1 + block = line + next + } + + printf "%s", line + next + } + + block = block line + + if ($0 ~ /^[[:space:]]*listen[[:space:]]+443[[:space:]]+ssl([[:space:]]|;)/) { + is_ssl = 1 + } + + if ($0 ~ /^[[:space:]]*server_name[[:space:]]+/) { + count = split($0, parts, /[[:space:];]+/) + for (i = 2; i <= count; i++) { + if (parts[i] in skip) { + has_skipped_domain = 1 + } + } + } + + opens = gsub(/\{/, "{", $0) + closes = gsub(/\}/, "}", $0) + depth += opens - closes + + if (depth == 0) { + emit_block() + } + } + END { + if (in_server) { + emit_block() + } + } + ' "$source_config" > "$target_config" +} + +obtain_certificate() { + local domain="$1" + local cert_root="$2" + local acme_webroot="$3" + local certbot_email="info@goodwalk.co.nz" + + echo "[deploy-git] Obtaining TLS certificate for $domain" + docker run --rm \ + -v "$cert_root:/etc/letsencrypt" \ + -v "$acme_webroot:/var/www/certbot" \ + certbot/certbot:latest \ + certonly \ + --webroot \ + -w /var/www/certbot \ + --cert-name "$domain" \ + -d "$domain" \ + --non-interactive \ + --agree-tos \ + -m "$certbot_email" +} + assert_command() { command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed on the server" } @@ -429,22 +531,6 @@ if (( nginx_args_present )); then [[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE" [[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE" - # Pre-flight: SSL certificate for onboarding.goodwalk.co.nz must exist before - # nginx can reload — the config references this cert path directly. - ONBOARDING_CERT="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem" - ONBOARDING_KEY="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem" - if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then - fail "SSL certificate for onboarding.goodwalk.co.nz is not present on this server. - Expected: $ONBOARDING_CERT - One-time setup on the droplet: - 1. Ensure the DNS A record for onboarding.goodwalk.co.nz points to this server's IP - 2. Bring nginx up so the ACME webroot is reachable, then obtain the certificate: - certbot certonly --webroot -w /var/www/certbot \\ - -d onboarding.goodwalk.co.nz \\ - --non-interactive --agree-tos -m info@goodwalk.co.nz - 3. Re-run this deploy script" - fi - MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html" MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png" [[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC" @@ -453,6 +539,55 @@ if (( nginx_args_present )); then NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)" [[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying." + CERT_ROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/etc/letsencrypt")" + [[ -n "$CERT_ROOT_HOST_DIR" ]] || fail "nginx container is missing the certificate bind mount for /etc/letsencrypt." + + CERTBOT_WEBROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/var/www/certbot")" + [[ -n "$CERTBOT_WEBROOT_HOST_DIR" ]] || fail "nginx container is missing the ACME webroot bind mount for /var/www/certbot." + + ONBOARDING_CERT="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/fullchain.pem" + ONBOARDING_KEY="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/privkey.pem" + CLIENTS_CERT="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/fullchain.pem" + CLIENTS_KEY="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/privkey.pem" + CP_CERT="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/fullchain.pem" + CP_KEY="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/privkey.pem" + MISSING_CERT_DOMAINS=() + if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then + MISSING_CERT_DOMAINS+=("onboarding.goodwalk.co.nz") + fi + if [[ ! -f "$CLIENTS_CERT" || ! -f "$CLIENTS_KEY" ]]; then + MISSING_CERT_DOMAINS+=("clients.goodwalk.co.nz") + fi + if [[ ! -f "$CP_CERT" || ! -f "$CP_KEY" ]]; then + MISSING_CERT_DOMAINS+=("cp.goodwalk.co.nz") + fi + + if (( ${#MISSING_CERT_DOMAINS[@]} > 0 )); then + echo "[deploy-git] Missing TLS certificates detected: ${MISSING_CERT_DOMAINS[*]}" + echo "[deploy-git] Bootstrapping nginx HTTP config so ACME challenges can be served" + mkdir -p "$(dirname "$NGINX_TARGET")" + BOOTSTRAP_CONFIG="$(mktemp "${TMPDIR:-/tmp}/goodwalk-nginx-acme.XXXXXX.conf")" + write_acme_bootstrap_config "$DEPLOY_PATH/$NGINX_SOURCE" "$BOOTSTRAP_CONFIG" "${MISSING_CERT_DOMAINS[@]}" + cp "$BOOTSTRAP_CONFIG" "$NGINX_TARGET" + rm -f "$BOOTSTRAP_CONFIG" + + echo "[deploy-git] Validating bootstrap nginx configuration" + "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t + + echo "[deploy-git] Reloading shared nginx with bootstrap config" + "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload + + for domain in "${MISSING_CERT_DOMAINS[@]}"; do + echo "[deploy-git] Ensure the DNS A record for $domain points to this server before certificate issuance" + obtain_certificate "$domain" "$CERT_ROOT_HOST_DIR" "$CERTBOT_WEBROOT_HOST_DIR" \ + || fail "Automatic certificate issuance failed for $domain. Confirm DNS resolves here and port 80 is reachable." + done + + [[ -f "$ONBOARDING_CERT" && -f "$ONBOARDING_KEY" ]] || fail "Automatic certificate issuance did not create onboarding.goodwalk.co.nz at $ONBOARDING_CERT" + [[ -f "$CLIENTS_CERT" && -f "$CLIENTS_KEY" ]] || fail "Automatic certificate issuance did not create clients.goodwalk.co.nz at $CLIENTS_CERT" + [[ -f "$CP_CERT" && -f "$CP_KEY" ]] || fail "Automatic certificate issuance did not create cp.goodwalk.co.nz at $CP_CERT" + fi + if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \ | grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then fail "nginx container is missing the maintenance bind mount. diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 7893d2d..5c06ec4 100644 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -35,6 +35,108 @@ fail() { exit 1 } +get_mount_source_for_destination() { + local container_id="$1" + local destination="$2" + + docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$container_id" \ + | awk -F'|' -v wanted="$destination" '$2 == wanted { print $1; exit }' +} + +write_acme_bootstrap_config() { + local source_config="$1" + local target_config="$2" + shift 2 + + awk -v skip_domains="$*" ' + BEGIN { + split(skip_domains, items, " ") + for (i in items) { + if (items[i] != "") skip[items[i]] = 1 + } + in_server = 0 + depth = 0 + block = "" + is_ssl = 0 + has_skipped_domain = 0 + } + function emit_block() { + if (!(is_ssl && has_skipped_domain)) { + printf "%s", block + } + in_server = 0 + depth = 0 + block = "" + is_ssl = 0 + has_skipped_domain = 0 + } + { + line = $0 ORS + + if (!in_server) { + if ($0 ~ /^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/) { + in_server = 1 + depth = 1 + block = line + next + } + + printf "%s", line + next + } + + block = block line + + if ($0 ~ /^[[:space:]]*listen[[:space:]]+443[[:space:]]+ssl([[:space:]]|;)/) { + is_ssl = 1 + } + + if ($0 ~ /^[[:space:]]*server_name[[:space:]]+/) { + count = split($0, parts, /[[:space:];]+/) + for (i = 2; i <= count; i++) { + if (parts[i] in skip) { + has_skipped_domain = 1 + } + } + } + + opens = gsub(/\{/, "{", $0) + closes = gsub(/\}/, "}", $0) + depth += opens - closes + + if (depth == 0) { + emit_block() + } + } + END { + if (in_server) { + emit_block() + } + } + ' "$source_config" > "$target_config" +} + +obtain_certificate() { + local domain="$1" + local cert_root="$2" + local acme_webroot="$3" + local certbot_email="info@goodwalk.co.nz" + + echo "[deploy-remote] Obtaining TLS certificate for $domain" + docker run --rm \ + -v "$cert_root:/etc/letsencrypt" \ + -v "$acme_webroot:/var/www/certbot" \ + certbot/certbot:latest \ + certonly \ + --webroot \ + -w /var/www/certbot \ + --cert-name "$domain" \ + -d "$domain" \ + --non-interactive \ + --agree-tos \ + -m "$certbot_email" +} + while [[ $# -gt 0 ]]; do case "$1" in --archive) @@ -292,22 +394,6 @@ if (( nginx_args_present )); then [[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE" [[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE" - # Pre-flight: SSL certificate for onboarding.goodwalk.co.nz must exist before - # nginx can reload — the config references this cert path directly. - ONBOARDING_CERT="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem" - ONBOARDING_KEY="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem" - if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then - fail "SSL certificate for onboarding.goodwalk.co.nz is not present on this server. - Expected: $ONBOARDING_CERT - One-time setup on the droplet: - 1. Ensure the DNS A record for onboarding.goodwalk.co.nz points to this server's IP - 2. Bring nginx up so the ACME webroot is reachable, then obtain the certificate: - certbot certonly --webroot -w /var/www/certbot \\ - -d onboarding.goodwalk.co.nz \\ - --non-interactive --agree-tos -m info@goodwalk.co.nz - 3. Re-run this deploy script" - fi - MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html" MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png" [[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC" @@ -320,6 +406,55 @@ if (( nginx_args_present )); then NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)" [[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying." + CERT_ROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/etc/letsencrypt")" + [[ -n "$CERT_ROOT_HOST_DIR" ]] || fail "nginx container is missing the certificate bind mount for /etc/letsencrypt." + + CERTBOT_WEBROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/var/www/certbot")" + [[ -n "$CERTBOT_WEBROOT_HOST_DIR" ]] || fail "nginx container is missing the ACME webroot bind mount for /var/www/certbot." + + ONBOARDING_CERT="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/fullchain.pem" + ONBOARDING_KEY="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/privkey.pem" + CLIENTS_CERT="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/fullchain.pem" + CLIENTS_KEY="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/privkey.pem" + CP_CERT="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/fullchain.pem" + CP_KEY="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/privkey.pem" + MISSING_CERT_DOMAINS=() + if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then + MISSING_CERT_DOMAINS+=("onboarding.goodwalk.co.nz") + fi + if [[ ! -f "$CLIENTS_CERT" || ! -f "$CLIENTS_KEY" ]]; then + MISSING_CERT_DOMAINS+=("clients.goodwalk.co.nz") + fi + if [[ ! -f "$CP_CERT" || ! -f "$CP_KEY" ]]; then + MISSING_CERT_DOMAINS+=("cp.goodwalk.co.nz") + fi + + if (( ${#MISSING_CERT_DOMAINS[@]} > 0 )); then + echo "[deploy-remote] Missing TLS certificates detected: ${MISSING_CERT_DOMAINS[*]}" + echo "[deploy-remote] Bootstrapping nginx HTTP config so ACME challenges can be served" + mkdir -p "$(dirname "$NGINX_TARGET")" + BOOTSTRAP_CONFIG="$(mktemp "${TMPDIR:-/tmp}/goodwalk-nginx-acme.XXXXXX.conf")" + write_acme_bootstrap_config "$DEPLOY_PATH/$NGINX_SOURCE" "$BOOTSTRAP_CONFIG" "${MISSING_CERT_DOMAINS[@]}" + cp "$BOOTSTRAP_CONFIG" "$NGINX_TARGET" + rm -f "$BOOTSTRAP_CONFIG" + + echo "[deploy-remote] Validating bootstrap nginx configuration" + "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t + + echo "[deploy-remote] Reloading shared nginx with bootstrap config" + "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload + + for domain in "${MISSING_CERT_DOMAINS[@]}"; do + echo "[deploy-remote] Ensure the DNS A record for $domain points to this server before certificate issuance" + obtain_certificate "$domain" "$CERT_ROOT_HOST_DIR" "$CERTBOT_WEBROOT_HOST_DIR" \ + || fail "Automatic certificate issuance failed for $domain. Confirm DNS resolves here and port 80 is reachable." + done + + [[ -f "$ONBOARDING_CERT" && -f "$ONBOARDING_KEY" ]] || fail "Automatic certificate issuance did not create onboarding.goodwalk.co.nz at $ONBOARDING_CERT" + [[ -f "$CLIENTS_CERT" && -f "$CLIENTS_KEY" ]] || fail "Automatic certificate issuance did not create clients.goodwalk.co.nz at $CLIENTS_CERT" + [[ -f "$CP_CERT" && -f "$CP_KEY" ]] || fail "Automatic certificate issuance did not create cp.goodwalk.co.nz at $CP_CERT" + fi + if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \ | grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then fail "nginx container is missing the maintenance bind mount. diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 776500a..f05afe1 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,31 +1,26 @@ import type { Handle } from '@sveltejs/kit'; +import { resolveSurface } from '$lib/server/surface'; -const ADMIN_HOSTNAME = 'admin.goodwalk.co.nz'; const ADMIN_PATH = '/owner/welcome'; -function isAdminHost(hostname: string | undefined | null): boolean { - if (!hostname) return false; - return hostname.toLowerCase() === ADMIN_HOSTNAME; -} - export const handle: Handle = async ({ event, resolve }) => { - const onAdminHost = isAdminHost(event.url.hostname); + const { surface } = resolveSurface(event.url, event.cookies); const path = event.url.pathname; - // The admin host serves the dashboard at its root. - if (onAdminHost && (path === '/' || path === '')) { + // The admin host (cp.*) serves the dashboard at its root. + if (surface === 'cp' && (path === '/' || path === '')) { return new Response(null, { status: 302, headers: { location: ADMIN_PATH }, }); } - // Block the admin dashboard from the public marketing host so it only - // lives on admin.goodwalk.co.nz in production. Localhost and the - // onboarding subdomain are still allowed for development and migration. - const hostname = event.url.hostname.toLowerCase(); - const isPublicMarketingHost = hostname === 'goodwalk.co.nz' || hostname === 'www.goodwalk.co.nz'; - if (isPublicMarketingHost && path.startsWith('/owner/')) { + // Block the admin dashboard from the public marketing site so /owner/* + // only renders on the cp surface (or on the clients surface during the + // legacy onboarding-host transition window). Localhost dev preview is + // allowed: resolveSurface returns 'cp' there too when ?preview=cp or + // ?preview=admin is set. + if (surface === 'marketing' && path.startsWith('/owner/')) { return new Response('Not Found', { status: 404 }); } diff --git a/src/lib/components/AboutPage.svelte b/src/lib/components/AboutPage.svelte index 4a93401..7730de0 100644 --- a/src/lib/components/AboutPage.svelte +++ b/src/lib/components/AboutPage.svelte @@ -2,8 +2,8 @@ import { accordion } from '$lib/actions/accordion'; import { reveal } from '$lib/actions/reveal'; import CtaCard from '$lib/components/CtaCard.svelte'; - import PageHeader from '$lib/components/PageHeader.svelte'; import Icon from '$lib/components/Icon.svelte'; + import ServiceHero from '$lib/components/ServiceHero.svelte'; import { getEnhancedImage } from '$lib/enhanced-images'; import type { AboutPageContent } from '$lib/types'; @@ -11,33 +11,25 @@ $: standardSections = pageContent.sections.filter((s) => s.accent !== 'founder'); $: founderSection = pageContent.sections.find((s) => s.accent === 'founder') ?? null; + const heroChips = [ + { icon: 'fas fa-star', label: '30+ five-star Google reviews' }, + { icon: 'fas fa-location-dot', label: 'Auckland Central' }, + { icon: 'fas fa-paw', label: 'Small dog specialists' } + ]; const founderHeadingLead = 'Meet Aless,'; const founderHeadingHighlight = 'the heart of Goodwalk';
- - - -
- - - 30+ five-star Google reviews - - Auckland Central - Small dog specialists -
-
+ imageUrl="/images/about-good-walk.webp" + imageAlt="Goodwalk dogs gathered together in the back of the car before a walk" + chips={heroChips} + cta={{ label: 'Book a Meet & Greet', href: '/contact-us', variant: 'yellow' }} + /> {#each standardSections as section} @@ -50,7 +42,7 @@
{#if section.eyebrow} - {section.eyebrow} + {section.eyebrow} {/if}

{section.title}

{#each section.body as paragraph} @@ -93,7 +85,7 @@
{#if founderSection.eyebrow} - {founderSection.eyebrow} + {founderSection.eyebrow} {/if}

@@ -144,7 +136,7 @@
- FAQ + FAQ

{pageContent.faqTitle ?? 'Common questions'}

@@ -187,21 +179,6 @@ .about-eyebrow { display: inline-block; margin-bottom: 14px; - padding: 7px 12px; - border-radius: 999px; - background: rgba(33, 48, 33, 0.08); - color: var(--gw-green); - box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05); - font-size: 12px; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; - } - - .about-chip-stars { - color: var(--yellow); - letter-spacing: 1px; - font-size: 13px; } /* ── Standard sections ── */ diff --git a/src/lib/components/BookingPage.svelte b/src/lib/components/BookingPage.svelte index e2a2cb4..8895bc3 100644 --- a/src/lib/components/BookingPage.svelte +++ b/src/lib/components/BookingPage.svelte @@ -2,7 +2,7 @@ import BookingWizard from '$lib/components/BookingWizard.svelte'; import Icon from '$lib/components/Icon.svelte'; import InfoSection from '$lib/components/InfoSection.svelte'; - import PageHeader from '$lib/components/PageHeader.svelte'; + import ServiceHero from '$lib/components/ServiceHero.svelte'; import type { BookingContent, InfoContent } from '$lib/types'; export let booking: BookingContent; @@ -18,12 +18,22 @@
- -
+ imageUrl="/images/happy-customer-anna.webp" + imageAlt="Happy Goodwalk customer Anna with her dog in Auckland" + chips={[ + { icon: 'fas fa-bolt', label: 'Reply within 24 hours' }, + { icon: 'fas fa-handshake', label: 'Free Meet & Greet' }, + { icon: 'fas fa-location-dot', label: 'Auckland Central' } + ]} + cta={{ label: 'Start your enquiry', href: '#newlead', variant: 'yellow' }} + /> + + @@ -44,13 +54,16 @@ background: var(--off-white); } + .booking-page-contact-strip { + padding: 18px 0 0; + } + .booking-page-contact { display: flex; align-items: center; justify-content: center; gap: 24px; flex-wrap: wrap; - margin-top: 28px; } .booking-contact-link { @@ -59,19 +72,20 @@ gap: 8px; padding: 10px 20px; border-radius: 999px; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(17, 20, 24, 0.08); font-family: var(--font-head); font-size: 14px; font-weight: 700; line-height: 1.2; letter-spacing: 0.01em; - color: #fff; - transition: background 0.2s; + color: var(--text-heading); + transition: background 0.2s, transform 0.2s; } .booking-contact-link:hover { - background: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.95); + transform: translateY(-1px); } @media (max-width: 768px) { diff --git a/src/lib/components/BookingWizard.svelte b/src/lib/components/BookingWizard.svelte index 301d730..305f366 100644 --- a/src/lib/components/BookingWizard.svelte +++ b/src/lib/components/BookingWizard.svelte @@ -10,6 +10,7 @@ export let booking: BookingContent; export let pagePath = ''; + $: isCompactContactPage = pagePath === '/contact-us'; const defaultServices = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Other Services']; $: serviceOptions = booking.serviceOptions && booking.serviceOptions.length > 0 @@ -204,6 +205,22 @@ return Object.keys(next).length === 0; } + function validateCompactContactForm(): boolean { + const next: Record = {}; + if (!petName.trim()) next.petName = "Please tell us your dog's name."; + if (selectedServices.length === 0) next.services = 'Pick at least one service.'; + if (message.trim().length < 10) { + next.message = 'Tell us a little about your dog so we can prepare properly.'; + } + if (!fullName.trim()) next.fullName = 'Please enter your full name'; + const emailErr = validateEmail(email); + if (emailErr) next.email = emailErr; + if (!phone.trim()) next.phone = 'Please enter your phone number'; + if (!location.trim()) next.location = 'Please enter your suburb'; + errors = next; + return Object.keys(next).length === 0; + } + function goNext() { noteInteraction(); if (!validateStep(step)) return; @@ -222,7 +239,9 @@ async function handleSubmit() { noteInteraction(); - if (!validateStep(2)) return; + if (isCompactContactPage) { + if (!validateCompactContactForm()) return; + } else if (!validateStep(2)) return; submitting = true; sendClickedAt = Date.now(); @@ -252,7 +271,7 @@ journey, referrer: typeof document !== 'undefined' ? document.referrer : '', page: typeof window !== 'undefined' ? window.location.href : '', - variant: 'booking-wizard' + variant: isCompactContactPage ? 'contact-compact' : 'booking-wizard' }) }); @@ -276,7 +295,7 @@
-
+
{#if submitted && SuccessModalComponent} {/if} -
- Free Meet & Greet -

- {headingParts.plain} - {#if headingParts.highlight} - {' '} - {headingParts.highlight} - {/if} -

-

{leadCopy}

-
- -
- -
- = 1} class:done={step > 1}> - 1 - {booking.dogStepLabel || 'Your dog'} - - 1} aria-hidden="true"> - - 2 - {booking.ownerStepLabel || 'Your details'} - -
+
+ +
+

{trustTitle}

+ {trustNote} +
+
+ +
+ = 1} class:done={step > 1}> + 1 + {booking.dogStepLabel || 'Your dog'} + + 1} aria-hidden="true"> + + 2 + {booking.ownerStepLabel || 'Your details'} + +
+ {/if}
-
+
{#key step} -
- {#if step === 1} +
+ {#if isCompactContactPage} + Start here +

Tell us about your dog

+

A few details now. We come back with the right next step.

+ + + +
+ +  Which service are you interested in? + +
+ {#each serviceOptions as service} + {@const checked = selectedServices.includes(service)} + + {/each} +
+ {#if errors.services} + + + {errors.services} + + {/if} +
+ +
+ + + + + + + +
+ + + +
+ +
+ {:else if step === 1} Step one of two

Tell us about your dog

Just the basics. Pick everything you are open to.

@@ -556,7 +756,7 @@
-

+

A real reply within 24 hours, usually sooner.

@@ -575,6 +775,11 @@ margin: 0 auto; } + .wiz-inner--compact { + max-width: 60rem; + margin-top: 8px; + } + .wiz-header { text-align: center; margin-bottom: 28px; @@ -780,12 +985,21 @@ box-shadow: 0 30px 60px rgba(var(--ink-rgb), 0.08); } + .wiz-card--compact { + max-width: 52rem; + padding: clamp(22px, 3vw, 32px); + } + .wiz-step { display: flex; flex-direction: column; gap: 18px; } + .wiz-step--compact { + gap: 16px; + } + .wiz-step-eyebrow { align-self: flex-start; padding: 6px 12px; @@ -831,19 +1045,6 @@ color: var(--text-heading); } - .wiz-optional { - margin-left: 6px; - padding: 2px 8px; - border-radius: 999px; - background: rgba(var(--brand-rgb), 0.06); - color: var(--text-subtle); - font-family: var(--font-body); - font-weight: 500; - font-size: 11px; - letter-spacing: 0.01em; - text-transform: uppercase; - } - .wiz-field input, .wiz-field textarea { width: 100%; @@ -942,6 +1143,10 @@ border-color: rgba(var(--brand-rgb), 0.32); } + .wiz-service--compact { + padding: 12px 14px; + } + .wiz-service.active { border-color: var(--gw-green); background: rgba(var(--accent-rgb), 0.1); @@ -1022,6 +1227,11 @@ background: oklch(0.88 0.18 95); } + .wiz-btn-primary--wide { + width: 100%; + justify-content: center; + } + .wiz-btn-back { background: transparent; color: var(--text-subtle); @@ -1041,6 +1251,10 @@ text-align: center; } + .wiz-reassurance--compact { + margin-top: 14px; + } + .wiz-reassurance :global(.icon) { color: var(--yellow); font-size: 13px; @@ -1056,6 +1270,10 @@ padding-left: var(--space-container-x-mobile); padding-right: var(--space-container-x-mobile); } + + .wiz-inner--compact { + margin-top: 0; + } } @media (max-width: 640px) { @@ -1085,6 +1303,24 @@ grid-template-columns: 1fr; } + .wiz-service-grid--compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .wiz-service--compact { + min-height: 100%; + padding: 12px; + } + + .wiz-service--compact .wiz-service-desc { + display: none; + } + + .wiz-card--compact { + padding: 20px 18px; + border-radius: 22px; + } + .wiz-grid-two { grid-template-columns: 1fr; } diff --git a/src/lib/components/ContractPage.svelte b/src/lib/components/ContractPage.svelte index 003976e..d991bc3 100644 --- a/src/lib/components/ContractPage.svelte +++ b/src/lib/components/ContractPage.svelte @@ -387,7 +387,7 @@ content="Sign your Goodwalk service agreement online." /> - +
diff --git a/src/lib/components/FounderStorySection.svelte b/src/lib/components/FounderStorySection.svelte index bcb0b97..d0432ca 100644 --- a/src/lib/components/FounderStorySection.svelte +++ b/src/lib/components/FounderStorySection.svelte @@ -139,13 +139,10 @@ margin-bottom: 18px; } + /* Layout-only override; typography and colour live on the shared + .eyebrow utility. */ .founder-kicker { display: inline-block; - color: var(--text-subtle); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; } .founder-greeting { diff --git a/src/lib/components/HowItWorksSection.svelte b/src/lib/components/HowItWorksSection.svelte index 2fa55da..97907ac 100644 --- a/src/lib/components/HowItWorksSection.svelte +++ b/src/lib/components/HowItWorksSection.svelte @@ -8,7 +8,7 @@ const journeyChips = [ { icon: 'fas fa-handshake', label: 'Free Meet & Greet' }, { icon: 'fas fa-clipboard-check', label: 'Assessment walks' }, - { icon: 'fas fa-calendar-check', label: 'A regular weekly rhythm' } + { icon: 'fas fa-calendar-check', label: 'Weekly rhythm' } ]; @@ -59,7 +59,7 @@ Book your free Meet & Greet -

Free, no-obligation. We reply within 24 hours.

+

No obligation. We reply within 24 hours.

@@ -74,7 +74,7 @@ .hiw-inner { max-width: var(--max-w); margin: 0 auto; - padding: 0 50px; + padding: 0 var(--space-container-x); } /* ── Header ── */ @@ -82,11 +82,9 @@ margin-bottom: 36px; } + /* Layout-only override; typography lives on the shared .eyebrow utility. */ .hiw-eyebrow { - padding: 7px 12px; - border-radius: 999px; - background: rgba(33, 48, 33, 0.08); - box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05); + display: inline-block; } .hiw-intro { @@ -103,14 +101,22 @@ margin-top: 28px; } + /* Connector tracks through the vertical centre of the phase pills + (card padding 40px + half pill height ~12px). Reads as a timeline + running through the three steps, not a floating decorative rule. */ .hiw-steps::before { content: ''; position: absolute; - top: 32px; + top: 52px; left: 13%; right: 13%; height: 1px; - background: linear-gradient(90deg, rgba(33, 48, 33, 0.16), rgba(242, 191, 47, 0.4), rgba(33, 48, 33, 0.16)); + background: linear-gradient( + 90deg, + rgba(var(--brand-rgb), 0.16), + rgba(var(--accent-rgb), 0.4), + rgba(var(--brand-rgb), 0.16) + ); pointer-events: none; } @@ -119,12 +125,12 @@ flex-direction: column; align-items: center; text-align: center; - padding: 40px 40px 36px; + padding: clamp(28px, 2.6vw, 40px) clamp(24px, 2.6vw, 40px) clamp(26px, 2.4vw, 36px); background: - radial-gradient(circle at top center, rgba(255, 209, 0, 0.12), transparent 34%), - #fff; - border: 1px solid rgba(17, 20, 24, 0.06); - box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04); + radial-gradient(circle at top center, rgba(var(--accent-rgb), 0.12), transparent 34%), + var(--surface-panel); + border: 1px solid var(--border-soft-strong); + box-shadow: var(--shadow-card); transition: box-shadow 0.22s ease, transform 0.18s cubic-bezier(0.22, 1, 0.36, 1); border-radius: 28px; overflow: hidden; @@ -133,7 +139,7 @@ @media (hover: hover) { .hiw-step:hover { - box-shadow: 0 16px 40px rgba(17, 20, 24, 0.09); + box-shadow: var(--shadow-xl); transform: translateY(-4px); z-index: 1; } @@ -151,9 +157,9 @@ .hiw-phase { display: inline-block; padding: 5px 13px; - border-radius: 999px; + border-radius: var(--radius-pill); background: var(--yellow); - color: #000; + color: var(--gw-green); font-family: var(--font-head); font-size: 11px; font-weight: 800; @@ -165,7 +171,7 @@ font-family: var(--font-head); font-size: 13px; font-weight: 700; - color: rgba(33, 48, 33, 0.28); + color: rgba(var(--brand-rgb), 0.28); letter-spacing: 0.04em; } @@ -178,8 +184,8 @@ height: 64px; margin-bottom: 22px; border-radius: 20px; - background: var(--gw-green); - box-shadow: 0 10px 28px rgba(33, 48, 33, 0.2); + background: var(--surface-brand); + box-shadow: var(--shadow-badge); } .hiw-icon-wrap :global(.hiw-step-icon) { @@ -194,12 +200,12 @@ font-size: var(--heading-card-size); font-weight: 700; line-height: 1.2; - color: #0d1a0d; + color: var(--text-heading); } .hiw-body { margin: 0 0 20px; - color: #4c5056; + color: var(--text-muted); font-size: 15px; line-height: 1.65; flex: 1; @@ -210,8 +216,8 @@ align-items: center; gap: 7px; padding: 7px 14px; - border-radius: 999px; - background: rgba(33, 48, 33, 0.07); + border-radius: var(--radius-pill); + background: var(--surface-brand-muted); color: var(--gw-green); font-size: 13px; font-weight: 700; @@ -234,7 +240,7 @@ .hiw-cta-note { margin: 0; - color: #888; + color: var(--text-softest); font-size: 13px; } @@ -253,12 +259,12 @@ gap: 8px; min-height: 44px; padding: 0 16px; - border-radius: 999px; - background: var(--gw-green); + border-radius: var(--radius-pill); + background: var(--surface-brand); box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.04), - 0 10px 22px rgba(17, 20, 24, 0.06); - color: #fff; + var(--shadow-inset-inverse), + 0 10px 22px rgba(var(--ink-rgb), 0.06); + color: var(--text-inverse); font-family: var(--font-head); font-size: 11px; font-weight: 700; @@ -310,8 +316,8 @@ align-items: flex-start; text-align: left; padding: 28px 24px; - border-radius: 24px; - border: 1px solid rgba(17, 20, 24, 0.06); + border-radius: var(--radius-xl); + border: 1px solid var(--border-soft-strong); } .hiw-step-meta { diff --git a/src/lib/components/LocationPage.svelte b/src/lib/components/LocationPage.svelte index a8b0bc5..c079209 100644 --- a/src/lib/components/LocationPage.svelte +++ b/src/lib/components/LocationPage.svelte @@ -57,7 +57,7 @@
- Auckland Central Dog Walking + Auckland Central Dog Walking

Dog walkers in {location.suburb}

{location.intro}

@@ -103,7 +103,7 @@
- Where we walk + Where we walk

Parks & walks in {location.suburb}

These are the parks and routes we know well in {location.suburb}. Every walk is planned around your dog's pace, size, and temperament — not just the nearest green space. @@ -128,7 +128,7 @@