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
+66
View File
@@ -283,6 +283,70 @@ def test_scheduler_guards():
conn.close()
def test_window_resolver():
from datetime import timedelta
nu = datetime(2026, 6, 16, 15, 0, tzinfo=timezone.utc)
nl = datetime(2026, 6, 16, 8, 0, tzinfo=timezone(timedelta(hours=-7))) # PDT
s, u = digest_builder.resolve_digest_window(now_utc=nu, now_local=nl)
check((s, u) == ("2026-06-15T15:00:00Z", "2026-06-16T15:00:00Z"), f"default = last 24h, got {(s,u)}")
s, u = digest_builder.resolve_digest_window(hours=48, now_utc=nu, now_local=nl)
check(s == "2026-06-14T15:00:00Z", f"hours=48 lookback, got {s}")
# since = a local calendar date -> that day's LOCAL midnight, expressed in UTC
s, u = digest_builder.resolve_digest_window(since="2026-06-01", now_utc=nu, now_local=nl)
check(s == "2026-06-01T07:00:00Z", f"since-date -> local midnight in UTC, got {s}")
# a since older than the cap clamps to MAX_WINDOW_DAYS (response echoes real window)
s, u = digest_builder.resolve_digest_window(since="2025-01-01", now_utc=nu, now_local=nl)
check(s == (nu - timedelta(days=digest_builder.MAX_WINDOW_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ"),
f"over-cap since clamps to {digest_builder.MAX_WINDOW_DAYS}d, got {s}")
# since wins over hours when both supplied
s, u = digest_builder.resolve_digest_window(hours=1, since="2026-06-10", now_utc=nu, now_local=nl)
check(s.startswith("2026-06-10"), f"since wins over hours, got {s}")
# same-day boundary: since = today's local date, now later in the day -> valid
# window (local midnight is strictly before now), not a "start must be before now" raise
s, u = digest_builder.resolve_digest_window(since="2026-06-16", now_utc=nu, now_local=nl)
check(s == "2026-06-16T07:00:00Z" and u == "2026-06-16T15:00:00Z",
f"since=today -> [local midnight, now], got {(s, u)}")
for bad in [dict(hours=0), dict(hours="abc"), dict(since="nope"), dict(since="2027-01-01")]:
try:
digest_builder.resolve_digest_window(now_utc=nu, now_local=nl, **bad)
check(False, f"bad input {bad} should raise")
except ValueError:
check(True, f"bad input rejected: {bad}")
def test_send_digest_window():
sent = []
build_fn = lambda conn, since, until: {"subject": "S", "body": f"{since}|{until}",
"has_activity": True, "user_count": 1,
"email_count": 2, "investor_count": 1}
def send_fn(conn, to_addrs, subject, body, sender_email=None):
sent.append((list(to_addrs), body))
return {"transport": "stub"}
conn = _conn()
before = digest_scheduler._get_setting(conn, digest_scheduler._LAST_AT_KEY)
conn.close()
r = digest_scheduler.send_digest_window(_conn, since_iso="2026-05-01T00:00:00Z",
until_iso="2026-06-16T00:00:00Z",
build_fn=build_fn, send_fn=send_fn)
check(r["status"] == "sent" and r["window"] == ["2026-05-01T00:00:00Z", "2026-06-16T00:00:00Z"],
f"windowed send returns its window, got {r}")
check(sent and sent[-1][0] == ["grant@ten31.xyz"], f"windowed send -> active admins only, got {sent}")
conn = _conn()
after = digest_scheduler._get_setting(conn, digest_scheduler._LAST_AT_KEY)
conn.close()
check(before == after, "windowed manual send does not advance the daily cursor")
def main():
setup()
print("collect_user_activity:"); test_collect()
@@ -290,6 +354,8 @@ def main():
print("build_digest + empty:"); test_build_and_empty()
print("summary fallback:"); test_summary_fallback()
print("digest policy:"); test_policy()
print("window resolver:"); test_window_resolver()
print("windowed manual send:"); test_send_digest_window()
print("scheduler guards:"); test_scheduler_guards()
if FAILS:
print(f"\nFAILED ({len(FAILS)})")