"""Minimal outbound SMTP sender for the CRM (daily digest, test sends). Config comes ONLY from SMTP_* environment variables. Two ways those get set: * dev / bare run: a local .env (see .env.example). * Start9 box: docker_entrypoint.sh reads the files the "Configure Digest SMTP" StartOS action writes under /data/secrets/smtp/ and exports them as env. The backend never reads those files directly, so dev and prod share one path. This is the package's OWN dedicated mailbox (per-package custom SMTP) — it is independent of any StartOS system-wide SMTP account; nothing here calls into the platform. Stdlib only (smtplib/ssl/email), consistent with the rest of runtime. """ import os import smtplib import ssl from email.message import EmailMessage class SMTPNotConfigured(Exception): """Raised when SMTP_* env is absent — callers turn this into a clear 'not configured' response rather than a 500.""" def smtp_configured(): return bool(os.environ.get("SMTP_HOST", "").strip()) def load_smtp_config(): host = os.environ.get("SMTP_HOST", "").strip() if not host: raise SMTPNotConfigured("SMTP_HOST is not set") # Port/security come from a free-text action field; normalize defensively. try: port = int(str(os.environ.get("SMTP_PORT", "") or "587").strip()) except ValueError: port = 587 security = (os.environ.get("SMTP_SECURITY", "") or "starttls").strip().lower() if security not in ("starttls", "tls", "none"): security = "starttls" return { "host": host, "port": port, "from_addr": os.environ.get("SMTP_FROM", "").strip(), "username": os.environ.get("SMTP_USERNAME", "").strip(), "password": os.environ.get("SMTP_PASSWORD", ""), "security": security, } def _connect(cfg, timeout): """Open an authenticated SMTP connection per the configured security mode. 'tls' = implicit TLS (SMTPS, usually 465); 'starttls' = upgrade on 587; 'none' = plaintext (for a LAN relay that does its own transport security).""" if cfg["security"] == "tls": ctx = ssl.create_default_context() server = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=timeout, context=ctx) else: server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=timeout) server.ehlo() if cfg["security"] == "starttls": server.starttls(context=ssl.create_default_context()) server.ehlo() if cfg["username"]: server.login(cfg["username"], cfg["password"]) return server def send_email(to_addrs, subject, body, *, html=None, cfg=None, timeout=30): """Send one message. `to_addrs` is a str or list; `body` is plain text and `html` an optional HTML alternative. Returns {'sent_to', 'from'} on success; raises SMTPNotConfigured / ValueError / smtplib.SMTPException otherwise.""" cfg = cfg or load_smtp_config() if isinstance(to_addrs, str): to_addrs = [to_addrs] to_addrs = [a for a in (str(x).strip() for x in to_addrs) if a] if not to_addrs: raise ValueError("no recipients") from_addr = cfg["from_addr"] or cfg["username"] if not from_addr: raise SMTPNotConfigured("no SMTP_FROM or SMTP_USERNAME to use as sender") msg = EmailMessage() msg["From"] = from_addr msg["To"] = ", ".join(to_addrs) msg["Subject"] = subject msg.set_content(body) if html: msg.add_alternative(html, subtype="html") server = _connect(cfg, timeout) try: server.send_message(msg, from_addr=from_addr, to_addrs=to_addrs) finally: try: server.quit() except Exception: pass return {"sent_to": to_addrs, "from": from_addr}