General enquries feature
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG APP_VERSION=4.0.1
|
||||
ARG APP_VERSION=4.0.2
|
||||
|
||||
FROM python:3.12-slim
|
||||
ARG APP_VERSION
|
||||
|
||||
Binary file not shown.
+172
-58
@@ -89,6 +89,7 @@ def _load_config() -> dict:
|
||||
"owner_email": os.environ["OWNER_EMAIL"],
|
||||
"from_email": os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>"),
|
||||
"reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"),
|
||||
"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 <strong style=\"color:#213021;\">Meet & Greet</strong> with you and "
|
||||
f"{data.petName}."
|
||||
)
|
||||
next_steps_html = (
|
||||
"We will review your details and reach out within 1 business day "
|
||||
"to schedule a free Meet & Greet. No commitment required — just a "
|
||||
f"chance for {data.petName} to make a new best friend."
|
||||
)
|
||||
logo_subtitle = "Professional dog walking services"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -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)}
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
@@ -392,9 +513,7 @@ def client_email(data: BookingSubmission) -> str:
|
||||
</h1>
|
||||
<p style="margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:16px;color:#555;line-height:1.65;">
|
||||
We’ve received your enquiry and we will be in touch shortly to arrange
|
||||
a <strong style="color:#213021;">Meet & Greet</strong> with you and
|
||||
{data.petName}.
|
||||
{intro_html}
|
||||
</p>
|
||||
|
||||
<!-- Details card -->
|
||||
@@ -408,13 +527,7 @@ def client_email(data: BookingSubmission) -> str:
|
||||
Your enquiry summary
|
||||
</div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{_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)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -431,9 +544,7 @@ def client_email(data: BookingSubmission) -> str:
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#666;line-height:1.6;">
|
||||
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}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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"""
|
||||
<tr>
|
||||
<td colspan="2" style="padding:16px 0 0;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
||||
text-transform:uppercase;margin-bottom:8px;">About the dog</div>
|
||||
text-transform:uppercase;margin-bottom:8px;">{message_label}</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
||||
border-radius:8px;padding:14px 16px;">{data.message}</div>
|
||||
@@ -490,7 +604,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
padding:10px 28px;">
|
||||
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:18px;font-weight:700;color:#213021;">
|
||||
📩 New lead!
|
||||
📩 New enquiry!
|
||||
</span>
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
@@ -500,13 +614,29 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
|
||||
referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark")
|
||||
page_row = _meta_row("Page", data.page) if data.page else ""
|
||||
detail_heading = "Enquiry details" if is_general else "Dog & services"
|
||||
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
|
||||
|
||||
if is_general:
|
||||
if data.petName:
|
||||
detail_rows.append(_detail_row("Dog", data.petName))
|
||||
if data.location:
|
||||
detail_rows.append(_detail_row("Location", data.location))
|
||||
else:
|
||||
detail_rows.extend(
|
||||
[
|
||||
_detail_row("Dog", data.petName),
|
||||
_detail_row("Location", data.location),
|
||||
_detail_row("Services", services_text),
|
||||
]
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>New GoodWalk Lead</title>
|
||||
<title>{email_title}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||||
|
||||
@@ -597,36 +727,13 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
<!-- Dog & service details -->
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
||||
text-transform:uppercase;margin-bottom:16px;">Dog & services</div>
|
||||
text-transform:uppercase;margin-bottom:16px;">{detail_heading}</div>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
||||
<tr><td style="padding:24px 28px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Dog</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
|
||||
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
|
||||
'Segoe UI',sans-serif;vertical-align:top;">{data.petName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Location</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;font-weight:500;color:#213021;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">{data.location}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Services</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;color:#444;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">{services_text}</td>
|
||||
</tr>
|
||||
{"".join(detail_rows)}
|
||||
{message_block}
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -681,7 +788,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
border-top:1px solid #e8e8e4;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:12px;color:#bbb;">
|
||||
Sent automatically by GoodWalk booking form
|
||||
Sent automatically by GoodWalk enquiry form
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user