v4.0.6 0 Simplicity focus

This commit is contained in:
2026-07-04 10:00:16 +12:00
parent 8eb20813c8
commit 018024510b
31 changed files with 11902 additions and 744 deletions
Binary file not shown.
+31 -1
View File
@@ -9,7 +9,37 @@
"priceDetails": "45",
"startDate": "2026-05-18",
"sentAt": "2026-05-19T22:59:53"
}
},
"lifecycle": {
"status": "active",
"reason": "",
"changedAt": "2026-06-29T20:39:59",
"changedBy": "info@goodwalk.co.nz"
},
"lifecycleHistory": [
{
"status": "active",
"reason": "",
"changedAt": "2026-06-29T20:39:59",
"changedBy": "info@goodwalk.co.nz"
}
],
"dogs": [
{
"id": "primary",
"name": "Geoffrey",
"breed": "",
"birthDate": "",
"birthdayAutoSend": false,
"birthdayEmailLastSentAt": "",
"birthdayEmailLastSentYear": ""
}
],
"dogBreed": "",
"dogAge": "",
"birthdayAutoSend": false,
"birthdayEmailLastSentAt": "",
"birthdayEmailLastSentYear": ""
},
"natalie@desseinparke.com": {
"fullName": "Natalie Parke",
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41 -3
View File
@@ -61,11 +61,37 @@ class WelcomePackEmailRequest(BaseModel):
# Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when
# blank (resolved server-side so scheduled sends keep a sensible subject too).
subject: str = ""
serviceType: str
priceDetails: str
startDate: str
# Structured offer details rendered in the details table. Now fully optional —
# the owner can hide the table entirely (showDetails=False) and write a free
# email instead. Left blank, a row is simply omitted from the table.
serviceType: str = ""
priceDetails: str = ""
startDate: str = ""
# Free-text customisation. All blank => the email falls back to the original
# default copy, so existing callers keep working unchanged. `{first_name}`
# and `{dog_name}` tokens are substituted server-side.
heading: str = ""
intro: str = ""
outro: str = ""
tagLabel: str = ""
# Customisable button block. Defaults to "Complete onboarding" pointing at the
# client onboarding portal. Clearing either field hides the button.
ctaLabel: str = ""
ctaUrl: str = ""
# Whether to render the Service / Price / Start date table at all.
showDetails: bool = True
# Rich-text (WYSIWYG) message body. When present it replaces the
# heading/intro/outro copy above and is sanitised to an email-safe subset of
# HTML server-side. `includeButton` toggles the onboarding button beneath it.
bodyHtml: str = ""
includeButton: bool = True
preview: bool = False
previewRecipients: list[EmailStr] = []
# When sending for real (not a preview), also copy the owner so they keep a
# record of exactly what the client received. CC is visible to the client;
# BCC is hidden. Independent. Ignored on previews and scheduled sends.
ccOwner: bool = False
bccOwner: bool = False
# ISO 8601 local datetime (e.g. "2026-06-20T14:30"). When set on a non-preview
# request, the welcome email is queued for reliable delivery at that time
# instead of being sent immediately.
@@ -84,6 +110,9 @@ class ScheduledEmailRescheduleRequest(BaseModel):
class BirthdayEmailRequest(BaseModel):
email: EmailStr
dogId: str | None = None
# Owner-editable subject line. Blank falls back to the default
# "Happy birthday <dog>" subject.
subject: str = ""
preview: bool = False
previewRecipients: list[EmailStr] = []
@@ -124,6 +153,12 @@ class ResetOnboardingRequest(BaseModel):
email: EmailStr
class DeleteClientRequest(BaseModel):
"""Owner-initiated permanent deletion of a client and their access."""
email: EmailStr
class NewClientRequest(BaseModel):
"""Owner-initiated client creation (e.g. leads from Instagram / Facebook
that never came through the public enquiry form). Registers the email so
@@ -138,6 +173,9 @@ class NewClientRequest(BaseModel):
dogAge: str = ""
# Where the client came from, e.g. "Instagram", "Facebook", "Referral".
source: str = ""
# The date the client joined Goodwalk (ISO yyyy-mm-dd). Owner-supplied, since
# only the owner knows it. Used to surface joining anniversaries.
joiningDate: str = ""
# When true (and the MYOB integration is configured), also create the client
# as a customer contact in MYOB. Non-fatal: a MYOB failure never blocks the
# local client from being created.
+541 -42
View File
@@ -2,6 +2,8 @@ import asyncio
import base64
from collections import deque
from contextlib import asynccontextmanager
import html
from html.parser import HTMLParser
import json
import os
import random
@@ -71,6 +73,7 @@ from mail_api.models import (
BookingSubmission,
ClientStatusUpdate,
ContractSubmission,
DeleteClientRequest,
NewClientRequest,
OnboardingSubmission,
RenderMessageRequest,
@@ -648,6 +651,53 @@ async def _update_client_profile(
return merged
async def _delete_client_profile(email: str) -> dict[str, Any]:
"""Permanently remove a client: their profile, saved draft, any pending login
codes/sessions, their allowed-email entry (so they can no longer sign in),
and any welcome emails still queued to send to them. The business owner's own
account can never be deleted."""
normalized = email.strip().lower()
if not normalized:
raise HTTPException(status_code=400, detail="A valid email is required.")
if normalized == OWNER_EMAIL.strip().lower():
raise HTTPException(status_code=400, detail="The business owner account cannot be deleted.")
async with _auth_lock:
if normalized not in _client_profiles:
raise HTTPException(status_code=404, detail="Client not found.")
removed = _client_profiles.pop(normalized, {})
_drafts.pop(normalized, None)
_pending_codes.pop(normalized, None)
_code_requests.pop(normalized, None)
for token in [t for t, s in _active_sessions.items() if isinstance(s, dict) and s.get("email") == normalized]:
_active_sessions.pop(token, None)
_allowed_emails.discard(normalized)
profiles_snapshot = dict(_client_profiles)
drafts_snapshot = dict(_drafts)
allowed_emails_snapshot = sorted(_allowed_emails)
await asyncio.to_thread(_save_client_profiles_file, profiles_snapshot)
await asyncio.to_thread(_save_drafts_file, drafts_snapshot)
await asyncio.to_thread(_save_allowed_emails_file, set(allowed_emails_snapshot))
await _persist_admin_state("client_profiles", profiles_snapshot)
await _persist_admin_state("drafts", drafts_snapshot)
await _persist_admin_state("allowed_emails", {"emails": allowed_emails_snapshot})
await _save_active_sessions_async()
# Drop any scheduled welcome emails still queued for this address.
async with _scheduled_lock:
stale = [eid for eid, entry in _scheduled_emails.items()
if isinstance(entry, dict) and str(entry.get("email", "")).strip().lower() == normalized]
for eid in stale:
_scheduled_emails.pop(eid, None)
if stale:
await _persist_scheduled_emails()
return removed if isinstance(removed, dict) else {}
async def _reset_client_onboarding(email: str) -> dict[str, Any]:
"""Mark a client's onboarding as incomplete while keeping every saved detail.
@@ -2384,11 +2434,353 @@ def _format_date_label(value: str) -> str:
return raw
def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str, price_details: str, start_date: str) -> str:
WELCOME_FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"
WELCOME_CTA_DEFAULT_LABEL = "Complete onboarding"
WELCOME_CTA_DEFAULT_URL = "https://clients.goodwalk.co.nz/"
# Canonical business contact details, kept in step with the public site footer
# (src/lib/content/homepage.ts). These power the copyright + contact block at the
# bottom of every email the owner sends.
BUSINESS_EMAIL = "info@goodwalk.co.nz"
BUSINESS_PHONE = "(022) 642 1011"
BUSINESS_PHONE_TEL = "0226421011"
def _email_footer_html(
*,
text_color: str = "#ffffff",
link_color: str = "#ffffff",
font_stack: str = WELCOME_FONT_STACK,
) -> str:
"""Shared email footer: a simple copyright notice plus the business contact
details. White text by default so it reads cleanly on the dark-green band."""
year = datetime.now().year
return (
f'<div style="font-family:{font_stack};font-size:12px;line-height:1.7;color:{text_color};">'
f'&copy; {year} Goodwalk<br>'
f'<a href="mailto:{BUSINESS_EMAIL}" style="color:{link_color};text-decoration:none;">{BUSINESS_EMAIL}</a>'
f' &middot; '
f'<a href="tel:{BUSINESS_PHONE_TEL}" style="color:{link_color};text-decoration:none;">{BUSINESS_PHONE}</a>'
f'</div>'
)
def _apply_welcome_tokens(text: str, first_name: str, dog_name: str) -> str:
"""Substitute the personalisation tokens the owner can use in custom copy."""
dog = dog_name or "your dog"
return (
text.replace("{first_name}", first_name)
.replace("{firstName}", first_name)
.replace("{dog_name}", dog)
.replace("{dogName}", dog)
.replace("{dog}", dog)
)
def _welcome_paragraphs(text: str, color: str) -> str:
"""Render owner body text as one or more email-safe paragraphs."""
parts = [p.strip() for p in (text or "").split("\n\n") if p.strip()]
return "".join(
f'<p style="margin:0 0 18px;font-family:{WELCOME_FONT_STACK};font-size:15px;'
f'line-height:1.7;color:{color};">{_html_breaks(para)}</p>'
for para in parts
)
def _html_breaks(text: str) -> str:
"""Convert single newlines within a paragraph to <br> (text is already trusted owner copy)."""
return text.replace("\n", "<br>")
# Inline styles applied to the whitelisted tags the WYSIWYG editor can produce,
# so the owner's message renders consistently across email clients.
_WELCOME_BODY_STYLES = {
"p": f"margin:0 0 16px;font-family:{WELCOME_FONT_STACK};font-size:15px;line-height:1.7;color:#4b584b;",
"h1": f"margin:0 0 12px;font-family:{WELCOME_FONT_STACK};font-size:26px;line-height:1.2;letter-spacing:-0.02em;font-weight:700;color:#171b20;",
"h2": f"margin:20px 0 10px;font-family:{WELCOME_FONT_STACK};font-size:20px;line-height:1.25;letter-spacing:-0.01em;font-weight:700;color:#171b20;",
"h3": f"margin:18px 0 8px;font-family:{WELCOME_FONT_STACK};font-size:16px;line-height:1.3;font-weight:700;color:#213021;",
"ul": "margin:0 0 16px;padding-left:22px;",
"ol": "margin:0 0 16px;padding-left:22px;",
"li": f"margin:0 0 6px;font-family:{WELCOME_FONT_STACK};font-size:15px;line-height:1.6;color:#4b584b;",
"strong": "font-weight:700;color:#171b20;",
"em": "font-style:italic;",
"u": "text-decoration:underline;",
"a": "color:#213021;text-decoration:underline;",
}
# Tags browsers emit that we fold onto our canonical equivalents.
_WELCOME_TAG_ALIASES = {"b": "strong", "i": "em", "div": "p"}
# Block tags that establish a paragraph context (so stray text gets wrapped).
_WELCOME_BLOCK_TAGS = {"p", "li", "h1", "h2", "h3"}
class _WelcomeBodySanitizer(HTMLParser):
"""Reduce the contenteditable HTML the owner produces to a small, email-safe
subset (paragraphs, bold/italic/underline, lists and http(s)/mailto links),
re-emitting every tag with our inline styles and dropping everything else."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.open_tags: list[str] = []
def _in_block(self) -> bool:
return any(t in _WELCOME_BLOCK_TAGS for t in self.open_tags)
def _in_list(self) -> bool:
return any(t in ("ul", "ol") for t in self.open_tags)
def _emit_open(self, tag: str) -> None:
style = _WELCOME_BODY_STYLES.get(tag, "")
self.parts.append(f'<{tag} style="{style}">' if style else f"<{tag}>")
self.open_tags.append(tag)
def _emit_close(self, tag: str) -> None:
if tag not in self.open_tags:
return
while self.open_tags:
current = self.open_tags.pop()
self.parts.append(f"</{current}>")
if current == tag:
break
def _ensure_paragraph(self) -> None:
if not self._in_block() and not self._in_list():
self._emit_open("p")
def handle_starttag(self, tag, attrs):
tag = _WELCOME_TAG_ALIASES.get(tag, tag)
if tag == "br":
self._ensure_paragraph()
self.parts.append("<br>")
return
if tag in ("p", "ul", "ol", "h1", "h2", "h3"):
# A new block ends any paragraph/heading we were implicitly inside.
if self._in_block():
for candidate in ("p", "li", "h1", "h2", "h3"):
if candidate in self.open_tags:
self._emit_close(candidate)
break
self._emit_open(tag)
return
if tag == "li":
self._emit_open("li")
return
if tag in ("strong", "em", "u"):
self._ensure_paragraph()
self._emit_open(tag)
return
if tag == "a":
href = ""
for name, value in attrs:
if name == "href" and value:
href = value.strip()
if not re.match(r"^(https?:|mailto:)", href, re.IGNORECASE):
return # drop unsafe/relative links, keep their text
self._ensure_paragraph()
self.parts.append(f'<a href="{_escape_attr(href)}" style="{_WELCOME_BODY_STYLES["a"]}">')
self.open_tags.append("a")
return
# Any other tag (span, font, h1…): ignored, but its text is kept.
def handle_endtag(self, tag):
tag = _WELCOME_TAG_ALIASES.get(tag, tag)
if tag in ("p", "ul", "ol", "li", "strong", "em", "u", "a", "h1", "h2", "h3"):
self._emit_close(tag)
def handle_data(self, data):
if not data.strip() and not self._in_block():
return # ignore whitespace between block tags
self._ensure_paragraph()
self.parts.append(
data.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
def result(self) -> str:
while self.open_tags:
self.parts.append(f"</{self.open_tags.pop()}>")
html = "".join(self.parts)
# Drop empty paragraphs the editor leaves behind (e.g. trailing blank line).
html = re.sub(r"<p[^>]*>(?:\s|<br>|&nbsp;)*</p>", "", html)
return html.strip()
def _sanitize_welcome_body_html(raw: str) -> str:
"""Public entry point: parse and re-emit owner rich-text as email-safe HTML."""
if not (raw or "").strip():
return ""
parser = _WelcomeBodySanitizer()
parser.feed(raw)
parser.close()
return parser.result()
def _strip_legacy_welcome_tag_html(raw: str) -> str:
"""Remove the old frontend-injected yellow tag block if it was saved into
bodyHtml before tagLabel became a first-class server-rendered field."""
if not (raw or "").strip():
return ""
return re.sub(
r'^\s*<div[^>]*>\s*<span[^>]*background:\s*#f4e7a8;[^>]*>.*?</span>\s*</div>\s*',
'',
raw,
flags=re.IGNORECASE | re.DOTALL,
).strip()
def _welcome_cta_html(label: str, url: str) -> str:
"""The dark-green pill button used at the foot of the welcome email."""
label = (label or "").strip()
url = (url or "").strip()
if not (label and url):
return ""
safe_label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return (
f'<a href="{_escape_attr(url)}" style="display:inline-block;background:#213021;color:#ffffff;'
"text-decoration:none;border-radius:999px;padding:14px 20px;font-family:"
f'{WELCOME_FONT_STACK};font-size:15px;font-weight:700;">{safe_label}</a>'
)
def _welcome_pack_email_html(
client_name: str,
dog_name: str,
service_type: str,
price_details: str,
start_date: str,
*,
heading: str = "",
intro: str = "",
outro: str = "",
tag_label: str = "",
cta_label: str = WELCOME_CTA_DEFAULT_LABEL,
cta_url: str = WELCOME_CTA_DEFAULT_URL,
show_details: bool = True,
body_html: str = "",
include_button: bool = True,
) -> str:
first_name = client_name.split()[0] if client_name.strip() else "there"
dog_line = f" for {dog_name}" if dog_name.strip() else ""
formatted_start_date = _format_date_label(start_date)
# Preferred path: the owner wrote a free-form message in the WYSIWYG editor.
# We sanitise it, substitute the personalisation tokens, and (optionally) add
# the onboarding button beneath it — no forced heading or details table.
clean_body = _sanitize_welcome_body_html(_strip_legacy_welcome_tag_html(body_html))
if clean_body:
clean_body = _apply_welcome_tokens(clean_body, first_name, dog_name)
# The "Badge" field drives the single header badge in the shell — it is not a
# second in-body pill, so the email never shows the badge text twice.
badge_label = _apply_welcome_tokens((tag_label or "").strip(), first_name, dog_name)
cta_html = _welcome_cta_html(WELCOME_CTA_DEFAULT_LABEL, WELCOME_CTA_DEFAULT_URL) if include_button else ""
# Only add a top margin when there's a badge to sit beneath; otherwise the
# cell's own top padding is enough and a top margin would leave an empty
# gap where the badge would have been.
body_top_margin = "16px" if badge_label else "0"
content_inner = f'<div style="margin:{body_top_margin} 0 22px;">{clean_body}</div>{cta_html}'
return _welcome_pack_shell(content_inner, tag_label=badge_label)
# No message written and no legacy structured copy supplied: render just the
# email chrome (logo + badge) so the live preview stays genuinely empty until
# the owner writes something, rather than falling back to canned default copy.
if not any(((heading or "").strip(), (intro or "").strip(), (outro or "").strip())):
badge_label = _apply_welcome_tokens((tag_label or "").strip(), first_name, dog_name)
return _welcome_pack_shell("", tag_label=badge_label)
# Legacy structured path: each block falls back to the original default copy
# when blank, so older callers that pass heading/intro/outro look unchanged.
heading_text = _apply_welcome_tokens(
(heading or "").strip()
or f"Hi {first_name}, we&rsquo;d love to get {dog_name or 'your dog'} started with Goodwalk.",
first_name,
dog_name,
)
intro_text = _apply_welcome_tokens(
(intro or "").strip()
or (
f"We&rsquo;ve set aside the details below{dog_line}. When you&rsquo;re ready, "
"complete your onboarding form and we&rsquo;ll take it from there."
),
first_name,
dog_name,
)
outro_text = _apply_welcome_tokens(
(outro or "").strip()
or (
"Use the same email address you originally used with Goodwalk. "
"We&rsquo;ll send you a one-time code when you sign in."
),
first_name,
dog_name,
)
intro_html = _welcome_paragraphs(intro_text, "#4b584b")
outro_html = ""
if outro_text:
outro_html = (
f'<p style="margin:18px 0 0;font-family:{WELCOME_FONT_STACK};font-size:14px;'
f'line-height:1.7;color:#657365;">{_html_breaks(outro_text)}</p>'
)
# The details table is optional — when hidden, or when every row is blank, it
# is dropped entirely so a free-text email reads cleanly.
details_rows = ""
if show_details:
details_rows = (
_detail_row("Service", service_type)
+ _detail_row("Price", price_details)
+ _detail_row("Start date", formatted_start_date if start_date.strip() else "")
)
details_html = ""
if details_rows:
details_html = f"""
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
<tr><td style="padding:22px 20px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{details_rows}
</table>
</td></tr>
</table>"""
# The button block keeps the welcome email's dark-green pill styling but lets
# the owner relabel it and point it anywhere. Clearing either field hides it.
# The standard default is materialised by callers (see _welcome_pack_payload),
# so an explicit blank here genuinely means "no button".
label = (cta_label or "").strip()
url = (cta_url or "").strip()
cta_html = ""
if label and url:
safe_url = _escape_attr(url)
safe_label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
cta_html = (
f'<a href="{safe_url}" style="display:inline-block;background:#213021;color:#ffffff;'
"text-decoration:none;border-radius:999px;padding:14px 20px;font-family:"
f'{WELCOME_FONT_STACK};font-size:15px;font-weight:700;">{safe_label}</a>'
)
# The legacy structured path never renders a header badge, so the heading
# has no top margin — the cell's top padding alone sets the spacing.
heading_html = (
f'<h1 style="margin:0 0 12px;font-family:{WELCOME_FONT_STACK};font-size:32px;'
f'line-height:1.05;letter-spacing:-0.03em;color:#171b20;">{heading_text}</h1>'
)
content_inner = f"{heading_html}{intro_html}{details_html}{cta_html}{outro_html}"
return _welcome_pack_shell(content_inner)
def _welcome_pack_shell(content_inner: str, *, tag_label: str = "") -> str:
"""The shared welcome-email chrome (logo header, optional header badge, rounded
card) wrapped around whatever message content is supplied. The badge text is
owner-editable via the "Badge" field; when it is left blank, no badge is shown.
This is the email's only tag — there is no second in-body pill."""
badge_label = (tag_label or "").strip()
badge_html = (
'<div style="display:inline-block;background:#ffd100;border-radius:999px;'
f'padding:8px 14px;margin-bottom:4px;font-family:{WELCOME_FONT_STACK};font-size:12px;'
'font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">'
f'{html.escape(badge_label)}</div>'
if badge_label
else ""
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -2407,33 +2799,13 @@ def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str,
</tr>
<tr>
<td style="background:#fbfaf7;padding:34px 24px 30px;">
<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">
Welcome to the pack
</div>
<h1 style="margin:18px 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
Hi {first_name}, we&rsquo;d love to get {dog_name or 'your dog'} started with Goodwalk.
</h1>
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
We&rsquo;ve set aside the details below{dog_line}. When you&rsquo;re ready, complete your onboarding form and we&rsquo;ll take it from there.
</p>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
<tr><td style="padding:22px 20px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{_detail_row("Service", service_type)}
{_detail_row("Price", price_details)}
{_detail_row("Start date", formatted_start_date)}
</table>
</td></tr>
</table>
<a href="https://clients.goodwalk.co.nz/" style="display:inline-block;background:#213021;color:#ffffff;text-decoration:none;border-radius:999px;padding:14px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;font-weight:700;">
Complete onboarding
</a>
<p style="margin:18px 0 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.7;color:#657365;">
Use the same email address you originally used with Goodwalk. We&rsquo;ll send you a one-time code when you sign in.
</p>
{badge_html}
{content_inner}
</td>
</tr>
<tr>
<td style="background:#213021;padding:22px 24px;text-align:center;">
{_email_footer_html()}
</td>
</tr>
</table>
@@ -2575,6 +2947,11 @@ def _birthday_email_html(client_name: str, dog_name: str) -> str:
</p>
</td>
</tr>
<tr>
<td style="background:#213021;padding:22px 24px;text-align:center;">
{_email_footer_html()}
</td>
</tr>
</table>
</td></tr>
</table>
@@ -2635,6 +3012,7 @@ async def _send_birthday_email_for_profile(
mark_auto_year: int | None = None,
preview: bool = False,
preview_recipients: list[str] | None = None,
subject: str = "",
) -> None:
client_name = str(profile.get("fullName", "")).strip()
dog = _get_selected_dog(profile, dog_id)
@@ -2642,7 +3020,7 @@ async def _send_birthday_email_for_profile(
raise HTTPException(status_code=400, detail="This client does not have a dog on file.")
dog_name = str(dog.get("name", "")).strip()
recipients = _resolve_preview_recipients(preview_recipients) if preview else [email]
subject = f"Happy birthday {dog_name or 'from Goodwalk'}"
subject = (subject or "").strip() or f"Happy birthday {dog_name or 'from Goodwalk'}"
if preview:
subject = f"[PREVIEW for {client_name or email}] {subject}"
payload = {
@@ -3346,7 +3724,8 @@ def _render_message_html(
<tr>
<td align="center" style="background:{footer_bg};padding:22px 24px 18px;font-family:{font_stack};font-size:12px;line-height:1.6;color:{footer_text_color};">
<div style="font-size:14px;letter-spacing:0.3em;margin-bottom:8px;color:{footer_text_color};">{ornament_bottom or '🐾 · 🐾 · 🐾'}</div>
{('<div style="font-weight:600;">' + fn + '</div>') if fn else ''}
{('<div style="font-weight:600;margin-bottom:10px;">' + fn + '</div>') if fn else ''}
{_email_footer_html(text_color=footer_text_color, link_color=footer_text_color, font_stack=font_stack)}
</td>
</tr>
@@ -3415,12 +3794,22 @@ async def owner_render_welcome_pack(data: WelcomePackEmailRequest, request: Requ
owner_name = str(profile.get("fullName", "")).strip()
dog_name = str(profile.get("dogName", "")).strip()
custom = _welcome_custom_fields(data)
html = _welcome_pack_email_html(
owner_name,
dog_name,
_trimmed(data.serviceType),
_trimmed(data.priceDetails),
_trimmed(data.startDate),
heading=str(custom["heading"]),
intro=str(custom["intro"]),
outro=str(custom["outro"]),
tag_label=str(custom["tagLabel"]),
cta_label=str(custom["ctaLabel"]),
cta_url=str(custom["ctaUrl"]),
show_details=bool(custom["showDetails"]),
body_html=str(custom["bodyHtml"]),
include_button=bool(custom["includeButton"]),
)
return {"ok": True, "html": html}
@@ -4030,6 +4419,31 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request):
}
@app.post("/owner/delete-client")
async def owner_delete_client(data: DeleteClientRequest, request: Request):
"""Permanently delete a client and revoke their access to the onboarding form."""
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
owner_email = await _require_owner_email(request)
email = str(data.email).strip().lower()
removed = await _delete_client_profile(email)
await admin_db.record_event(
event_type="owner_client_deleted",
request_id=request_id,
actor_email=owner_email,
ip=_get_ip(request),
status="ok",
detail={
"email": email,
"fullName": removed.get("fullName", "") if isinstance(removed, dict) else "",
"dogName": removed.get("dogName", "") if isinstance(removed, dict) else "",
},
)
return {"ok": True, "email": email}
@app.post("/owner/add-client")
async def owner_add_client(data: NewClientRequest, request: Request):
"""Create a client by hand (leads from Instagram / Facebook / referrals that
@@ -4046,6 +4460,7 @@ async def owner_add_client(data: NewClientRequest, request: Request):
dog_breed = str(data.dogBreed).strip()
dog_age = str(data.dogAge).strip()
source = str(data.source).strip()
joining_date = str(data.joiningDate).strip()
if not full_name:
raise HTTPException(status_code=400, detail="Please enter the owner name.")
@@ -4067,6 +4482,9 @@ async def owner_add_client(data: NewClientRequest, request: Request):
"dogBreed": dog_breed,
"dogAge": dog_age,
"source": source,
# Owner-supplied date the client joined Goodwalk (falls back to the
# add timestamp). Drives the joining-anniversaries view.
"joiningDate": joining_date or added_at[:10],
# Treated like an enquiry timestamp so the client surfaces in the
# pending-onboarding and recent-clients views straight away.
"lastEnquiryAt": added_at,
@@ -4352,6 +4770,22 @@ def _default_welcome_subject(dog_name: str) -> str:
return f"Goodwalk Onboarding - {name}" if name else "Goodwalk Onboarding"
def _welcome_custom_fields(data: WelcomePackEmailRequest) -> dict:
"""Pull the owner's customisation fields off the request into a plain dict
that travels with the offer (live send, preview, and scheduled queue)."""
return {
"tagLabel": _trimmed(data.tagLabel),
"heading": _trimmed(data.heading),
"intro": _trimmed(data.intro),
"outro": _trimmed(data.outro),
"ctaLabel": _trimmed(data.ctaLabel),
"ctaUrl": _trimmed(data.ctaUrl),
"showDetails": bool(data.showDetails),
"bodyHtml": _trimmed(data.bodyHtml),
"includeButton": bool(data.includeButton),
}
def _welcome_pack_payload(
email: str,
owner_name: str,
@@ -4363,22 +4797,45 @@ def _welcome_pack_payload(
subject: str,
is_preview: bool,
recipients: list[str],
custom: dict | None = None,
cc: list[str] | None = None,
bcc_extra: list[str] | None = None,
) -> dict:
"""Assemble the Resend payload for a welcome-pack email. Shared by the
immediate send, the preview path, and the scheduled-email worker."""
subject = (subject or "").strip() or _default_welcome_subject(dog_name)
if is_preview:
subject = f"[PREVIEW for {owner_name or email}] {subject}"
custom = custom or {}
payload = {
"from": FROM_EMAIL,
"to": recipients,
"reply_to": REPLY_TO,
"subject": subject,
"html": _welcome_pack_email_html(owner_name, dog_name, service, price, start),
"html": _welcome_pack_email_html(
owner_name, dog_name, service, price, start,
heading=str(custom.get("heading", "")),
intro=str(custom.get("intro", "")),
outro=str(custom.get("outro", "")),
tag_label=str(custom.get("tagLabel", "")),
# Missing keys (legacy scheduled entries) fall back to the standard
# button; a present-but-empty value is an explicit "hide the button".
cta_label=str(custom.get("ctaLabel", WELCOME_CTA_DEFAULT_LABEL)),
cta_url=str(custom.get("ctaUrl", WELCOME_CTA_DEFAULT_URL)),
show_details=bool(custom.get("showDetails", True)),
body_html=str(custom.get("bodyHtml", "")),
include_button=bool(custom.get("includeButton", True)),
),
}
bcc = _client_bcc_list()
bcc = list(_client_bcc_list())
if bcc_extra and not is_preview:
for addr in bcc_extra:
if addr and addr not in bcc:
bcc.append(addr)
if bcc and not is_preview:
payload["bcc"] = bcc
if cc and not is_preview:
payload["cc"] = cc
return payload
@@ -4390,6 +4847,9 @@ async def _deliver_welcome_pack(
start: str,
subject: str,
request_id: str,
custom: dict | None = None,
cc: list[str] | None = None,
bcc_extra: list[str] | None = None,
) -> str:
"""Send the welcome pack to the client now and record it on the profile.
Returns the sentAt timestamp. Raises on send failure (caller decides how to
@@ -4397,10 +4857,11 @@ async def _deliver_welcome_pack(
two paths can never drift apart."""
owner_name = str(profile.get("fullName", "")).strip()
dog_name = str(profile.get("dogName", "")).strip()
custom = custom or {}
sent_at = datetime.now().isoformat(timespec="seconds")
payload = _welcome_pack_payload(
email, owner_name, dog_name, service, price, start,
subject=subject, is_preview=False, recipients=[email],
subject=subject, is_preview=False, recipients=[email], custom=custom, cc=cc, bcc_extra=bcc_extra,
)
await _send_email(payload, label="welcome_pack_email", request_id=request_id)
await _store_client_profile(email, {
@@ -4409,6 +4870,16 @@ async def _deliver_welcome_pack(
"serviceType": service,
"priceDetails": price,
"startDate": start,
"subject": subject,
"tagLabel": str(custom.get("tagLabel", "")),
"heading": str(custom.get("heading", "")),
"intro": str(custom.get("intro", "")),
"outro": str(custom.get("outro", "")),
"ctaLabel": str(custom.get("ctaLabel", "")),
"ctaUrl": str(custom.get("ctaUrl", "")),
"showDetails": bool(custom.get("showDetails", True)),
"bodyHtml": str(custom.get("bodyHtml", "")),
"includeButton": bool(custom.get("includeButton", True)),
"sentAt": sent_at,
},
})
@@ -4425,8 +4896,10 @@ async def _enqueue_scheduled_welcome(
subject: str,
scheduled_for: datetime,
owner_email: str,
custom: dict | None = None,
) -> dict:
"""Add a welcome email to the durable queue for later delivery."""
custom = custom or {}
entry_id = uuid.uuid4().hex
entry = {
"id": entry_id,
@@ -4436,6 +4909,12 @@ async def _enqueue_scheduled_welcome(
"serviceType": service,
"priceDetails": price,
"startDate": start,
"heading": str(custom.get("heading", "")),
"intro": str(custom.get("intro", "")),
"outro": str(custom.get("outro", "")),
"ctaLabel": str(custom.get("ctaLabel", "")),
"ctaUrl": str(custom.get("ctaUrl", "")),
"showDetails": bool(custom.get("showDetails", True)),
"clientName": str(profile.get("fullName", "")).strip(),
"dogName": str(profile.get("dogName", "")).strip(),
"scheduledFor": scheduled_for.isoformat(timespec="minutes"),
@@ -4495,6 +4974,16 @@ async def _run_scheduled_sender_once() -> None:
str(entry.get("startDate", "")),
str(entry.get("subject", "")),
request_id,
custom={
"heading": str(entry.get("heading", "")),
"intro": str(entry.get("intro", "")),
"outro": str(entry.get("outro", "")),
# Pre-customisation entries have no cta keys → standard
# button; newer entries store explicit values (incl. "").
"ctaLabel": str(entry.get("ctaLabel", WELCOME_CTA_DEFAULT_LABEL)),
"ctaUrl": str(entry.get("ctaUrl", WELCOME_CTA_DEFAULT_URL)),
"showDetails": bool(entry.get("showDetails", True)),
},
)
entry["status"] = "sent"
entry["sentAt"] = sent_at
@@ -4545,19 +5034,26 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
raise HTTPException(status_code=404, detail="Client profile not found.")
if profile.get("onboardingCompleted"):
raise HTTPException(status_code=400, detail="This client has already completed onboarding.")
if not _trimmed(data.serviceType):
raise HTTPException(status_code=400, detail="Please enter a service.")
if not _trimmed(data.priceDetails):
raise HTTPException(status_code=400, detail="Please enter the price details.")
if not _trimmed(data.startDate):
raise HTTPException(status_code=400, detail="Please enter a start date.")
# The email is now fully customisable, so the structured offer fields are
# optional. We only require that the email isn't completely empty.
service = _trimmed(data.serviceType)
price = _trimmed(data.priceDetails)
start = _trimmed(data.startDate)
custom = _welcome_custom_fields(data)
subject = _trimmed(data.subject) or _default_welcome_subject(str(profile.get("dogName", "")))
is_preview = bool(data.preview)
has_body = bool(
_sanitize_welcome_body_html(str(custom["bodyHtml"]))
or custom["heading"]
or custom["intro"]
or custom["outro"]
)
has_details = bool(custom["showDetails"] and (service or price or start))
if not has_body and not has_details:
raise HTTPException(status_code=400, detail="Please add some content or offer details to the email.")
# Scheduled send: queue it for the worker rather than sending now. Previews
# are always immediate — there's nothing to schedule about a test email.
if data.scheduledFor and not is_preview:
@@ -4566,7 +5062,7 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
raise HTTPException(status_code=400, detail="Please choose a time in the future to schedule the email.")
entry = await _enqueue_scheduled_welcome(
email=email, profile=profile, service=service, price=price, start=start,
subject=subject, scheduled_for=when, owner_email=owner_email,
subject=subject, scheduled_for=when, owner_email=owner_email, custom=custom,
)
logger.info("[%s] welcome pack scheduled: email=%s for=%s id=%s", request_id, email, entry["scheduledFor"], entry["id"])
await admin_db.record_event(
@@ -4582,7 +5078,7 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
recipients = _resolve_preview_recipients([str(e) for e in (data.previewRecipients or [])])
payload = _welcome_pack_payload(
email, owner_name, dog_name, service, price, start,
subject=subject, is_preview=True, recipients=recipients,
subject=subject, is_preview=True, recipients=recipients, custom=custom,
)
try:
await _send_email(payload, label="welcome_pack_email_preview", request_id=request_id)
@@ -4599,8 +5095,10 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
logger.info("[%s] welcome pack PREVIEW sent: original_recipient=%s -> owner", request_id, email)
return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": True}
cc_list = [owner_email] if (bool(data.ccOwner) and owner_email) else None
bcc_list = [owner_email] if (bool(data.bccOwner) and owner_email) else None
try:
sent_at = await _deliver_welcome_pack(email, profile, service, price, start, subject, request_id)
sent_at = await _deliver_welcome_pack(email, profile, service, price, start, subject, request_id, custom=custom, cc=cc_list, bcc_extra=bcc_list)
except Exception as exc:
logger.error("[%s] welcome pack email failed: %s", request_id, exc, exc_info=True)
raise HTTPException(
@@ -4727,6 +5225,7 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request
dog_id=data.dogId,
preview=bool(data.preview),
preview_recipients=[str(e) for e in (data.previewRecipients or [])],
subject=str(data.subject or ""),
)
except Exception as exc:
logger.error("[%s] birthday email failed: %s", request_id, exc, exc_info=True)