Files
smb-online/backoffice/app/booking_mail.py
T
mivanchenko 644c99ee30 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>
2026-07-23 15:40:45 +02:00

72 lines
2.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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),
)