Add daily activity digest — Phase B (v0.1.0:77)

Sends a once-a-day internal email to all active admins summarizing each team
member's email activity per investor, plus a team-wide by-investor view
(inbound + outbound, deduped). Narratives are generated on the LOCAL Spark
model, never Claude — the digest is intentionally un-anonymized, so substance
stays on Ten31 infra. This is an internal ops email, exempt from the
'agents draft, humans send' rule (which governs outward LP contact).

- backend/digest_builder.py: per-user + per-investor activity queries
  (soft-delete filtered), per-user Spark narrative with a deterministic
  fallback, two-section plain-text body, and the DB-backed policy resolver.
- backend/email_integration/digest_scheduler.py: always-on daily thread that
  re-reads the policy each cycle and sends once/day; window cursor in
  app_settings so a missed day rolls forward.
- server.py: POST /api/admin/digest/send-now and GET/PATCH
  /api/admin/digest/policy; scheduler wired into main().
- Control lives in Settings -> Admin (enable toggle + send-time dropdown),
  not StartOS actions; env vars only seed the first-boot default.
- Tests: backend/test_digest_builder.py.
This commit is contained in:
Keysat
2026-06-15 22:32:27 -05:00
parent 036226ed74
commit 323f016f64
12 changed files with 1113 additions and 19 deletions
+73
View File
@@ -1817,6 +1817,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_list_fundraising_backups(user)
if path == '/api/fundraising/backup-policy':
return self.handle_get_backup_policy(user)
if path == '/api/admin/digest/policy':
return self.handle_get_digest_policy(user)
if path == '/api/fundraising/relational-summary':
return self.handle_get_fundraising_relational_summary(user)
if path == '/api/fundraising/automations':
@@ -1921,6 +1923,8 @@ class CRMHandler(BaseHTTPRequestHandler):
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/admin/digest/send-now':
return self.handle_admin_send_digest_now(user, body)
if path == '/api/fundraising/backup':
return self.handle_backup_fundraising_state(user)
if path == '/api/fundraising/restore-preview':
@@ -2019,6 +2023,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_admin_update_user(user, target_user_id, body)
if path == '/api/fundraising/backup-policy':
return self.handle_update_backup_policy(user, body)
if path == '/api/admin/digest/policy':
return self.handle_update_digest_policy(user, body)
if re.match(r'^/api/fundraising/automations/[^/]+$', path):
rule_id = path.split('/')[-1]
return self.handle_update_fundraising_automation_rule(user, rule_id, body)
@@ -4292,6 +4298,63 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.send_json({"data": {"status": "sent", **result}})
def handle_admin_send_digest_now(self, user, body):
"""Build the REAL daily activity digest (last 24h) on demand and send it to
the active-admin set now. An on-demand preview of Phase B — it does not
touch the daily schedule's cursor, so it never suppresses the scheduled send.
Content is summarized on Spark (local), never Claude."""
if not require_admin(user):
return self.send_error_json("Admin only", 403)
import digest_mailer
try:
from email_integration.digest_scheduler import maybe_send_digest
result = maybe_send_digest(force=True)
except digest_mailer.NoTransport as exc:
return self.send_error_json(str(exc), 400)
except Exception as exc:
# Never echo the exception — an auth error can carry a token/credential.
print(f"[digest] send-now 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": result})
def handle_get_digest_policy(self, user):
"""Return the live daily-digest policy (enabled + send hour). DB-backed
(app_settings), set from this same panel — see digest_builder.load_digest_policy."""
if not require_admin(user):
return self.send_error_json("Admin access required", 403)
import digest_builder
conn = get_db()
try:
return self.send_json({"data": digest_builder.load_digest_policy(conn)})
finally:
conn.close()
def handle_update_digest_policy(self, user, body):
"""Update the daily-digest policy. Takes effect on the scheduler's next
cycle (no restart). Recipients stay the active-admin set; sender/transport
are env/StartOS config, not toggled here."""
if not require_admin(user):
return self.send_error_json("Admin access required", 403)
import digest_builder
conn = get_db()
try:
policy = digest_builder.load_digest_policy(conn)
if 'enabled' in body:
policy['enabled'] = bool(body.get('enabled'))
if 'send_hour' in body:
try:
policy['send_hour'] = max(0, min(23, int(body.get('send_hour'))))
except (ValueError, TypeError):
return self.send_error_json("send_hour must be an integer from 0 to 23")
normalized = {"enabled": bool(policy['enabled']), "send_hour": int(policy['send_hour'])}
set_app_setting(conn, digest_builder.DIGEST_POLICY_KEY, normalized)
conn.commit()
return self.send_json({"data": normalized})
finally:
conn.close()
def handle_list_audit_log(self, user, params):
if not require_admin(user):
return self.send_error_json("Admin access required", 403)
@@ -5425,6 +5488,16 @@ def main():
except Exception as _e:
print(f"[email_integration] failed to start scheduler: {_e}")
# ─── Daily activity digest scheduler ─────────────────────────────
# Always started; it reads the digest policy (enabled + send hour) from the DB
# each cycle, so the Settings → Admin toggle controls it live (no restart).
try:
from email_integration.digest_scheduler import start_digest_scheduler
start_digest_scheduler()
print("[digest] daily activity digest scheduler started (policy-controlled)")
except Exception as _e:
print(f"[digest] failed to start digest scheduler: {_e}")
# ThreadingHTTPServer lets one slow request (or a wave of scanner probes)
# not block legit users. SQLite is opened per-request via get_db(), and
# WAL mode allows concurrent readers + a single writer, so this is safe.