diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 160300b..3228ece 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -70,7 +70,8 @@ mkdir -p /docker/goodwalk-svelte It is created from [deploy.env.template](deploy.env.template). Current template contents: ```env -APP_VERSION=4.0.1 +APP_VERSION=4.0.2 +ENABLE_GENERAL_ENQUIRIES=false TZ=Pacific/Auckland POSTGRES_DB=goodwalk diff --git a/Dockerfile b/Dockerfile index 4092e83..e42df7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG APP_VERSION=4.0.1 +ARG APP_VERSION=4.0.2 FROM node:22-alpine AS builder ARG APP_VERSION diff --git a/deploy.env.template b/deploy.env.template index c886e1c..eaf9bf9 100644 --- a/deploy.env.template +++ b/deploy.env.template @@ -1,4 +1,4 @@ -APP_VERSION=4.0.1 +APP_VERSION=4.0.2 TZ=Pacific/Auckland POSTGRES_DB=goodwalk @@ -10,6 +10,7 @@ RESEND_API_KEY=re_hcDByLp8_HEBW93wDirr7o9g16FgCeYNF OWNER_EMAIL=mattcohen0@gmail.com FROM_EMAIL=GoodWalk REPLY_TO=info@goodwalk.co.nz +ENABLE_GENERAL_ENQUIRIES=false FORM_MIN_SECONDS=4 FORM_MAX_SECONDS=7200 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3b80592..1bb821c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,13 +3,14 @@ services: build: context: . args: - APP_VERSION: ${APP_VERSION:-4.0.1} + APP_VERSION: ${APP_VERSION:-4.0.2} container_name: goodwalk_svelte_app environment: - APP_VERSION: ${APP_VERSION:-4.0.1} + APP_VERSION: ${APP_VERSION:-4.0.2} DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk} NODE_ENV: production PORT: 3000 + ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false} TZ: ${TZ:-Pacific/Auckland} depends_on: - db @@ -24,14 +25,15 @@ services: build: context: ./mail-api args: - APP_VERSION: ${APP_VERSION:-4.0.1} + APP_VERSION: ${APP_VERSION:-4.0.2} container_name: goodwalk_svelte_mail_api environment: - APP_VERSION: ${APP_VERSION:-4.0.1} + APP_VERSION: ${APP_VERSION:-4.0.2} RESEND_API_KEY: ${RESEND_API_KEY} OWNER_EMAIL: ${OWNER_EMAIL} FROM_EMAIL: ${FROM_EMAIL:-GoodWalk } REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz} + ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false} FORM_MIN_SECONDS: ${FORM_MIN_SECONDS:-4} FORM_MAX_SECONDS: ${FORM_MAX_SECONDS:-7200} RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-900} diff --git a/docker-compose.yml b/docker-compose.yml index 9a763d6..140b075 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,12 +3,13 @@ services: build: context: . args: - APP_VERSION: ${APP_VERSION:-4.0.1} + APP_VERSION: ${APP_VERSION:-4.0.2} environment: - APP_VERSION: ${APP_VERSION:-4.0.1} + APP_VERSION: ${APP_VERSION:-4.0.2} DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk} NODE_ENV: production PORT: ${APP_PORT:-3000} + ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false} TZ: ${TZ:-Pacific/Auckland} depends_on: - db @@ -18,13 +19,14 @@ services: build: context: ./mail-api args: - APP_VERSION: ${APP_VERSION:-4.0.1} + APP_VERSION: ${APP_VERSION:-4.0.2} environment: - APP_VERSION: ${APP_VERSION:-4.0.1} + APP_VERSION: ${APP_VERSION:-4.0.2} RESEND_API_KEY: ${RESEND_API_KEY} OWNER_EMAIL: ${OWNER_EMAIL} FROM_EMAIL: ${FROM_EMAIL:-GoodWalk } REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz} + ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false} TZ: ${TZ:-Pacific/Auckland} restart: unless-stopped diff --git a/mail-api/Dockerfile b/mail-api/Dockerfile index d94b1d4..37187cf 100644 --- a/mail-api/Dockerfile +++ b/mail-api/Dockerfile @@ -1,4 +1,4 @@ -ARG APP_VERSION=4.0.1 +ARG APP_VERSION=4.0.2 FROM python:3.12-slim ARG APP_VERSION diff --git a/mail-api/__pycache__/main.cpython-314.pyc b/mail-api/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..33503fc Binary files /dev/null and b/mail-api/__pycache__/main.cpython-314.pyc differ diff --git a/mail-api/main.py b/mail-api/main.py index 73000d8..8dee5c5 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -89,6 +89,7 @@ def _load_config() -> dict: "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"), + "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"))), @@ -105,6 +106,7 @@ resend.api_key = _config["resend_api_key"] OWNER_EMAIL = _config["owner_email"] 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"] @@ -116,12 +118,13 @@ 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 max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss", + "Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%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", APP_VERSION, os.environ.get("TZ", "system-default"), FROM_EMAIL, REPLY_TO, OWNER_EMAIL, + ENABLE_GENERAL_ENQUIRIES, MAX_SEND_ATTEMPTS, FORM_MIN_SECONDS, FORM_MAX_SECONDS, @@ -165,11 +168,12 @@ async def _request_logging_middleware(request: Request, call_next): class BookingSubmission(BaseModel): + enquiryType: str = "booking" fullName: str email: EmailStr phone: str - petName: str - location: str + petName: str = "" + location: str = "" message: str = "" services: list[str] = [] website: str = "" @@ -300,6 +304,85 @@ def _is_honeypot_triggered(data: BookingSubmission) -> bool: return bool(_trimmed(data.website)) +def _is_general_enquiry(data: BookingSubmission) -> bool: + return _trimmed(data.enquiryType).lower() == "general" + + +def _enquiry_type_label(data: BookingSubmission) -> str: + return "General enquiry" if _is_general_enquiry(data) else "Booking enquiry" + + +def _validate_submission(request_id: str, data: BookingSubmission) -> None: + enquiry_type = _trimmed(data.enquiryType).lower() + + if enquiry_type not in {"booking", "general"}: + logger.warning("[%s] rejected: invalid enquiryType=%r", request_id, data.enquiryType) + raise HTTPException( + status_code=400, + detail="Please choose a valid enquiry type and try again.", + ) + + if not _trimmed(data.fullName): + logger.warning("[%s] rejected: missing full name", request_id) + raise HTTPException( + status_code=400, + detail="Please enter your full name.", + ) + + if not _trimmed(data.phone): + logger.warning("[%s] rejected: missing phone number", request_id) + raise HTTPException( + status_code=400, + detail="Please enter your contact number.", + ) + + if _is_general_enquiry(data): + if not ENABLE_GENERAL_ENQUIRIES: + logger.warning("[%s] rejected: general enquiries are disabled", request_id) + raise HTTPException( + status_code=403, + detail="General enquiries are currently unavailable through this form.", + ) + if not _trimmed(data.message): + logger.warning("[%s] rejected: missing general enquiry message", request_id) + raise HTTPException( + status_code=400, + detail="Please tell us how we can help.", + ) + return + + if not _trimmed(data.petName): + logger.warning("[%s] rejected: missing pet name", request_id) + raise HTTPException( + status_code=400, + detail="Please enter your dog's name.", + ) + + if not _trimmed(data.location): + logger.warning("[%s] rejected: missing location", request_id) + raise HTTPException( + status_code=400, + detail="Please enter your location.", + ) + + +def _normalize_submission(data: BookingSubmission) -> None: + data.enquiryType = "general" if _is_general_enquiry(data) else "booking" + data.fullName = _trimmed(data.fullName) + data.phone = _trimmed(data.phone) + data.petName = _trimmed(data.petName) + data.location = _trimmed(data.location) + data.message = _trimmed(data.message) + data.referrer = _trimmed(data.referrer) + data.page = _trimmed(data.page) + data.services = [_trimmed(service) for service in data.services if _trimmed(service)] + + if _is_general_enquiry(data): + data.petName = "" + data.location = "" + data.services = [] + + def _parse_ua(ua: str) -> str: if not ua: return "Unknown" @@ -360,8 +443,46 @@ def _logo_header(badge_html: str = "", subtitle: str = "") -> str: def client_email(data: BookingSubmission) -> str: + is_general = _is_general_enquiry(data) services_text = ", ".join(data.services) if data.services else "Not specified" - message_row = _detail_row("About the dog", data.message) if data.message else "" + enquiry_summary_rows = [ + _detail_row("Your name", data.fullName), + _detail_row("Email", str(data.email)), + _detail_row("Phone", data.phone), + _detail_row("Type", _enquiry_type_label(data)), + ] + + if is_general: + if data.message: + enquiry_summary_rows.append(_detail_row("Message", data.message)) + intro_html = ( + "We’ve received your message and we will be in touch shortly." + ) + next_steps_html = ( + "We will review your message and reply within 1 business day." + ) + logo_subtitle = "General enquiries and dog walking support" + else: + enquiry_summary_rows.extend( + [ + _detail_row("Dog’s name", data.petName), + _detail_row("Location", data.location), + _detail_row("Services", services_text), + ] + ) + if data.message: + enquiry_summary_rows.append(_detail_row("About the dog", data.message)) + intro_html = ( + "We’ve received your enquiry and we will be in touch shortly to arrange " + "a Meet & Greet with you and " + f"{data.petName}." + ) + next_steps_html = ( + "We will review your details and reach out within 1 business day " + "to schedule a free Meet & Greet. No commitment required — just a " + f"chance for {data.petName} to make a new best friend." + ) + logo_subtitle = "Professional dog walking services" return f""" @@ -380,7 +501,7 @@ def client_email(data: BookingSubmission) -> str: style="max-width:600px;width:100%;border-radius:16px;overflow:hidden; box-shadow:0 4px 24px rgba(0,0,0,0.08);"> - {_logo_header(subtitle="Professional dog walking services")} + {_logo_header(subtitle=logo_subtitle)} @@ -392,9 +513,7 @@ def client_email(data: BookingSubmission) -> str:

- We’ve received your enquiry and we will be in touch shortly to arrange - a Meet & Greet with you and - {data.petName}. + {intro_html}

@@ -408,13 +527,7 @@ def client_email(data: BookingSubmission) -> str: Your enquiry summary - {_detail_row("Your name", data.fullName)} - {_detail_row("Email", data.email)} - {_detail_row("Phone", data.phone)} - {_detail_row("Dog’s name", data.petName)} - {_detail_row("Location", data.location)} - {_detail_row("Services", services_text)} - {message_row} + {"".join(enquiry_summary_rows)}
@@ -431,9 +544,7 @@ def client_email(data: BookingSubmission) -> str:
- We will review your details and reach out within 1 business days - to schedule a free Meet & Greet. No commitment required — just a - chance for {data.petName} to make a new best friend. + {next_steps_html}
@@ -469,17 +580,20 @@ def client_email(data: BookingSubmission) -> str: def owner_email(data: BookingSubmission, ip: str, browser: str) -> str: + is_general = _is_general_enquiry(data) services_text = ", ".join(data.services) if data.services else "—" now = datetime.now() submitted_at = now.strftime("%d %b %Y at %I:%M %p").lstrip("0") first_name = data.fullName.split()[0] if data.fullName.strip() else "them" + email_title = "New GoodWalk Enquiry" if is_general else "New GoodWalk Lead" + message_label = "Message" if is_general else "About the dog" message_block = f"""
About the dog
+ text-transform:uppercase;margin-bottom:8px;">{message_label}
{data.message}
@@ -490,7 +604,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str: padding:10px 28px;"> - 📩  New lead! + 📩  New enquiry!
- New GoodWalk Lead + {email_title} @@ -597,36 +727,13 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
Dog & services
+ text-transform:uppercase;margin-bottom:16px;">{detail_heading}
@@ -681,7 +788,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str: border-top:1px solid #e8e8e4;">
- Sent automatically by GoodWalk booking form + Sent automatically by GoodWalk enquiry form
@@ -753,15 +860,6 @@ async def submit_booking(data: BookingSubmission, request: Request): ip = _get_ip(request) browser = _parse_ua(request.headers.get("user-agent", "")) - name_parts = data.fullName.strip().split() - first_name = name_parts[0] if name_parts else "there" - - logger.info( - "[%s] /submit: email=%s ip=%s browser=%r dog=%s services=%s page=%r", - request_id, data.email, ip, browser, data.petName, data.services, data.page, - ) - logger.debug("[%s] full payload: %s", request_id, data.model_dump()) - await _enforce_submit_rate_limits(request_id, ip, str(data.email)) _enforce_form_timing(request_id, data) @@ -779,6 +877,18 @@ async def submit_booking(data: BookingSubmission, request: Request): "ignored": True, } + _validate_submission(request_id, data) + _normalize_submission(data) + + name_parts = data.fullName.split() + first_name = name_parts[0] if name_parts else "there" + + logger.info( + "[%s] /submit: type=%s email=%s ip=%s browser=%r dog=%s services=%s page=%r", + request_id, data.enquiryType, data.email, ip, browser, data.petName, data.services, data.page, + ) + logger.debug("[%s] full payload: %s", request_id, data.model_dump()) + failures: list[dict] = [] try: @@ -787,7 +897,7 @@ async def submit_booking(data: BookingSubmission, request: Request): "from": FROM_EMAIL, "to": [data.email], "reply_to": REPLY_TO, - "subject": f"We received your enquiry, {first_name}! 🐾", + "subject": f"We received your {'general enquiry' if _is_general_enquiry(data) else 'enquiry'}, {first_name}! 🐾", "html": client_email(data), }, label="client_email", @@ -807,7 +917,11 @@ async def submit_booking(data: BookingSubmission, request: Request): "from": FROM_EMAIL, "to": [OWNER_EMAIL], "reply_to": data.email, - "subject": f"New GoodWalk lead — {data.fullName} ({data.petName})", + "subject": ( + f"New GoodWalk general enquiry — {data.fullName}" + if _is_general_enquiry(data) + else f"New GoodWalk lead — {data.fullName} ({data.petName})" + ), "html": owner_email(data, ip, browser), }, label="owner_email", diff --git a/package-lock.json b/package-lock.json index 208bcfa..fa64b70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodwalk-svelte-port", - "version": "4.0.1", + "version": "4.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodwalk-svelte-port", - "version": "4.0.1", + "version": "4.0.2", "dependencies": { "canvas-confetti": "^1.9.4", "pg": "^8.13.1" diff --git a/package.json b/package.json index 2116bf5..d67f385 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodwalk-svelte-port", - "version": "4.0.1", + "version": "4.0.2", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/BookingPage.svelte b/src/lib/components/BookingPage.svelte index 2d2bf20..bf5480e 100644 --- a/src/lib/components/BookingPage.svelte +++ b/src/lib/components/BookingPage.svelte @@ -4,6 +4,7 @@ import type { BookingContent } from '$lib/types'; export let booking: BookingContent; + export let allowGeneralEnquiry = false; const email = 'info@goodwalk.co.nz'; const phone = '(022) 642 1011'; @@ -12,8 +13,14 @@
-

Book a Meet & Greet

-

Fill in the form below and we'll be in touch to arrange a free introduction.

+

Contact Us

+

+ {#if allowGeneralEnquiry} + Fill in the form below to book a Meet & Greet or send a general enquiry. + {:else} + Fill in the form below and we'll be in touch to arrange a free introduction. + {/if} +

- +
- - - - - - - - - - - - + {"".join(detail_rows)} {message_block}
Dog{data.petName}
Location{data.location}
Services{services_text}