Files

85 lines
3.2 KiB
Python

"""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