From a7f8a619b11d34de17db0ff8353654fd30fd8220 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Tue, 19 May 2026 23:36:58 +1200 Subject: [PATCH] v4.0.0.1 --- .claude/settings.local.json | 3 +- DEPLOYMENT.md | 56 +- Dockerfile | 2 +- data/drafts.json | 57 + deploy.env.template | 11 +- deploy.ps1 | 18 +- docker-compose.prod.yml | 21 +- docker-compose.yml | 8 +- logs/mail-api.log | 93 ++ mail-api/Dockerfile | 16 +- mail-api/__pycache__/db.cpython-314.pyc | Bin 8668 -> 19061 bytes mail-api/__pycache__/main.cpython-314.pyc | Bin 211108 -> 224363 bytes mail-api/data/client_profiles.json | 4 +- mail-api/db.py | 177 +++ mail-api/logs/mail-api.log | 59 + mail-api/mail_api/__init__.py | 1 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 202 bytes .../__pycache__/config.cpython-314.pyc | Bin 0 -> 14638 bytes .../__pycache__/models.cpython-314.pyc | Bin 0 -> 5281 bytes mail-api/mail_api/config.py | 257 +++ mail-api/mail_api/models.py | 117 ++ mail-api/main.py | 859 ++++++---- mail-api/requirements.txt | 1 + nginx/goodwalk.co.nz.svelte.conf.example | 60 +- nginx/nginx.conf | 101 +- package-lock.json | 4 +- package.json | 2 +- scripts/deploy-from-git.sh | 167 +- scripts/deploy-remote.sh | 167 +- src/hooks.server.ts | 25 +- src/lib/components/AboutPage.svelte | 53 +- src/lib/components/BookingPage.svelte | 38 +- src/lib/components/BookingWizard.svelte | 346 +++- src/lib/components/ContractPage.svelte | 2 +- src/lib/components/FounderStorySection.svelte | 7 +- src/lib/components/HowItWorksSection.svelte | 70 +- src/lib/components/LocationPage.svelte | 31 +- src/lib/components/MobileBookBar.svelte | 2 + src/lib/components/OnboardingAuth.svelte | 32 +- src/lib/components/OnboardingPage.svelte | 769 +++++++-- src/lib/components/PricingPage.svelte | 1404 ++++++++++------- src/lib/components/PricingPlanCard.svelte | 11 +- src/lib/components/ServiceHero.svelte | 1 + src/lib/components/ServiceLandingPage.svelte | 9 + src/lib/components/ServicesSection.svelte | 4 +- src/lib/components/TestimonialsPage.svelte | 163 +- src/lib/components/TestimonialsSection.svelte | 13 +- src/lib/components/ValuesSection.svelte | 74 +- .../admin-dashboard/AdminDashboard.svelte | 207 ++- src/lib/content/homepage.ts | 28 +- src/lib/content/our-pricing.ts | 68 +- src/lib/content/puppy-visits.ts | 1 + src/lib/server/surface.test.ts | 81 + src/lib/server/surface.ts | 86 + src/lib/styles/forms.css | 10 +- src/lib/styles/responsive.css | 2 - src/lib/styles/sections.css | 6 +- src/lib/styles/typography.css | 15 +- src/lib/types.ts | 10 + src/lib/utils/pricing.test.ts | 25 + src/lib/utils/pricing.ts | 7 +- src/routes/+page.server.ts | 16 +- src/routes/[slug]/slug-page.test.ts | 2 +- src/routes/contract/+page.server.ts | 15 +- src/routes/home-page.server.test.ts | 22 +- static/images/about-good-walk.webp | Bin 0 -> 214048 bytes static/images/happy-customer-anna.webp | Bin 0 -> 225346 bytes static/images/our-client-testimonials.webp | Bin 0 -> 335648 bytes 68 files changed, 4486 insertions(+), 1430 deletions(-) create mode 100644 data/drafts.json create mode 100644 mail-api/mail_api/__init__.py create mode 100644 mail-api/mail_api/__pycache__/__init__.cpython-314.pyc create mode 100644 mail-api/mail_api/__pycache__/config.cpython-314.pyc create mode 100644 mail-api/mail_api/__pycache__/models.cpython-314.pyc create mode 100644 mail-api/mail_api/config.py create mode 100644 mail-api/mail_api/models.py create mode 100644 src/lib/server/surface.test.ts create mode 100644 src/lib/server/surface.ts create mode 100644 src/lib/utils/pricing.test.ts create mode 100644 static/images/about-good-walk.webp create mode 100644 static/images/happy-customer-anna.webp create mode 100644 static/images/our-client-testimonials.webp 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 d702c20f7734d9b6b1f98a3b70d82dc02b9313ad..94231e0fd090b94c0652336fb14b7f916f96fb52 100644 GIT binary patch literal 19061 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 bHCEvxmA>_R*v`Wytk#ZVaRM8BipBp6Hpc#W delta 613 zcmYk3O=}ZD7{{O4O}3lFBu%Fu&*jyv{Jwe}_)g?B!6<4{RNO z^Y45P1AOJqzvcNW&}r#y_V=A}s7A)Y8nqMEDAQv@JyBJdjt%u>RX7h*uoaL%mjF_b zfvRBp(@?QZxGko^M7g+gf}RS6S@l+JgBY098k%j_YuhghDBG zsmVbFWqts|k~r$3g>;gP|VBKm=v_ zaH*$2GV?k-NYYR|fn=JE59q7;ePI)vv-vHt?D|b&Hi@k@>xS#m&ARP+ER)!-_esY# zi0k7TAEJqMfO@bFP@`s7K?tBIh$;gK7FVroG*(hAuz{$ zEOXqTBc*GC12`VVI=><2V=ynsAzWdG zAchhSFuL+Wqowa!#uDCOE>Dmnpt-xx;_MgtSE4A<&qvYdAn!w&pS{N-t=yXouSTF7 M@vWxCZVEB}0d%gO$^ZZW diff --git a/mail-api/__pycache__/main.cpython-314.pyc b/mail-api/__pycache__/main.cpython-314.pyc index acbfe4d808145dbf199f2a8dded449bed61fe2b8..55a85fade9a1d0c15fe02495399a258459758ccf 100644 GIT binary patch delta 65105 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 bg!aNqMc^4UV?IGF16D=_QZGtWa=rf#^hR%I delta 57227 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 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 0000000000000000000000000000000000000000..64cc6ce7afd287fef58762a3bbfb1f0a86a5cce8 GIT binary patch literal 202 zcmdPqemEDs?47{q}AMkwPmNJ#@j5JL$=5Ti1KCR3>+P{21aGe^NOz*C_h zF*!RiJyqdR!xV+$f}G3}h5S+=PE#n!NL9#5O;1d&R4C5OOV3HwP0P$lRme@u%+o8V z)brD1yu}_LpOT*(AHR~}Gsv=AB0$NU_{4%t{rLFIyv&mLc%aH#95%W6DWy57c13JJ XMIdJta{!4C%#4hTw;9}vSb!V=I1w{< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..817fde9fee23583da38bb785b8d7cd3638578949 GIT binary patch literal 14638 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 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 @@