85 lines
3.2 KiB
Python
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
|