"""Minimal SMTP sender for owner/customer notifications. Deliberately stdlib-only (smtplib + email) — no new dependency, no queue, no broker. Mail is sent on a daemon thread so a slow or dead SMTP host never makes the visitor wait, and a failure is logged rather than raised: the demo request is already committed to the database by the time we get here, so the owner can always see it in /admin/demos even if mail is misconfigured. """ import logging import smtplib import threading from email.message import EmailMessage from email.utils import formataddr, parseaddr mail_log = logging.getLogger("jqc.mail") def mail_enabled(config): """Mail can only go out with a host and a From address configured.""" return bool(config.get("SMTP_HOST") and config.get("MAIL_FROM")) def _clean_header(value): """Strip CR/LF so a user-supplied name can't inject extra headers.""" return " ".join(str(value or "").split())[:200] def send_email(config, to, subject, body, reply_to=None, reply_name=None): """Send one plain-text mail. Returns True on success, False otherwise. Never raises — callers treat mail as best-effort.""" if not mail_enabled(config) or not to: mail_log.info("Mail skipped (SMTP not configured): %s", _clean_header(subject)) return False prefix = config.get("MAIL_SUBJECT_PREFIX", "") msg = EmailMessage() msg["Subject"] = _clean_header(f"{prefix} {subject}".strip()) msg["From"] = config["MAIL_FROM"] msg["To"] = to if reply_to: addr = parseaddr(_clean_header(reply_to))[1] if addr: msg["Reply-To"] = formataddr((_clean_header(reply_name), addr)) msg.set_content(body) host = config["SMTP_HOST"] port = config.get("SMTP_PORT", 587) timeout = config.get("SMTP_TIMEOUT", 20) try: if config.get("SMTP_USE_SSL"): server = smtplib.SMTP_SSL(host, port, timeout=timeout) else: server = smtplib.SMTP(host, port, timeout=timeout) with server: server.ehlo() if config.get("SMTP_USE_TLS") and not config.get("SMTP_USE_SSL"): server.starttls() server.ehlo() if config.get("SMTP_USER"): server.login(config["SMTP_USER"], config.get("SMTP_PASSWORD", "")) server.send_message(msg) mail_log.info("Mail sent to %s: %s", to, msg["Subject"]) return True except Exception as exc: # noqa: BLE001 - mail must never break the request mail_log.error("Mail to %s failed: %s", to, exc) return False def send_email_async(config, to, subject, body, reply_to=None, reply_name=None): """Fire-and-forget wrapper. `config` is copied to a plain dict first so the thread never touches the Flask app/request context.""" cfg = {k: config.get(k) for k in ( "SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_USE_TLS", "SMTP_USE_SSL", "SMTP_TIMEOUT", "MAIL_FROM", "MAIL_SUBJECT_PREFIX", )} thread = threading.Thread( target=send_email, args=(cfg, to, subject, body), kwargs={"reply_to": reply_to, "reply_name": reply_name}, daemon=True, ) thread.start() return thread