Booking confirmation email + customer self-service cancel/reschedule (#18)
Sends a confirmation email (best-effort, fire-and-forget SMTP via mailer.py) on booking creation, with a manage-booking link embedding the ticket-2 signed token. Adds /manage/<token>, a stateless cancel/reschedule page that reuses the existing slot-picker against booking_api's create/cancel/reschedule API, distinguishing an invalid/expired link from an already-cancelled one. Sender address uses the client's own domain when configured, falling back to a mivanchenko.de address otherwise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""Booking confirmation email (#18): sender-address resolution, the
|
||||
customer-facing manage-booking link, and the render/send call. Booking
|
||||
creation must succeed even if this fails -- see mailer.py's fire-and-forget
|
||||
send, which this relies on rather than talking to smtplib directly.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import render_template
|
||||
|
||||
import mailer
|
||||
|
||||
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://onboard.mivanchenko.de").rstrip("/")
|
||||
|
||||
# customer_contact is a single free-text "E-Mail oder Telefon" field (#16/#17)
|
||||
# -- not guaranteed to be an email address. Only attempt to send when it
|
||||
# looks like one.
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def looks_like_email(contact):
|
||||
return bool(_EMAIL_RE.match((contact or "").strip()))
|
||||
|
||||
|
||||
def manage_url(token):
|
||||
return f"{PUBLIC_BASE_URL}/manage/{token}"
|
||||
|
||||
|
||||
# clients.domain is free text from the onboarding form (e.g. "cafe-lichtblick.de",
|
||||
# but nothing stops "https://cafe-lichtblick.de/" being entered) -- strip any
|
||||
# scheme/path/whitespace so a malformed value can't end up in a From header.
|
||||
_DOMAIN_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*://)?([^/\s]+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _clean_domain(domain):
|
||||
m = _DOMAIN_RE.match((domain or "").strip())
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
|
||||
def _sender_for(client):
|
||||
domain = _clean_domain((client or {}).get("domain"))
|
||||
return f"noreply@{domain}" if domain else mailer.MAIL_FALLBACK_FROM
|
||||
|
||||
|
||||
def send_booking_confirmation(client, booking, token):
|
||||
"""No-op if customer_contact doesn't look like an email address -- it's a
|
||||
free-text "E-Mail oder Telefon" field (#16/#17), so this is expected for
|
||||
phone-only customers, not an error."""
|
||||
if not looks_like_email(booking.get("customer_contact")):
|
||||
print(f"[booking_mail] customer_contact for booking "
|
||||
f"{booking.get('booking_id')} doesn't look like an email, "
|
||||
f"skipping confirmation send", flush=True)
|
||||
return
|
||||
tz = ZoneInfo((client or {}).get("timezone") or "Europe/Berlin")
|
||||
business_name = (client or {}).get("business_name") or "Ihr Termin"
|
||||
html = render_template(
|
||||
"emails/booking_confirmation.html",
|
||||
business_name=business_name,
|
||||
customer_name=booking["customer_name"],
|
||||
service=booking["service"],
|
||||
start_local=booking["start_time"].astimezone(tz),
|
||||
status=booking["status"],
|
||||
manage_url=manage_url(token),
|
||||
)
|
||||
mailer.send_email(
|
||||
booking["customer_contact"],
|
||||
f"Terminbestätigung – {business_name}",
|
||||
html,
|
||||
from_addr=_sender_for(client),
|
||||
)
|
||||
Reference in New Issue
Block a user