This commit is contained in:
2026-05-26 08:30:08 +12:00
parent 005aab8139
commit 135a5a3b83
75 changed files with 22417 additions and 4288 deletions
+7
View File
@@ -26,4 +26,11 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY main.py db.py ./
COPY mail_api ./mail_api
# Legacy clients seed, generated by scripts/build-legacy-seed.mjs (called from
# deploy.ps1). Lives OUTSIDE the data volume so every deploy carries a fresh
# copy. The mail-api merges this on boot, add-only — it never overwrites a
# live entry. Must always be present in the build context; for local dev run
# `node scripts/build-legacy-seed.mjs` once, or `echo {} > legacy-clients-seed.json`.
COPY legacy-clients-seed.json /app/legacy-clients-seed.json
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1436,3 +1436,4 @@ resend.exceptions.ResendError: API key is invalid
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
19/05/2026 23:37:58 New Zealand Standard Time INFO mail-api: [e8c74fa0] GET /auth/verify → 401 (2ms)
+13
View File
@@ -211,6 +211,13 @@ _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"
# Legacy seed lives OUTSIDE the data volume — it's shipped in the image so a
# fresh deploy always carries the same baked-in copy. On boot the mail-api
# merges this dict into _client_profiles, adding any emails that aren't
# already present. It never overwrites a live entry.
LEGACY_SEED_FILE = Path(
os.environ.get("LEGACY_SEED_FILE", "/app/legacy-clients-seed.json")
)
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
@@ -235,6 +242,12 @@ 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 ""
# Shared secret presented by the deploy script's post-deploy form smoke tests.
# When set, requests carrying a matching X-Deploy-Smoke header short-circuit the
# form handlers before any email/db side effects so production submissions stay
# clean. When unset, the bypass is fully disabled.
DEPLOY_SMOKE_SECRET = (os.environ.get("DEPLOY_SMOKE_SECRET") or "").strip()
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,
+12 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, EmailStr
@@ -70,6 +70,17 @@ class BirthdayAutoSendRequest(BaseModel):
enabled: bool
# Client lifecycle status. Soft-delete only — every client stays on file so the
# history is preserved for future newsletter / retention work.
ClientLifecycleStatus = Literal["active", "paused", "cancelled", "archived"]
class ClientStatusUpdate(BaseModel):
email: EmailStr
status: ClientLifecycleStatus
reason: str = ""
class ContractSubmission(BaseSubmission):
address: str
dogName: str
+410 -1
View File
@@ -36,6 +36,7 @@ from mail_api.config import (
CLIENT_PROFILES_FILE as _CLIENT_PROFILES_FILE,
CORS_ALLOWED_ORIGINS,
CP_ADMIN_EMAILS,
DEPLOY_SMOKE_SECRET,
DEV_MODE,
DRAFTS_FILE as _DRAFTS_FILE,
EMAIL_SEND_TIMEOUT_SECONDS,
@@ -43,6 +44,7 @@ from mail_api.config import (
FORM_MAX_SECONDS,
FORM_MIN_SECONDS,
FROM_EMAIL,
LEGACY_SEED_FILE as _LEGACY_SEED_FILE,
LOGO_URL,
MAX_REQUEST_BODY_BYTES,
MAX_SEND_ATTEMPTS,
@@ -62,6 +64,7 @@ from mail_api.models import (
BirthdayAutoSendRequest,
BirthdayEmailRequest,
BookingSubmission,
ClientStatusUpdate,
ContractSubmission,
OnboardingSubmission,
RenderMessageRequest,
@@ -227,6 +230,70 @@ async def _seed_admin_state_from_json_if_needed() -> None:
logger.warning("Admin seed from JSON failed: %s", exc)
async def _merge_legacy_seed_if_present() -> None:
"""Merge the shipped legacy-clients-seed.json into _client_profiles.
Add-only: never overwrites an email that already exists in the live data.
Idempotent: re-running on every boot is a no-op once the entries are in.
Writes the updated profiles back to the JSON file + admin_kv so the merged
state survives container restarts.
"""
global _client_profiles
seed_path = _LEGACY_SEED_FILE
if not seed_path.exists():
return
try:
seed = json.loads(seed_path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("Legacy seed file unreadable (%s): %s", seed_path, exc)
return
if not isinstance(seed, dict) or not seed:
return
added: list[str] = []
skipped_existing = 0
for raw_email, profile in seed.items():
if not isinstance(raw_email, str) or not isinstance(profile, dict):
continue
email = raw_email.strip().lower()
if not email:
continue
if email == OWNER_EMAIL.strip().lower():
continue
if email in _client_profiles:
skipped_existing += 1
continue
_client_profiles[email] = profile
added.append(email)
if not added:
logger.info(
"Legacy seed already merged (existing=%d, candidates=%d).",
skipped_existing, len(seed),
)
return
snapshot = dict(_client_profiles)
try:
await asyncio.to_thread(_save_client_profiles_file, snapshot)
except Exception as exc:
logger.warning("Could not save client_profiles after legacy merge: %s", exc)
try:
await _persist_admin_state("client_profiles", snapshot)
except Exception as exc:
logger.warning("Could not persist client_profiles to postgres after legacy merge: %s", exc)
logger.info(
"Legacy seed merged: added=%d skipped_existing=%d total_after=%d",
len(added), skipped_existing, len(_client_profiles),
)
async def _load_allowed_emails_async() -> set[str]:
if admin_db.is_enabled():
data = await admin_db.get_kv("allowed_emails")
@@ -308,6 +375,16 @@ async def _register_email(email: str) -> None:
logger.info("Auth: registered new allowed email: %s", normalized)
def _client_is_reachable(profile: dict) -> bool:
"""True if outreach (welcome pack, birthday email, etc.) should still target
this client. Excludes lifecycle states that mean the relationship has ended.
"""
lifecycle = profile.get("lifecycle")
if not isinstance(lifecycle, dict):
return True
return lifecycle.get("status") not in {"cancelled", "archived"}
async def _store_client_profile(email: str, profile: dict) -> None:
normalized = email.strip().lower()
if not normalized:
@@ -458,6 +535,21 @@ def _get_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _is_deploy_smoke(request: Request) -> bool:
"""True when the request carries a matching X-Deploy-Smoke header.
Used by the deploy script to verify the form endpoints are reachable and
parse a valid payload, without producing a real submission. Disabled
entirely when DEPLOY_SMOKE_SECRET is unset.
"""
if not DEPLOY_SMOKE_SECRET:
return False
presented = request.headers.get("x-deploy-smoke") or ""
if not presented:
return False
return secrets.compare_digest(presented, DEPLOY_SMOKE_SECRET)
_submit_attempts_by_ip: dict[str, deque[float]] = {}
_submit_attempts_by_email: dict[str, deque[float]] = {}
_submit_rate_limit_lock = asyncio.Lock()
@@ -1477,6 +1569,213 @@ def _birthday_ics_attachment(dog_name: str, dog_birth_date: str, owner_name: str
}
def _pdf_escape(value: Any) -> str:
if value is None:
return ""
text = str(value)
return (
text.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "<br>")
)
def owner_onboarding_pdf_html(data: OnboardingSubmission) -> str:
"""Clean, full-width, black-and-white, Arial HTML for the printable onboarding PDF."""
submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0")
snapshot = data.submissionSnapshot or {}
sections = snapshot.get("sections") if isinstance(snapshot, dict) else None
def render_value(value: Any) -> str:
if isinstance(value, list):
items = [_pdf_escape(item) for item in value if str(item).strip()]
return ", ".join(items) if items else "&mdash;"
text = _pdf_escape(value).strip()
return text if text else "&mdash;"
sections_html_parts: list[str] = []
if isinstance(sections, list) and sections:
for section in sections:
if not isinstance(section, dict):
continue
title = _pdf_escape(section.get("title", ""))
fields = section.get("fields") or []
rows_html = ""
for field in fields:
if not isinstance(field, dict):
continue
label = _pdf_escape(field.get("label", ""))
rows_html += (
"<tr>"
f"<th>{label}</th>"
f"<td>{render_value(field.get('value'))}</td>"
"</tr>"
)
if rows_html:
sections_html_parts.append(
f"<section class='pdf-section'>"
f"<h2>{title}</h2>"
f"<table class='pdf-table'><tbody>{rows_html}</tbody></table>"
f"</section>"
)
else:
# Fallback if snapshot is missing — render the core fields directly.
def row(label: str, value: Any) -> str:
return f"<tr><th>{_pdf_escape(label)}</th><td>{render_value(value)}</td></tr>"
owner_rows = (
row("Name", data.fullName)
+ row("Email", str(data.email))
+ row("Phone", data.phone)
+ row("Address", data.address)
)
dog_rows = (
row("Dog", data.dogName)
+ row("Breed", data.dogBreed)
+ row("Date of birth", data.dogAge or "")
+ row("Services", data.servicesNeeded)
+ row("Temperament / routine", data.temperament)
+ row("Medical notes", data.medicalNotes)
+ row("Home access", data.accessInstructions)
)
safety_rows = (
row("Vet clinic", data.vetName)
+ row("Vet phone", data.vetPhone)
+ row("Emergency contact", data.emergencyContactName)
+ row("Emergency phone", data.emergencyContactPhone)
+ row("Council registration", "Confirmed" if data.councilRegistrationConfirmed else "Not confirmed")
+ row("Vaccinations", "Confirmed" if data.vaccinationsConfirmed else "Not confirmed")
+ row("Emergency vet consent", "Confirmed" if data.emergencyVetConsent else "Not confirmed")
+ row("Declaration", "Signed" if data.termsAccepted else "Not signed")
)
sections_html_parts.append(
f"<section class='pdf-section'><h2>Owner Details</h2><table class='pdf-table'><tbody>{owner_rows}</tbody></table></section>"
f"<section class='pdf-section'><h2>Dog Details</h2><table class='pdf-table'><tbody>{dog_rows}</tbody></table></section>"
f"<section class='pdf-section'><h2>Safety</h2><table class='pdf-table'><tbody>{safety_rows}</tbody></table></section>"
)
signature_html = ""
if data.signatureDataUrl:
signature_html = (
"<section class='pdf-section pdf-signature'>"
"<h2>Signature</h2>"
f"<img src='{data.signatureDataUrl}' alt='Client signature'>"
f"<div class='pdf-signed-line'>Signed by {_pdf_escape(data.fullName)} on {_pdf_escape(submitted_at)}</div>"
"</section>"
)
body_html = "".join(sections_html_parts) + signature_html
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Goodwalk onboarding form &mdash; {_pdf_escape(data.fullName)}</title>
<style>
@page {{
size: A4;
margin: 14mm 12mm 14mm 12mm;
}}
* {{ box-sizing: border-box; }}
html, body {{
margin: 0;
padding: 0;
background: #ffffff;
color: #000000;
font-family: Arial, Helvetica, sans-serif;
font-size: 10.5pt;
line-height: 1.4;
}}
.pdf-doc {{ width: 100%; }}
.pdf-header {{
width: 100%;
border-bottom: 1.5pt solid #000;
padding-bottom: 8pt;
margin-bottom: 14pt;
}}
.pdf-header h1 {{
font-size: 18pt;
font-weight: 700;
margin: 0 0 4pt 0;
letter-spacing: 0.5pt;
text-transform: uppercase;
}}
.pdf-header .pdf-meta {{
font-size: 9.5pt;
color: #000;
}}
.pdf-section {{
width: 100%;
margin: 0 0 14pt 0;
page-break-inside: avoid;
}}
.pdf-section h2 {{
font-size: 11pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6pt;
margin: 0 0 6pt 0;
padding: 0 0 3pt 0;
border-bottom: 0.75pt solid #000;
}}
table.pdf-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
table.pdf-table th,
table.pdf-table td {{
text-align: left;
vertical-align: top;
padding: 5pt 8pt;
border-bottom: 0.4pt solid #000;
word-wrap: break-word;
overflow-wrap: break-word;
}}
table.pdf-table th {{
width: 34%;
font-weight: 700;
background: #ffffff;
}}
table.pdf-table td {{
width: 66%;
font-weight: 400;
}}
.pdf-signature img {{
display: block;
max-width: 70%;
max-height: 60mm;
height: auto;
border: 0.5pt solid #000;
padding: 4pt;
margin: 4pt 0 6pt 0;
background: #fff;
}}
.pdf-signed-line {{
font-size: 9.5pt;
border-top: 0.4pt solid #000;
padding-top: 4pt;
margin-top: 4pt;
}}
</style>
</head>
<body>
<div class="pdf-doc">
<header class="pdf-header">
<h1>Goodwalk Onboarding Form</h1>
<div class="pdf-meta">
<strong>{_pdf_escape(data.fullName)}</strong> &middot; {_pdf_escape(data.dogName)} &middot; Submitted {_pdf_escape(submitted_at)}
</div>
</header>
{body_html}
</div>
</body>
</html>"""
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()
@@ -1678,6 +1977,9 @@ async def _startup_mail_check() -> None:
except Exception:
logger.exception("Admin state refresh from postgres failed; using JSON snapshot")
# 3. Merge any shipped legacy seed (add-only — never clobbers live entries).
await _merge_legacy_seed_if_present()
try:
await _send_startup_test_email()
except Exception:
@@ -2728,6 +3030,42 @@ async def owner_render_message(data: RenderMessageRequest, request: Request):
return {"ok": True, "html": html}
@app.post("/owner/render-welcome-pack")
async def owner_render_welcome_pack(data: WelcomePackEmailRequest, request: Request):
"""Render the welcome pack email as HTML for in-modal preview."""
await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email, {})
owner_name = str(profile.get("fullName", "")).strip()
dog_name = str(profile.get("dogName", "")).strip()
html = _welcome_pack_email_html(
owner_name,
dog_name,
_trimmed(data.serviceType),
_trimmed(data.priceDetails),
_trimmed(data.startDate),
)
return {"ok": True, "html": html}
@app.post("/owner/render-birthday-email")
async def owner_render_birthday_email(data: BirthdayEmailRequest, request: Request):
"""Render the birthday email as HTML for in-modal preview."""
await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email, {})
if not profile:
raise HTTPException(status_code=404, detail="Client profile not found.")
owner_name = str(profile.get("fullName", "")).strip()
dog_name = str(profile.get("dogName", "")).strip()
html = _birthday_email_html(owner_name, dog_name)
return {"ok": True, "html": html}
@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])
@@ -2896,6 +3234,8 @@ async def owner_pending_onboarding(request: Request):
continue
if profile.get("onboardingCompleted"):
continue
if not _client_is_reachable(profile):
continue
pending_clients.append({
"email": email,
@@ -2947,6 +3287,8 @@ async def owner_completed_onboarding(request: Request):
continue
if not profile.get("onboardingCompleted"):
continue
if not _client_is_reachable(profile):
continue
completed_clients.append({
"email": email,
@@ -3011,6 +3353,7 @@ async def owner_all_clients(request: Request):
if email == OWNER_EMAIL.strip().lower():
continue
lifecycle = profile.get("lifecycle") if isinstance(profile.get("lifecycle"), dict) else None
clients.append({
"email": email,
"fullName": profile.get("fullName", ""),
@@ -3018,6 +3361,7 @@ async def owner_all_clients(request: Request):
"dogName": profile.get("dogName", ""),
"dogBreed": profile.get("dogBreed", ""),
"status": "completed" if profile.get("onboardingCompleted") else "pending",
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
"lastActivityAt": profile.get("onboardingSubmittedAt", "") or profile.get("lastEnquiryAt", "") or profile.get("welcomePackSentAt", ""),
"welcomePackSentAt": profile.get("welcomePackSentAt", ""),
})
@@ -3068,6 +3412,8 @@ async def owner_birthdays(request: Request):
continue
if not profile.get("onboardingCompleted"):
continue
if not _client_is_reachable(profile):
continue
upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")), today)
if not upcoming:
@@ -3255,6 +3601,56 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request
return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": bool(data.preview)}
@app.post("/owner/client-status")
async def owner_client_status(data: ClientStatusUpdate, request: Request):
"""Set a client's lifecycle status (active / paused / cancelled / archived).
Soft-delete only: no client record is ever removed. Each change is recorded
in the profile's lifecycleHistory list and the global activity feed.
"""
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
owner_email = await _require_owner_email(request)
email = str(data.email).strip().lower()
profile = _client_profiles.get(email)
if not profile:
raise HTTPException(status_code=404, detail="Client not found.")
reason = (data.reason or "").strip()[:500]
now_iso = datetime.now().isoformat(timespec="seconds")
existing_history = profile.get("lifecycleHistory")
history: list[dict[str, Any]] = list(existing_history) if isinstance(existing_history, list) else []
history.append({
"status": data.status,
"reason": reason,
"changedAt": now_iso,
"changedBy": owner_email,
})
# Cap history to a sensible size so the JSON file doesn't grow unbounded.
history = history[-50:]
lifecycle = {
"status": data.status,
"reason": reason,
"changedAt": now_iso,
"changedBy": owner_email,
}
await _store_client_profile(email, {
"lifecycle": lifecycle,
"lifecycleHistory": history,
})
await admin_db.record_event(
event_type="owner_client_status_changed",
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
detail={"clientEmail": email, "status": data.status, "reason": reason},
)
logger.info("[%s] owner: %s set %s -> %s", request_id, owner_email, email, data.status)
return {"ok": True, "email": email, "lifecycle": lifecycle}
@app.post("/owner/birthday-auto-send")
async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Request):
owner_email = await _require_owner_email(request)
@@ -3285,6 +3681,10 @@ async def submit_booking(data: BookingSubmission, request: Request):
ip = _get_ip(request)
browser = _parse_ua(request.headers.get("user-agent", ""))
if _is_deploy_smoke(request):
logger.info("[%s] /submit deploy-smoke bypass (no email, no db write)", request_id)
return {"ok": True, "request_id": request_id, "smoke": True}
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
_enforce_form_timing(request_id, data)
@@ -3625,6 +4025,10 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request):
ip = _get_ip(request)
browser = _parse_ua(request.headers.get("user-agent", ""))
if _is_deploy_smoke(request):
logger.info("[%s] /onboarding-submit deploy-smoke bypass (no email, no db write)", request_id)
return {"ok": True, "request_id": request_id, "smoke": True}
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
_enforce_form_timing(request_id, data)
@@ -3670,7 +4074,8 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request):
if 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)
pdf_html = owner_onboarding_pdf_html(data)
pdf_attachment = await _signed_form_pdf_attachment(pdf_html, data.fullName, "onboarding", request_id)
if pdf_attachment:
attachments.append(pdf_attachment)
if attachments:
@@ -3755,6 +4160,10 @@ async def submit_contract(data: ContractSubmission, request: Request):
ip = _get_ip(request)
browser = _parse_ua(request.headers.get("user-agent", ""))
if _is_deploy_smoke(request):
logger.info("[%s] /contract-submit deploy-smoke bypass (no email, no db write)", request_id)
return {"ok": True, "request_id": request_id, "smoke": True}
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
_enforce_form_timing(request_id, data)