2758ac81d3
Groundwork for the daily activity digest: give the CRM an outbound mail path. Today nothing leaves the box (Gmail capture + drafts only), so this adds a dedicated, per-package SMTP account independent of any StartOS system-wide SMTP. - configureDigestSmtp Start9 action: writes host/port/from/username/password/ security to /data/secrets/smtp/* (password piped over stdin, never argv/env; per-field files, owner-only) — mirrors the setAnthropicApiKey pattern. - docker_entrypoint.sh reads those at boot and exports SMTP_* (operator env wins). - backend/smtp_send.py: stdlib smtplib wrapper reading SMTP_* (one code path for dev .env and the box); starttls/tls/none modes. - POST /api/admin/digest/test-email (admin-only): proves the pipe. Recipients are restricted to the active-admin set — an arbitrary `to` is rejected, so the endpoint is not an open relay; send failures are logged, not echoed (an SMTP auth error can carry the credential). - Tests: test_smtp_send.py (sender), test_smtp_endpoint.py (gating + relay restriction + no-leak). 18/18 backend green; s9pk typechecks. Analysis/summarization for the digest body (Phase B) will run on Spark, never Claude — the digest is deliberately un-anonymized. Decisions + Phase B plan in ROADMAP.md.
100 lines
3.7 KiB
Python
100 lines
3.7 KiB
Python
"""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}
|