644c99ee30
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>
72 lines
2.6 KiB
Python
72 lines
2.6 KiB
Python
"""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),
|
||
)
|