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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user