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:
Keysat
2026-06-15 18:33:06 -05:00
parent ecfc5d968a
commit 2758ac81d3
13 changed files with 765 additions and 14 deletions
+63
View File
@@ -1919,6 +1919,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_admin_create_user(user, body)
if path == '/api/admin/reset-all-data':
return self.handle_admin_reset_all_data(user, body)
if path == '/api/admin/digest/test-email':
return self.handle_admin_send_test_email(user, body)
if path == '/api/fundraising/backup':
return self.handle_backup_fundraising_state(user)
if path == '/api/fundraising/restore-preview':
@@ -4234,6 +4236,67 @@ class CRMHandler(BaseHTTPRequestHandler):
}
})
def handle_admin_send_test_email(self, user, body):
"""Send a test email through the configured digest SMTP account, to prove
the outbound pipe before the daily digest itself is built (Phase A)."""
if not require_admin(user):
return self.send_error_json("Admin only", 403)
from smtp_send import send_email, smtp_configured, SMTPNotConfigured
if not smtp_configured():
return self.send_error_json(
"SMTP is not configured. Set it via the 'Configure Digest SMTP' "
"Start9 action (or SMTP_* in .env for dev), then restart.", 400)
# Recipients are restricted to the active-admin set (the real digest
# audience). An explicit `to` may NARROW to specific admins but can never
# introduce an outside address — this endpoint is not an open mail relay.
conn = get_db()
try:
rows = conn.execute(
"SELECT email FROM users WHERE role = 'admin' AND is_active = 1 "
"AND email IS NOT NULL AND TRIM(email) != ''"
).fetchall()
finally:
conn.close()
admin_emails = [str(r['email']).strip() for r in rows if str(r['email']).strip()]
admin_lower = {e.lower() for e in admin_emails}
to = body.get('to')
if to:
requested = [to] if isinstance(to, str) else list(to)
requested = [str(e).strip() for e in requested if str(e).strip()]
outside = [e for e in requested if e.lower() not in admin_lower]
if outside:
return self.send_error_json(
"Test email may only go to an active admin address; "
f"not allowed: {', '.join(outside)}", 400)
recipients = requested
else:
recipients = admin_emails
if not recipients:
return self.send_error_json(
"No recipient: give an active admin an email address first.", 400)
subject = "Ten31 CRM — test digest email"
email_body = (
"This is a test message from the Ten31 CRM daily-digest mailer.\n\n"
f"Triggered by {user.get('full_name') or user.get('user_id')} at {now()}.\n\n"
"If you received this, outbound SMTP works and the daily digest can be "
"delivered to this address."
)
try:
result = send_email(recipients, subject, email_body)
except SMTPNotConfigured as exc:
return self.send_error_json(f"SMTP not configured: {exc}", 400)
except Exception as exc:
# Never echo the exception to the client — an SMTP auth error can carry
# the server's reply (and potentially the credential). Log it instead.
print(f"[smtp] test send failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return self.send_error_json("Send failed — see server logs for details.", 502)
return self.send_json({"data": {"status": "sent", **result}})
def handle_list_audit_log(self, user, params):
if not require_admin(user):
return self.send_error_json("Admin access required", 403)