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,59 @@
|
||||
"""Best-effort SMTP email sending (#18).
|
||||
|
||||
Mirrors app.py's DB -> Sheets mirror pattern: a single background worker
|
||||
thread drains a queue, and a send failure is logged, never raised back to the
|
||||
caller -- booking creation must succeed even if the mail relay is down.
|
||||
"""
|
||||
import os
|
||||
import queue
|
||||
import smtplib
|
||||
import threading
|
||||
import traceback
|
||||
from email.message import EmailMessage
|
||||
|
||||
SMTP_HOST = os.environ.get("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
||||
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||
# Used when a client has no domain configured -- see booking_mail.py.
|
||||
MAIL_FALLBACK_FROM = os.environ.get("MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
|
||||
|
||||
_queue = queue.Queue()
|
||||
|
||||
|
||||
def _send_now(msg):
|
||||
if not SMTP_HOST:
|
||||
print(f"[mailer] SMTP_HOST not configured, skipping send to {msg['To']}", flush=True)
|
||||
return
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as smtp:
|
||||
smtp.starttls()
|
||||
if SMTP_USERNAME:
|
||||
smtp.login(SMTP_USERNAME, SMTP_PASSWORD)
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
def _worker():
|
||||
while True:
|
||||
msg = _queue.get()
|
||||
try:
|
||||
_send_now(msg)
|
||||
except Exception: # noqa: BLE001
|
||||
print(f"[mailer] send to {msg['To']} failed:", flush=True)
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
_queue.task_done()
|
||||
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
|
||||
def send_email(to_addr, subject, html_body, from_addr=None):
|
||||
"""Queue an HTML email for best-effort async delivery. Never raises and
|
||||
never blocks the caller on the network -- the actual SMTP conversation
|
||||
happens on the background worker thread."""
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_addr or MAIL_FALLBACK_FROM
|
||||
msg["To"] = to_addr
|
||||
msg.set_content(html_body, subtype="html")
|
||||
_queue.put(msg)
|
||||
Reference in New Issue
Block a user