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.
152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Standalone tests for backend/smtp_send.py — config parsing + send paths.
|
|
|
|
No network: smtplib.SMTP / SMTP_SSL are monkeypatched with a fake that records
|
|
the calls. Run directly or via backend/run_tests.py.
|
|
"""
|
|
import os
|
|
import smtplib
|
|
|
|
import smtp_send
|
|
|
|
FAILS = []
|
|
|
|
|
|
def check(cond, label):
|
|
print(f" {'ok' if cond else 'XX'} {label}")
|
|
if not cond:
|
|
FAILS.append(label)
|
|
|
|
|
|
class FakeServer:
|
|
def __init__(self, kind, log):
|
|
self.kind, self.log = kind, log
|
|
self.logged_in = None
|
|
self.sent = []
|
|
|
|
def ehlo(self, *a):
|
|
self.log.append(("ehlo", self.kind))
|
|
|
|
def starttls(self, *a, **k):
|
|
self.log.append(("starttls", self.kind))
|
|
|
|
def login(self, user, pw):
|
|
self.logged_in = (user, pw)
|
|
self.log.append(("login", user))
|
|
|
|
def send_message(self, msg, from_addr=None, to_addrs=None):
|
|
self.sent.append({"from_addr": from_addr, "to_addrs": to_addrs})
|
|
self.log.append(("send", from_addr))
|
|
|
|
def quit(self):
|
|
self.log.append(("quit", self.kind))
|
|
|
|
|
|
def install_fakes():
|
|
"""Patch smtplib; return (log, holder) where holder['last'] is the server."""
|
|
log, holder = [], {}
|
|
|
|
def mk(kind):
|
|
def factory(host, port, timeout=None, context=None):
|
|
log.append((kind, host, port))
|
|
srv = FakeServer(kind, log)
|
|
holder["last"] = srv
|
|
return srv
|
|
return factory
|
|
|
|
smtplib.SMTP = mk("SMTP")
|
|
smtplib.SMTP_SSL = mk("SMTP_SSL")
|
|
return log, holder
|
|
|
|
|
|
def set_env(**kw):
|
|
for k in ("SMTP_HOST", "SMTP_PORT", "SMTP_FROM", "SMTP_USERNAME",
|
|
"SMTP_PASSWORD", "SMTP_SECURITY"):
|
|
os.environ.pop(k, None)
|
|
for k, v in kw.items():
|
|
os.environ["SMTP_" + k.upper()] = str(v)
|
|
|
|
|
|
def main():
|
|
# 1. unconfigured
|
|
set_env()
|
|
check(not smtp_send.smtp_configured(), "smtp_configured() false when unset")
|
|
try:
|
|
smtp_send.load_smtp_config(); ok = False
|
|
except smtp_send.SMTPNotConfigured:
|
|
ok = True
|
|
check(ok, "load_smtp_config raises SMTPNotConfigured without host")
|
|
|
|
# 2. parse + normalize garbage
|
|
set_env(host="smtp.x.com", port="nope", security="WEIRD", username="u@x", password="p")
|
|
os.environ["SMTP_FROM"] = "f@x"
|
|
cfg = smtp_send.load_smtp_config()
|
|
check(cfg["host"] == "smtp.x.com", "host parsed")
|
|
check(cfg["port"] == 587, "non-numeric port falls back to 587")
|
|
check(cfg["security"] == "starttls", "unknown security falls back to starttls")
|
|
|
|
# 3. STARTTLS path: plain SMTP, upgrade, auth, multi-recipient
|
|
log, holder = install_fakes()
|
|
set_env(host="h", port="587", security="starttls", username="u@x", password="pw")
|
|
os.environ["SMTP_FROM"] = "f@x"
|
|
res = smtp_send.send_email(["a@x.com", "b@x.com"], "s", "body")
|
|
srv = holder["last"]
|
|
check(log[0] == ("SMTP", "h", 587), "starttls connects via plain SMTP")
|
|
check(("starttls", "SMTP") in log, "starttls negotiated")
|
|
check(srv.logged_in == ("u@x", "pw"), "login with username/password")
|
|
check(srv.sent[0]["from_addr"] == "f@x", "from = SMTP_FROM")
|
|
check(srv.sent[0]["to_addrs"] == ["a@x.com", "b@x.com"], "recipients passed through")
|
|
check(res["sent_to"] == ["a@x.com", "b@x.com"] and res["from"] == "f@x", "result reports send")
|
|
|
|
# 4. implicit TLS path: SMTP_SSL, no STARTTLS
|
|
log, holder = install_fakes()
|
|
set_env(host="h", port="465", security="tls", username="u@x", password="pw")
|
|
os.environ["SMTP_FROM"] = "f@x"
|
|
smtp_send.send_email("a@x.com", "s", "b")
|
|
check(log[0][0] == "SMTP_SSL", "tls uses SMTP_SSL")
|
|
check(all(e[0] != "starttls" for e in log), "no STARTTLS on implicit TLS")
|
|
|
|
# 5. security=none, from falls back to username
|
|
log, holder = install_fakes()
|
|
set_env(host="h", port="25", security="none", username="relay@x", password="")
|
|
smtp_send.send_email("a@x.com", "s", "b")
|
|
srv = holder["last"]
|
|
check(all(e[0] != "starttls" for e in log), "no STARTTLS when security=none")
|
|
check(srv.sent[0]["from_addr"] == "relay@x", "from falls back to username")
|
|
|
|
# 6. no username -> no login attempted
|
|
log, holder = install_fakes()
|
|
set_env(host="h", port="25", security="none")
|
|
os.environ["SMTP_FROM"] = "f@x"
|
|
smtp_send.send_email("a@x.com", "s", "b")
|
|
check(holder["last"].logged_in is None, "no login when username blank")
|
|
|
|
# 7. no sender at all -> SMTPNotConfigured
|
|
set_env(host="h", port="25", security="none")
|
|
try:
|
|
smtp_send.send_email("a@x.com", "s", "b"); ok = False
|
|
except smtp_send.SMTPNotConfigured:
|
|
ok = True
|
|
check(ok, "no from/username raises SMTPNotConfigured")
|
|
|
|
# 8. empty recipients -> ValueError
|
|
set_env(host="h", port="587", security="starttls", username="u", password="p")
|
|
os.environ["SMTP_FROM"] = "f@x"
|
|
try:
|
|
smtp_send.send_email([], "s", "b"); ok = False
|
|
except ValueError:
|
|
ok = True
|
|
check(ok, "empty recipients raises ValueError")
|
|
|
|
print()
|
|
if FAILS:
|
|
print(f"FAILED ({len(FAILS)}):")
|
|
for f in FAILS:
|
|
print(f" - {f}")
|
|
raise SystemExit(1)
|
|
print("ALL PASS (smtp_send)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|