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.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user