323f016f64
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.
5.6 KiB
5.6 KiB
paths
| paths | |||
|---|---|---|---|
|
Email capture & drafts (Gmail)
Read this before editing Gmail capture or draft creation.
What it does
backend/email_integration/captures Gmail via domain-wide delegation (credentials.py,matcher.py,parser.py,db.py,sync.py,scheduler.py,routes.py) and creates Tier-B in-thread drafts (compose.py). It has its ownmigrations/.- Captured email becomes CRM activity through a propose → approve flow — nothing lands on a contact record until a human approves the proposal.
Hard rule
- Agents draft; humans send. Never let an agent send email, post, or contact an LP autonomously. Tier-B
compose.pyonly creates a Gmail draft for human review.
Outbound mail — the daily digest (internal; exempt from "agents draft")
The CRM sends an internal daily activity digest to the fund's own admins. This is the ONE automated send path, and it does not violate the hard rule above: that rule governs outward LP/prospect contact. An internal ops email to the team's own inboxes is a different category. Never extend this path to send to LPs/prospects.
- Transport selector:
backend/digest_mailer.py(top-level, not in this package) —send_digest(conn, to_addrs, subject, body)picks Gmail-DWD (preferred) → SMTP (fallback). DWD-impersonation sender =CRM_DIGEST_SENDERenv, else the first active admin. - Gmail-DWD path:
gmail_send.py(this package) — reusescredentials.py'sDWDCredentialProviderwith thegmail.composescope to callusers.messages.send(REST, mirrorscompose.py; body is{raw}not the draft's{message:{raw}}). The deployment's DWD grant includesgmail.compose(which authorizes send) but not the narrowgmail.send— so requestgmail.compose. Verified live 2026-06-15 (token mint + a realmessages.send). - SMTP fallback:
backend/smtp_send.py(top-level) — stdlib smtplib readingSMTP_*env, populated on the box by the Configure Digest SMTP Start9 action (writes/data/secrets/smtp/*; entrypoint exportsSMTP_*). A dedicated per-package account, independent of any StartOS system-wide SMTP. - The admin
POST /api/admin/digest/test-emailrestricts recipients to the active-admin set (not an open relay), and logs send failures rather than echoing them (an auth error can carry a token/credential).
Phase B — the daily digest itself (built)
- Content builder:
backend/digest_builder.py(top-level).build_digest(conn, since_iso, until_iso, chat_fn=None)returns{subject, body, has_activity, user_count, email_count, investor_count}and composes two sections:- By team member —
collect_user_activity: per registered user, both directions (per-mailboxeam.is_sent), with one Spark narrative paragraph per user (ingest/llm.py→ Spark Control/v1/chat/completions), never Claude (the digest is deliberately un-anonymized — real LP names + substance stay local). Deterministic count-only fallback if Spark is unreachable (always-send must not fail). - By investor —
collect_investor_activity: re-pivots the same window across the whole team, deduped per email (a reply to several teammates counts once), direction decided at the email level (outbound iffrom_emailis one of our mailboxes, else inbound). Structured list, no extra Spark calls. - Soft-delete filters:
email_account_messages.deleted_at IS NULL+users.is_active = 1, and the org/contact name joins drop soft-deleted rows (falling back to the matched address).
- By team member —
- Control is DB-backed, set from the admin panel —
digest_builder.load_digest_policy(conn)readsapp_settings.digest_policy={enabled, send_hour}. Precedence: DB row wins (the Settings → Admin toggle + send-time dropdown), elseCRM_DIGEST_ENABLED/CRM_DIGEST_SEND_HOURseed a first-boot default, else{false, 18}.GET/PATCH /api/admin/digest/policy(admin-only) read/write it. Not a StartOS action — it's an operational toggle, so it lives in-app where it's discoverable and takes effect live. - Scheduler:
backend/email_integration/digest_scheduler.py(co-located with the sync scheduler). One daemon thread, always started; each cycle (60s) re-reads the DB policy and sends once per local day at/aftersend_houronly whenenabled— so toggling in the panel takes effect with no restart. Content window = (last send, now]; cursor (digest_last_sent_at) + once-per-day guard (digest_last_sent_date) live inapp_settings, so a missed day rolls into the next digest. Recipients = all active admins. - On-demand:
POST /api/admin/digest/send-now(admin-only) →maybe_send_digest(force=True)builds the real last-24h digest and sends to the admin set regardless of the policy and without touching the daily cursor (a preview never suppresses the scheduled send). Surfaced as a "Send Digest Now" button in Settings → Admin, beside "Send Test Digest Email". - Decisions (locked): 6 PM default send · always-send (empty days get a "no activity"
note) · per-user narrative + by-investor structured section · enable/time controlled in the
admin panel. Tests:
backend/test_digest_builder.py(per-user + per-investor queries, soft-delete, inbound dedup, two-section compose, fallback, policy resolver, scheduler guards — stubbed LLM + transport).
Known gap
- Tier-B drafts currently reply to the LP only; reply-all is the next change (see AGENTS.md → Current state).
See also docs/gmail-enablement-runbook.md.