069e60053b
When a sent/received email is matched to an investor, a local-model agent drafts a
one-line dated note and queues it as a PENDING proposal (it never writes the grid
itself). On the Email Capture page a partner sees "Proposed grid notes", can edit the
text, and Approve (appends to that investor's grid notes cell, newest at bottom,
stamped with the approver) or Dismiss. Going-forward only: a cutoff (app_settings
email_activity_since, set on first run) means email dated before the feature was
enabled is never summarized, so the historical backfill makes no noise. Sovereign:
summaries run entirely on the local model (no redaction needed). Gmail sync interval
tightened 180 -> 15 min so outgoing email surfaces quickly.
Backend: migration 0002 (email_activity_proposals); propose_email_activity_notes()
runs via a new scheduler post_sync hook; list/decide functions + routes
GET /api/activity/proposals, POST .../{id}/approve|dismiss. Grid append stamps the
approving user (fundraising_state.updated_by has a FK to users). Test
test_email_activity.py (propose cutoff/idempotency, approve appends + edited note,
dismiss, already-decided guard) under FK enforcement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
6.4 KiB
Python
118 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Test the email-activity proposal flow: the agent drafts PROPOSED grid notes for
|
|
newly-matched emails (going-forward only, idempotent, no grid write), and a human
|
|
approves (optionally edited -> appended to the grid) or dismisses them. The local
|
|
model is stubbed. Synthetic data only (guardrail #9).
|
|
Run: cd backend && python3 test_email_activity.py
|
|
"""
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
|
|
os.environ["CRM_DB_PATH"] = os.path.join(tempfile.mkdtemp(), "crm.db")
|
|
os.environ.setdefault("CRM_DATA_DIR", os.path.dirname(os.environ["CRM_DB_PATH"]))
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import server # noqa: E402
|
|
|
|
server._summarize_email_gist = lambda subject, body: "fundraising update; proposed a call"
|
|
|
|
FAILS = []
|
|
|
|
|
|
def check(cond, msg):
|
|
print((" PASS " if cond else " FAIL ") + msg)
|
|
if not cond:
|
|
FAILS.append(msg)
|
|
|
|
|
|
def setup():
|
|
conn = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
|
conn.row_factory = sqlite3.Row
|
|
conn.executescript("""
|
|
CREATE TABLE app_settings (key TEXT PRIMARY KEY, value_json TEXT, updated_at TEXT);
|
|
CREATE TABLE email_accounts (id TEXT, email_address TEXT, sync_enabled INT DEFAULT 1, sync_status TEXT, backfill_complete INT);
|
|
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, from_email TEXT, sent_at TEXT, is_matched INT, match_status TEXT);
|
|
CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT, organization_id TEXT, contact_id TEXT, match_confidence REAL);
|
|
CREATE TABLE email_activity_proposals (id TEXT PRIMARY KEY, email_id TEXT UNIQUE, investor_id TEXT, investor_name TEXT,
|
|
direction TEXT, summary TEXT, proposed_note TEXT, email_subject TEXT, email_date TEXT, status TEXT DEFAULT 'pending',
|
|
decided_by TEXT, decided_at TEXT, final_note TEXT, created_at TEXT);
|
|
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT);
|
|
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, notes TEXT);
|
|
CREATE TABLE fundraising_state (id TEXT PRIMARY KEY, grid_json TEXT, views_json TEXT, version INT,
|
|
updated_by TEXT REFERENCES users(id), updated_at TEXT);
|
|
CREATE TABLE interaction_log (id TEXT PRIMARY KEY, ts TEXT, actor_type TEXT, actor_id TEXT, action TEXT, target_type TEXT, target_id TEXT, payload TEXT, source TEXT, created_at TEXT);
|
|
""")
|
|
conn.execute("INSERT INTO users (id,username) VALUES ('user-1','grant')")
|
|
conn.execute("INSERT INTO app_settings VALUES ('email_activity_since', ?, ?)", (json.dumps("2026-01-01T00:00:00"), "x"))
|
|
conn.execute("INSERT INTO email_accounts (id,email_address) VALUES ('a','grant@ten31.xyz')")
|
|
conn.execute("INSERT INTO fundraising_investors (id,investor_name,notes) VALUES ('inv1','Harbor & Vine','existing note')")
|
|
grid = {"columns": [], "rows": [{"id": "inv1", "investor_name": "Harbor & Vine", "notes": "existing note"}]}
|
|
conn.execute("INSERT INTO fundraising_state (id,grid_json,views_json,version) VALUES ('main',?,?,1)", (json.dumps(grid), "[]"))
|
|
# e1 sent (from us), e2 received, both after cutoff; e3 before cutoff (excluded)
|
|
conn.executemany("INSERT INTO emails (id,subject,body_text,from_email,sent_at,is_matched,match_status) VALUES (?,?,?,?,?,1,'matched')", [
|
|
("e1", "Fund III", "Here is the update", "grant@ten31.xyz", "2026-06-01T10:00:00"),
|
|
("e2", "Re: Fund III", "Thanks, a question", "lp@harborvine.example", "2026-06-02T10:00:00"),
|
|
("e3", "Old", "ancient", "lp@harborvine.example", "2025-01-01T10:00:00"),
|
|
])
|
|
conn.executemany("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id,match_confidence) VALUES (?,?, 'inv1', 1.0)",
|
|
[("l1", "e1"), ("l2", "e2"), ("l3", "e3")])
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def main():
|
|
setup()
|
|
|
|
res = server.propose_email_activity_notes()
|
|
check(res.get("proposed") == 2, f"proposes 2 (e1,e2 after cutoff; e3 excluded), got {res}")
|
|
|
|
res2 = server.propose_email_activity_notes()
|
|
check(res2.get("proposed") == 0, f"idempotent: second run proposes 0, got {res2}")
|
|
|
|
conn = server.get_db()
|
|
props = server.list_email_activity_proposals(conn, status="pending")
|
|
check(len(props) == 2, f"2 pending proposals listed, got {len(props)}")
|
|
dirs = sorted(p["direction"] for p in props)
|
|
check(dirs == ["received", "sent"], f"directions sent+received, got {dirs}")
|
|
e1 = next(p for p in props if p["email_id"] == "e1")
|
|
check(e1["direction"] == "sent" and "Sent" in e1["proposed_note"], "e1 (from us) is 'sent'")
|
|
check("✉" in e1["proposed_note"] and "fundraising update" in e1["proposed_note"], "proposed note marked + has gist")
|
|
|
|
# grid must be UNTOUCHED before approval
|
|
grid = json.loads(conn.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()["grid_json"])
|
|
check(grid["rows"][0]["notes"] == "existing note", "grid notes unchanged before approval")
|
|
|
|
# approve e1 (default note) -> appended at bottom
|
|
r = server.decide_email_activity_proposal(conn, e1["id"], "approve", "user-1")
|
|
check(r.get("status") == "approved" and r.get("placed_in_grid") is True, f"approve places in grid, got {r}")
|
|
grid = json.loads(conn.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()["grid_json"])
|
|
notes = grid["rows"][0]["notes"]
|
|
check(notes.startswith("existing note\n") and "✉" in notes, "approved note appended below existing note")
|
|
|
|
# approve e2 with an EDITED note
|
|
e2 = next(p for p in props if p["email_id"] == "e2")
|
|
r2 = server.decide_email_activity_proposal(conn, e2["id"], "approve", "user-1", edited_note="Custom edited note")
|
|
grid = json.loads(conn.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()["grid_json"])
|
|
check("Custom edited note" in grid["rows"][0]["notes"], "edited note is what gets appended")
|
|
|
|
# re-deciding an already-decided proposal is rejected
|
|
r3 = server.decide_email_activity_proposal(conn, e1["id"], "dismiss", "user-1")
|
|
check(r3.get("error") == "already_decided", f"cannot re-decide, got {r3}")
|
|
|
|
# nothing left pending
|
|
check(len(server.list_email_activity_proposals(conn, status="pending")) == 0, "no pending proposals remain")
|
|
conn.close()
|
|
|
|
if FAILS:
|
|
print(f"\nFAILED ({len(FAILS)})")
|
|
for f in FAILS:
|
|
print(" - " + f)
|
|
sys.exit(1)
|
|
print("\nALL PASS (email-activity proposal flow)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|