Email search/query + windowed digest preview (v0.1.0:83)

Communications tab (search/query roadmap items 1 & 2):
- Fix the investor dropdown: the facet only listed grid investors, so it
  came back empty whenever email matched a classic contact or org domain
  (no grid id — the common case). It now mirrors the email list, resolving
  each link to a typed identity (fund:/org:/contact:/addr:) with precedence
  grid -> org -> contact -> address; investor_id accepts the typed key
  (bare id = fund: for back-compat) and an unknown prefix matches nothing.
- Add a date-range filter and a click-to-expand full-body view
  (GET /api/email/detail, admin, soft-delete-gated; body_text only, never
  raw remote HTML).
- Add a "Search content" mode: GET /api/email/search wraps the ingest
  hybrid_search over the Qdrant email index (doc_type=email), hydrated and
  soft-delete-filtered against SQLite (canonical), 503 if Spark/Qdrant down.

Daily digest:
- Settings -> Admin builds a digest over a chosen window (last 24h or since
  a date) as an in-app preview before sending (POST /api/admin/digest/preview),
  so the local-Spark summarizer can be verified on demand even on a quiet day.
  Manual send uses the same window; neither advances the daily cursor, so a
  preview never suppresses the scheduled digest.

Code-only, migrations no-op. 22/22 backend tests, render-smoke pass.
This commit is contained in:
Keysat
2026-06-16 20:46:15 -05:00
parent c29ac2f2ee
commit c7b74a2704
14 changed files with 989 additions and 138 deletions
+43 -9
View File
@@ -1914,6 +1914,8 @@ class CRMHandler(BaseHTTPRequestHandler):
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/admin/digest/preview':
return self.handle_admin_digest_preview(user, body)
if path == '/api/fundraising/backup':
return self.handle_backup_fundraising_state(user)
if path == '/api/fundraising/restore-preview':
@@ -4140,18 +4142,50 @@ 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."""
def handle_admin_digest_preview(self, user, body):
"""Build the activity digest over a chosen window and return it WITHOUT
sending — the admin-panel preview. Window defaults to the last 24h, or
{hours: N} / {since: 'YYYY-MM-DD'} (a local date -> that day's midnight).
Runs the REAL Spark summarization, so widening the window is how you verify
the summarizer on a quiet day. Never touches the daily cursor."""
if not require_admin(user):
return self.send_error_json("Admin only", 403)
import digest_mailer
body = body or {}
import digest_builder
try:
from email_integration.digest_scheduler import maybe_send_digest
result = maybe_send_digest(force=True)
since_iso, until_iso = digest_builder.resolve_digest_window(
hours=body.get('hours'), since=body.get('since'))
except ValueError as exc:
return self.send_error_json(str(exc), 400)
conn = get_db()
try:
digest = digest_builder.build_digest(conn, since_iso, until_iso)
except Exception as exc:
print(f"[digest] preview failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return self.send_error_json("Preview failed — see server logs for details.", 502)
finally:
conn.close()
return self.send_json({"data": {**digest, "window": [since_iso, until_iso]}})
def handle_admin_send_digest_now(self, user, body):
"""Build the REAL activity digest over a chosen window and send it to the
active-admin set now. Window defaults to the last 24h, or {hours: N} /
{since: 'YYYY-MM-DD'} — same resolution as the preview. 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)
body = body or {}
import digest_mailer
import digest_builder
try:
since_iso, until_iso = digest_builder.resolve_digest_window(
hours=body.get('hours'), since=body.get('since'))
except ValueError as exc:
return self.send_error_json(str(exc), 400)
try:
from email_integration.digest_scheduler import send_digest_window
result = send_digest_window(since_iso=since_iso, until_iso=until_iso)
except digest_mailer.NoTransport as exc:
return self.send_error_json(str(exc), 400)
except Exception as exc: