Repurpose Communications tab as admin-only email-activity panel (v0.1.0:80)
The Communications tab is now an admin-only search over captured Gmail
(email_* tables), part of consolidating on the fundraising grid + email
capture as the canonical system of record.
- New GET /api/email/activity (admin-enforced server-side): filter by
investor / mailbox / direction with free-text search over subject,
snippet, and sender. Query logic in db.query_email_activity.
- Soft-delete honored on the per-mailbox sighting (emails carry no
deleted_at; deletion lives on email_account_messages).
- Direction decided at the email level (outbound if the sender is one of
our mailboxes), mirroring digest_builder.
- Graveyard investors are hidden from the filter dropdown (CRM-wide
graveyard=0 convention) but their email stays visible in the list and
findable by free-text search — this is an audit surface.
- Communications page rewritten to render the panel; the classic manual
"Log Communication" form is retired (the grid context menu remains the
manual-log path). Nav item + page are admin-only.
- Tests: email_integration/test_email_activity_panel.py (filters,
per-sighting soft-delete, roll-ups, graveyard handling, route 401/403);
full suite 22/22. Frontend render verified via a jsdom mount smoke test
plus the pinned classic-runtime Babel transform.
Code-only, no schema migration (version migrations are no-ops).
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test the admin-only email-activity panel (Communications tab, v0.1.0:80).
|
||||
|
||||
Covers the pure query (`db.query_email_activity`): investor/mailbox/search/direction
|
||||
filters, per-sighting soft-delete, direction at the email level, mailbox + investor
|
||||
roll-ups (incl. unmatched fallback to the matched address), and the filter facets.
|
||||
Also asserts the route handler enforces admin server-side. Synthetic data only.
|
||||
|
||||
Run: cd backend && python3 email_integration/test_email_activity_panel.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from email_integration import db as _db # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
def make_db():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.executescript("""
|
||||
CREATE TABLE email_accounts (id TEXT PRIMARY KEY, email_address TEXT);
|
||||
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, from_name TEXT, from_email TEXT,
|
||||
sent_at TEXT, snippet TEXT, has_attachments INT DEFAULT 0, is_matched INT DEFAULT 0,
|
||||
match_status TEXT DEFAULT 'unmatched');
|
||||
CREATE TABLE email_account_messages (id TEXT PRIMARY KEY, email_id TEXT, account_id TEXT,
|
||||
is_sent INT DEFAULT 0, deleted_at TEXT);
|
||||
CREATE TABLE email_investor_links (id TEXT PRIMARY KEY, email_id TEXT,
|
||||
fundraising_investor_id TEXT, fundraising_contact_id TEXT, matched_address TEXT);
|
||||
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, graveyard INTEGER DEFAULT 0);
|
||||
CREATE TABLE fundraising_contacts (id TEXT PRIMARY KEY, investor_id TEXT, full_name TEXT);
|
||||
""")
|
||||
# Two mailboxes (us), three investors (one reached only via a contact link;
|
||||
# one graveyarded but still with captured email history).
|
||||
conn.executemany("INSERT INTO email_accounts VALUES (?,?)", [
|
||||
("acc-grant", "grant@ten31.xyz"),
|
||||
("acc-jon", "jonathan@ten31.xyz"),
|
||||
])
|
||||
conn.executemany("INSERT INTO fundraising_investors VALUES (?,?,?)", [
|
||||
("inv-harbor", "Harbor & Vine", 0),
|
||||
("inv-pacific", "Pacific Capital", 0),
|
||||
("inv-dead", "Dead Deal LP", 1),
|
||||
])
|
||||
conn.execute("INSERT INTO fundraising_contacts VALUES ('fc-1','inv-pacific','Sarah Williams')")
|
||||
# Emails:
|
||||
# e1 outbound (from us) -> Harbor, seen by grant
|
||||
# e2 inbound (from LP) -> Harbor, seen by grant + jonathan
|
||||
# e3 inbound (from LP) -> Pacific via contact link, seen by jonathan
|
||||
# e4 inbound, UNMATCHED (no investor link), seen by grant
|
||||
# e5 inbound, only sighting is tombstoned -> must be excluded
|
||||
conn.executemany(
|
||||
"INSERT INTO emails (id,subject,from_name,from_email,sent_at,snippet,has_attachments,is_matched,match_status) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
("e1", "Fund III update", "Grant", "grant@ten31.xyz", "2026-06-05T10:00:00", "here is the deck", 1, 1, "matched"),
|
||||
("e2", "Re: Fund III update", "LP Harbor", "lp@harborvine.example", "2026-06-06T09:00:00", "thanks, one question", 0, 1, "matched"),
|
||||
("e3", "Intro", "Sarah Williams", "sarah@pacificcap.example", "2026-06-07T08:00:00", "would love to chat", 0, 1, "matched"),
|
||||
("e4", "Cold inbound", "Random", "noreply@spam.example", "2026-06-08T08:00:00", "buy now", 0, 0, "unmatched"),
|
||||
("e5", "Deleted thread", "Ghost", "ghost@x.example", "2026-06-09T08:00:00", "gone", 0, 1, "matched"),
|
||||
("e6", "Old dead-deal thread", "Dead LP", "lp@deaddeal.example", "2026-06-01T00:00:00", "we passed", 0, 1, "matched"),
|
||||
])
|
||||
conn.executemany(
|
||||
"INSERT INTO email_account_messages (id,email_id,account_id,is_sent,deleted_at) VALUES (?,?,?,?,?)",
|
||||
[
|
||||
("m1", "e1", "acc-grant", 1, None),
|
||||
("m2", "e2", "acc-grant", 0, None),
|
||||
("m3", "e2", "acc-jon", 0, None),
|
||||
("m4", "e3", "acc-jon", 0, None),
|
||||
("m5", "e4", "acc-grant", 0, None),
|
||||
("m6", "e5", "acc-grant", 0, "2026-06-10T00:00:00"), # tombstoned
|
||||
("m7", "e6", "acc-grant", 0, None),
|
||||
])
|
||||
conn.executemany(
|
||||
"INSERT INTO email_investor_links (id,email_id,fundraising_investor_id,fundraising_contact_id,matched_address) VALUES (?,?,?,?,?)",
|
||||
[
|
||||
("l1", "e1", "inv-harbor", None, "lp@harborvine.example"),
|
||||
("l2", "e2", "inv-harbor", None, "lp@harborvine.example"),
|
||||
("l3", "e3", None, "fc-1", "sarah@pacificcap.example"),
|
||||
("l5", "e5", "inv-harbor", None, "lp@harborvine.example"),
|
||||
("l6", "e6", "inv-dead", None, "lp@deaddeal.example"),
|
||||
])
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def ids(res):
|
||||
return [e["id"] for e in res["emails"]]
|
||||
|
||||
|
||||
def main():
|
||||
conn = make_db()
|
||||
|
||||
# --- baseline: live emails only, newest first, tombstoned excluded ---
|
||||
res = _db.query_email_activity(conn)
|
||||
check(ids(res) == ["e4", "e3", "e2", "e1", "e6"], f"live emails newest-first, e5 (tombstoned) excluded; got {ids(res)}")
|
||||
check(res["count"] == 5 and res["truncated"] is False, "count + not truncated")
|
||||
|
||||
# --- direction at the email level ---
|
||||
e1 = next(e for e in res["emails"] if e["id"] == "e1")
|
||||
e2 = next(e for e in res["emails"] if e["id"] == "e2")
|
||||
check(e1["direction"] == "outbound", "e1 from our mailbox -> outbound")
|
||||
check(e2["direction"] == "inbound", "e2 from LP -> inbound")
|
||||
check(_db.query_email_activity(conn, direction="outbound")["emails"][0]["id"] == "e1"
|
||||
and len(_db.query_email_activity(conn, direction="outbound")["emails"]) == 1,
|
||||
"direction=outbound returns only e1")
|
||||
check(ids(_db.query_email_activity(conn, direction="inbound")) == ["e4", "e3", "e2", "e6"],
|
||||
"direction=inbound excludes the outbound e1")
|
||||
|
||||
# --- mailbox roll-up + per-account filter ---
|
||||
check(set(e2["mailboxes"]) == {"grant@ten31.xyz", "jonathan@ten31.xyz"}, "e2 seen by both mailboxes")
|
||||
check(ids(_db.query_email_activity(conn, account_id="acc-jon")) == ["e3", "e2"],
|
||||
"account_id=acc-jon returns only emails that mailbox saw")
|
||||
|
||||
# --- investor filter: direct link and via-contact link ---
|
||||
check(set(ids(_db.query_email_activity(conn, investor_id="inv-harbor"))) == {"e2", "e1"},
|
||||
"investor_id=inv-harbor -> e1,e2")
|
||||
check(ids(_db.query_email_activity(conn, investor_id="inv-pacific")) == ["e3"],
|
||||
"investor_id=inv-pacific resolved through fundraising_contacts -> e3")
|
||||
|
||||
# --- investor name roll-up + unmatched fallback ---
|
||||
check(e1["investors"] == [{"id": "inv-harbor", "name": "Harbor & Vine"}], "e1 investor resolved to name")
|
||||
e3 = next(e for e in res["emails"] if e["id"] == "e3")
|
||||
check(e3["investors"] == [{"id": "inv-pacific", "name": "Pacific Capital"}], "e3 investor resolved via contact")
|
||||
e4 = next(e for e in res["emails"] if e["id"] == "e4")
|
||||
check(e4["investors"] == [] and e4["investor_labels"] == [], "e4 unmatched -> no investor, no link")
|
||||
|
||||
# --- free-text search over subject / snippet / sender ---
|
||||
check(set(ids(_db.query_email_activity(conn, search="Fund III"))) == {"e1", "e2"}, "search subject")
|
||||
check(ids(_db.query_email_activity(conn, search="pacificcap")) == ["e3"], "search sender address")
|
||||
check(ids(_db.query_email_activity(conn, search="buy now")) == ["e4"], "search snippet")
|
||||
|
||||
# --- facets ---
|
||||
check([a["email_address"] for a in res["accounts"]] == ["grant@ten31.xyz", "jonathan@ten31.xyz"],
|
||||
"accounts facet sorted")
|
||||
facet_inv = {i["id"] for i in res["investors"]}
|
||||
check(facet_inv == {"inv-harbor", "inv-pacific"}, "investor facet covers direct + via-contact activity")
|
||||
|
||||
# --- graveyard: hidden from the picker, but its email stays visible + findable ---
|
||||
check("inv-dead" not in facet_inv, "graveyard investor excluded from the facet dropdown")
|
||||
check("e6" in ids(res), "graveyard investor's email still shows in the unfiltered list (audit completeness)")
|
||||
e6 = next(e for e in res["emails"] if e["id"] == "e6")
|
||||
check(e6["investors"] == [{"id": "inv-dead", "name": "Dead Deal LP"}], "graveyard email still shows its investor chip")
|
||||
check(ids(_db.query_email_activity(conn, investor_id="inv-dead")) == ["e6"],
|
||||
"explicit investor_id filter still works for a graveyard investor")
|
||||
check(ids(_db.query_email_activity(conn, search="deaddeal")) == ["e6"],
|
||||
"graveyard email remains findable by free-text search")
|
||||
|
||||
# --- truncation ---
|
||||
tr = _db.query_email_activity(conn, limit=2)
|
||||
check(tr["count"] == 2 and tr["truncated"] is True, "limit=2 -> truncated")
|
||||
|
||||
conn.close()
|
||||
|
||||
# --- route enforces admin server-side ---
|
||||
test_route_admin_only()
|
||||
|
||||
if FAILS:
|
||||
print(f"\nFAILED ({len(FAILS)})")
|
||||
for f in FAILS:
|
||||
print(" - " + f)
|
||||
sys.exit(1)
|
||||
print("\nALL PASS (email-activity panel)")
|
||||
|
||||
|
||||
def test_route_admin_only():
|
||||
try:
|
||||
from email_integration import routes
|
||||
except Exception as e: # pragma: no cover - optional deps missing in some dev envs
|
||||
print(f" SKIP route admin test (routes import failed: {e})")
|
||||
return
|
||||
|
||||
class FakeHandler:
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
self.json = None
|
||||
self.err = None
|
||||
self.code = None
|
||||
|
||||
def get_user(self):
|
||||
return self._user
|
||||
|
||||
def get_query_params(self):
|
||||
return {}
|
||||
|
||||
def send_json(self, obj):
|
||||
self.json = obj
|
||||
|
||||
def send_error_json(self, msg, code):
|
||||
self.err = msg
|
||||
self.code = code
|
||||
|
||||
h = FakeHandler(None)
|
||||
routes._h_activity(h)
|
||||
check(h.code == 401 and h.json is None, "route: no user -> 401")
|
||||
|
||||
h = FakeHandler({"role": "member", "user_id": "u1"})
|
||||
routes._h_activity(h)
|
||||
check(h.code == 403 and h.json is None, "route: member -> 403 (admin enforced server-side)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user