Files
Keysat 2758ac81d3 Add daily-digest Phase A: per-package SMTP send + admin test endpoint (v0.1.0:75)
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.
2026-06-15 18:33:06 -05:00

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}