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:
2026-07-23 15:40:45 +02:00
parent b895663c3a
commit 644c99ee30
14 changed files with 872 additions and 15 deletions
+71
View File
@@ -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),
)