diff --git a/AGENTS.md b/AGENTS.md index c5c8e3b..76df105 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude _Phase 0 + Phase 1 built; **box and repo at v0.1.0:90** (v89 + v90 installed & verified live 2026-06-18 — `installed-version` = 0.1.0:90, server up on :8080, no errors; the StartOS version-graph traversal logs an inert down-to-39-then-up-to-90 because the per-version `up`/`down` hooks are no-ops — the real SQLite migrations run in-app at startup). **The fundraising grid + email capture is the canonical system of record** (2026-06-16) — vestigial classic-CRM surfaces get pruned/repurposed. Deploy/feature history lives in git log + `start9/0.4/startos/versions/`; longer-term backlog + debt in `ROADMAP.md` / `EVALUATION.md`._ -- **Email-proposal review over Matrix + a `bot` role — DEPLOYED & LIVE 2026-06-18 (box v0.1.0:90; Spark bot at commit `2998706`).** Feature 1 (the inline source-email popup) is live on the box. The Matrix leg is live: bot CRM user flipped to `bot` (via the v90 Settings → Admin dropdown), `MATRIX_EMAIL_REVIEW_ROOM=!CImVJWFmzNxPcCcrZl:matrix.gilliam.ai` set on the Spark `.env`, bot joins the room on startup, polls every 20s. **End-to-end proven on deploy:** the whole pending backlog (N=16) posted as review cards to the room and was marked posted on the box (no errors); the bot now idles (to_post empty) holding 16 open threads. **Deploy gotchas learned:** (a) order matters — the bot caches its JWT (role embedded) at login and only re-logins on 401, not 403, so the role must be `bot` *before* the bot process starts; (b) "invited" ≠ "joined" — the bot must `client.join` the room (added in `2998706`) or `room_send` fails M_FORBIDDEN; (c) on first run the bot posts a card per *pending* proposal — fine here at N=16, but a large backlog would flood + get homeserver-429'd (a `since`-floor on `to_post` is the upgrade path if that ever bites). **Smoke-test still pending (manual):** reply `yes`/`no`/edit **in-thread** on a card → grid note + thread closes; approve one on the web → the bot announces + closes its thread (≤20s); confirm a Matrix decision clears the web card (≤25s). Two asks on the email-capture "proposed grid notes": (1) **inline source-email popup** — each proposed-note card on the Email Capture page gets a **View email** toggle that lazily fetches the existing **`GET /api/email/detail`** and renders from/to/cc/date/subject + scrollable body inline, so you can judge the note against the email (frontend-only, reuses the Communications detail pattern). (2) **CRM→Matrix review bridge** — the CRM (box, no matrix-nio) can't push, so the **intake bot (Spark) PULLS**: new **`GET /api/intake/email-proposals`** returns three work-lists (`to_post`/`open`/`to_close`); the bot posts a review card (metadata + snippet + draft note) to a **dedicated review room** (`MATRIX_EMAIL_REVIEW_ROOM`), records the thread root (`POST .../{id}/matrix`), and relays in-thread **yes / no / NL-edit** (`POST .../{id}/decide`, NL-revise via local Qwen). **Bidirectional sync:** decide on the web → bot announces + closes the thread; decide on Matrix → the web panel's ~25s poll clears the card. State is CRM-side in **`email_proposal_matrix`** (1:1 side row, email-integration migration **`0003`**, additive + idempotent `CREATE TABLE IF NOT EXISTS` — the email runner re-runs every boot via `executescript`, so no `ALTER`), so it survives a bot restart. New **`bot` role** (authenticated, never admin — `require_bot_or_admin`) gates the email-proposal endpoints; the bot's CRM user must be flipped `member→bot` (one-time, kept out of the invite UI). **Deploy split:** endpoints + migration + role + frontend → **s9pk (v89/v90, installed)**; poll loop + review-room handling → **Spark git pull + restart (pending)**. Tests: `backend/test_email_proposal_matrix.py` + `backend/matrix_intake/test_email_proposals.py`; **30/30 suite green**, render-smoke green, migration verified twice on a copy of `data/crm.db` + applied clean on the box. **Next (see the two manual steps above): flip the bot user → `bot`; then Spark deploy with the review room; then live-smoke the web↔Matrix round-trip** (the popup is already verifiable on the box). Guide: `docs/guides/matrix-intake.md` "Email-activity proposal review". +- **Email-proposal review over Matrix + a `bot` role — DEPLOYED & LIVE 2026-06-18 (box v0.1.0:91), smoke-tested OK.** **Post-smoke refinements (v0.1.0:91 + bot):** (1) every review card/reply is framed with `-----------------------` dash rules so threads don't bleed together on mobile (`email_proposals.frame`); (2) on a conclusive decision (either surface) the bot **redacts the card** (`client.room_redact`) so the room clears down to only undecided items — redacting its *own* card needs no power; wiping the human's reply for a fully-empty thread needs a `redact`/mod power level (not set, optional extension); (3) the proposed note now NAMES who emailed whom — "{teammate} emailed {investor}" / "{sender} emailed the team" — instead of a bare "Sent/Received", and outbound detection now also matches our corporate domain (public providers excluded) so a teammate's non-enrolled `@ten31.xyz` mail no longer reads as "Received". Feature 1 (the inline source-email popup) is live on the box. The Matrix leg is live: bot CRM user flipped to `bot` (via the v90 Settings → Admin dropdown), `MATRIX_EMAIL_REVIEW_ROOM=!CImVJWFmzNxPcCcrZl:matrix.gilliam.ai` set on the Spark `.env`, bot joins the room on startup, polls every 20s. **End-to-end proven on deploy:** the whole pending backlog (N=16) posted as review cards to the room and was marked posted on the box (no errors); the bot now idles (to_post empty) holding 16 open threads. **Deploy gotchas learned:** (a) order matters — the bot caches its JWT (role embedded) at login and only re-logins on 401, not 403, so the role must be `bot` *before* the bot process starts; (b) "invited" ≠ "joined" — the bot must `client.join` the room (added in `2998706`) or `room_send` fails M_FORBIDDEN; (c) on first run the bot posts a card per *pending* proposal — fine here at N=16, but a large backlog would flood + get homeserver-429'd (a `since`-floor on `to_post` is the upgrade path if that ever bites). **Smoke-test still pending (manual):** reply `yes`/`no`/edit **in-thread** on a card → grid note + thread closes; approve one on the web → the bot announces + closes its thread (≤20s); confirm a Matrix decision clears the web card (≤25s). Two asks on the email-capture "proposed grid notes": (1) **inline source-email popup** — each proposed-note card on the Email Capture page gets a **View email** toggle that lazily fetches the existing **`GET /api/email/detail`** and renders from/to/cc/date/subject + scrollable body inline, so you can judge the note against the email (frontend-only, reuses the Communications detail pattern). (2) **CRM→Matrix review bridge** — the CRM (box, no matrix-nio) can't push, so the **intake bot (Spark) PULLS**: new **`GET /api/intake/email-proposals`** returns three work-lists (`to_post`/`open`/`to_close`); the bot posts a review card (metadata + snippet + draft note) to a **dedicated review room** (`MATRIX_EMAIL_REVIEW_ROOM`), records the thread root (`POST .../{id}/matrix`), and relays in-thread **yes / no / NL-edit** (`POST .../{id}/decide`, NL-revise via local Qwen). **Bidirectional sync:** decide on the web → bot announces + closes the thread; decide on Matrix → the web panel's ~25s poll clears the card. State is CRM-side in **`email_proposal_matrix`** (1:1 side row, email-integration migration **`0003`**, additive + idempotent `CREATE TABLE IF NOT EXISTS` — the email runner re-runs every boot via `executescript`, so no `ALTER`), so it survives a bot restart. New **`bot` role** (authenticated, never admin — `require_bot_or_admin`) gates the email-proposal endpoints; the bot's CRM user must be flipped `member→bot` (one-time, kept out of the invite UI). **Deploy split:** endpoints + migration + role + frontend → **s9pk (v89/v90, installed)**; poll loop + review-room handling → **Spark git pull + restart (pending)**. Tests: `backend/test_email_proposal_matrix.py` + `backend/matrix_intake/test_email_proposals.py`; **30/30 suite green**, render-smoke green, migration verified twice on a copy of `data/crm.db` + applied clean on the box. **Next (see the two manual steps above): flip the bot user → `bot`; then Spark deploy with the review room; then live-smoke the web↔Matrix round-trip** (the popup is already verifiable on the box). Guide: `docs/guides/matrix-intake.md` "Email-activity proposal review". - **Adopt the Pipeline — grid drives the deal board — DEPLOYED & live-smoked 2026-06-18 (v0.1.0:88; the full +Pipeline → board → advance-stage → remove round-trip is verified on the box). v88 (frontend-only): retired the Pipeline page's "+ New Opportunity" button + its create-by-contact modal** — opportunities are now born **only** from a grid investor row (matches how the team works; the board is view + stage-management; button replaced with a muted "Add deals from the Fundraising Grid" hint). An **"Add to Pipeline"** row action on the fundraising grid opens a seed modal (primary contact / target fund / expected amount / stage / probability) and creates a durably-linked `opportunities` row via the new **`opportunities.fundraising_investor_id`** (migration 0005, additive + reversible). **Grid owns the link + seed; the board owns stage/probability/owner** — a grid save never reseeds a live opp (`POST /api/fundraising/pipeline/link` is idempotent, one live opp/investor). Contact is **reused from the grid's synced `fundraising_contacts.contact_id`** (the `POST /api/contacts` side-door is gone); grid `lead`→owner. Two **read-only** grid columns (Pipeline action + Pipeline Stage) are **injected on read** from the live opp and **stripped on write** (never persisted, never dirty the autosave). **Remove from pipeline** (`POST .../unlink`) **soft-deletes the opp; the grid row stays fully intact**; deleting an investor from the grid archives its orphaned opp (`reconcile_grid_pipeline_links`, after `sync_fundraising_relational`). **Folded in:** the standing P2 soft-delete leak in `handle_pipeline_report` + dashboard pipeline aggregates (archived opps no longer counted). Tests: `backend/test_grid_pipeline_link.py`; 28/28 suite green, render-smoke green; migration verified on a copy of `data/crm.db` and **applied clean on the box**. **Next: live-smoke on the box — add an investor to the pipeline, confirm it lands on the board, advance a stage, and remove (opp archived, grid row intact).** Detail + locked decisions in `ROADMAP.md` "Adopt the Pipeline". diff --git a/backend/matrix_intake/bot.py b/backend/matrix_intake/bot.py index 483fa8e..5df75bb 100644 --- a/backend/matrix_intake/bot.py +++ b/backend/matrix_intake/bot.py @@ -162,10 +162,21 @@ async def main(): store.put(root, proposal) await say(room_id, "I didn't catch that.\n\n" + proposals.render_disambiguation(proposal), root) + async def redact_card(event_id): + """Remove a decided card from the room so only undecided ones remain. Redacting our OWN + card needs no special power; in Element a redacted message drops out of the timeline. (To + also wipe the human's yes/no reply for a fully-empty thread, give the bot a redact/mod + power level — not required for this.)""" + try: + await client.room_redact(review_room, event_id, reason="proposal resolved") + except Exception as exc: + print(f"matrix-intake: could not redact card {event_id}: {exc}", flush=True) + async def handle_email_reply(room_id, root, text): """An in-thread reply to a CRM-drafted email-proposal card: yes commits, no dismisses, and anything else is a natural-language revision of the note (re-drafted by local Qwen; the - human still approves the revised note, so the draft→approve gate holds).""" + human still approves the revised note, so the draft→approve gate holds). On a conclusive + decision the card is redacted so the room clears down to only what still needs handling.""" item = email_threads.get(root) if item is None: return # a threaded reply we don't own (or already resolved) @@ -177,33 +188,35 @@ async def main(): await asyncio.to_thread(crm_client.decide_email_proposal, item["id"], "approve", item.get("note")) except Exception as exc: email_threads[root] = item # restore for retry - await say(room_id, f"⚠️ couldn't add it ({str(exc)[:200]}). Reply **yes** to retry, **no** to dismiss.", root) + await say(room_id, email_proposals.frame(f"⚠️ couldn't add it ({str(exc)[:200]}). Reply **yes** to retry, **no** to dismiss."), root) return - await say(room_id, f"✅ Added to the grid for **{item.get('investor_name') or 'the investor'}**.", root) + await say(room_id, email_proposals.frame(f"✅ Added to the grid for **{item.get('investor_name') or 'the investor'}**."), root) + await redact_card(root) elif decision == "reject": email_threads.pop(root, None) try: await asyncio.to_thread(crm_client.decide_email_proposal, item["id"], "dismiss") except Exception as exc: email_threads[root] = item - await say(room_id, f"⚠️ couldn't dismiss it ({str(exc)[:200]}). Try again.", root) + await say(room_id, email_proposals.frame(f"⚠️ couldn't dismiss it ({str(exc)[:200]}). Try again."), root) return - await say(room_id, "🗑️ Dismissed — nothing added to the grid.", root) + await say(room_id, email_proposals.frame("🗑️ Dismissed — nothing added to the grid."), root) + await redact_card(root) else: try: new_note = await asyncio.to_thread(email_proposals.revise_note, item.get("note") or "", text) except Exception as exc: - await say(room_id, f"⚠️ couldn't revise that ({str(exc)[:200]}). Reply **yes** to add as-is, " - "**no** to dismiss, or rephrase.", root) + await say(room_id, email_proposals.frame(f"⚠️ couldn't revise that ({str(exc)[:200]}). Reply **yes** to add as-is, " + "**no** to dismiss, or rephrase."), root) return if not new_note: - await say(room_id, "I didn't catch a change. Reply **yes** to add the note as-is, **no** to " - "dismiss, or tell me how to change it.", root) + await say(room_id, email_proposals.frame("I didn't catch a change. Reply **yes** to add the note as-is, **no** to " + "dismiss, or tell me how to change it."), root) return item["note"] = new_note email_threads[root] = item - await say(room_id, f"✏️ Updated draft note:\n\n{new_note}\n\nReply **yes** to add it, **no** to " - "dismiss, or refine again.", root) + await say(room_id, email_proposals.frame(f"✏️ Updated draft note:\n\n{new_note}\n\nReply **yes** to add it, **no** to " + "dismiss, or refine again."), root) async def poll_email_proposals(): """Poll the CRM for email-activity proposals: post a review card for each new one, rebuild @@ -231,12 +244,12 @@ async def main(): "note": it.get("proposed_note") or ""} except Exception as exc: print(f"matrix-intake: failed to post email proposal {it.get('id')}: {exc}", flush=True) - for it in lists["to_close"]: # decided on the web → announce in-thread, then close + for it in lists["to_close"]: # decided on the web → remove the card, then close ev = it.get("event_id") if not ev: continue try: - await say(review_room, email_proposals.closure_line(it.get("status")), ev) + await redact_card(ev) await asyncio.to_thread(crm_client.mark_email_proposal_closed, it["id"]) email_threads.pop(ev, None) except Exception as exc: diff --git a/backend/matrix_intake/email_proposals.py b/backend/matrix_intake/email_proposals.py index f47cbe6..632dd56 100644 --- a/backend/matrix_intake/email_proposals.py +++ b/backend/matrix_intake/email_proposals.py @@ -16,6 +16,12 @@ _YES = {"yes", "y", "approve", "approved", "ok", "confirm", "go", "add", "👍", _NO = {"no", "n", "cancel", "discard", "reject", "skip", "stop", "👎", "❌"} _SNIPPET_MAX = 400 # email snippet shown on the card; the full body is in the web popup +RULE = "-----------------------" # top/bottom rule so threads don't bleed together on mobile + + +def frame(text): + """Wrap a message in dash rules so each card/reply is visually bounded in the room.""" + return f"{RULE}\n{text}\n{RULE}" def _truncate(s, n): @@ -26,11 +32,10 @@ def _truncate(s, n): def render_card(item): """The review card posted to the Matrix review room: who/when + a short email snippet + the drafted note. Deliberately compact for mobile — the full scrollable body is in the web Email - Capture popup (this is the metadata+snippet+note choice).""" + Capture popup. Direction isn't a bare label anymore — the note itself names who emailed whom.""" name = item.get("investor_name") or "Unknown investor" - direction = "Sent" if item.get("direction") == "sent" else "Received" frm = item.get("from_name") or item.get("from_email") or "?" - lines = [f"📧 Proposed **grid note** for **{name}** ({direction})"] + lines = [f"📧 Proposed **grid note** for **{name}**"] if item.get("email_subject"): lines.append(f"· Subject: {item['email_subject']}") if item.get("email_date"): @@ -44,13 +49,7 @@ def render_card(item): lines.append("") lines.append("Reply **yes** to add it to the grid, **no** to dismiss, or just tell me how to " "change the note (e.g. *say we discussed the Q3 raise*).") - return "\n".join(lines) - - -def closure_line(status): - """Posted in-thread when a proposal was decided on the WEB while its Matrix thread was open.""" - verb = "approved ✅ and added to the grid" if status == "approved" else "dismissed 🗑️" - return f"This was {verb} on the web — nothing more to do here. Thread closed." + return frame("\n".join(lines)) def interpret(text): diff --git a/backend/matrix_intake/test_email_proposals.py b/backend/matrix_intake/test_email_proposals.py index fde9274..32e06f6 100644 --- a/backend/matrix_intake/test_email_proposals.py +++ b/backend/matrix_intake/test_email_proposals.py @@ -1,5 +1,5 @@ -"""Offline tests for the email-proposal review logic (card render, reply grammar, note revision). -The network/Matrix wiring lives in bot.py (live-smoke only); this covers the pure functions.""" +"""Offline tests for the email-proposal review logic (card render, framing, reply grammar, note +revision). The network/Matrix wiring lives in bot.py (live-smoke only); this covers pure functions.""" import os import sys @@ -11,7 +11,8 @@ ITEM = { "id": "p1", "investor_name": "Acme Capital", "direction": "received", "from_name": "Jane Doe", "from_email": "jane@acme.com", "email_subject": "Re: Fund III", "email_date": "2026-06-02", - "snippet": "thanks for the deck — one question on terms", "proposed_note": "✉ Received: asked about terms", + "snippet": "thanks for the deck — one question on terms", + "proposed_note": "✉ Jane Doe emailed the team: asked about terms", } @@ -25,18 +26,28 @@ def test_interpret_yes_no_else(): assert email_proposals.interpret("say we discussed the Q3 raise") == "revise" +def test_frame_wraps_with_rules(): + out = email_proposals.frame("hello") + lines = out.split("\n") + assert lines[0] == email_proposals.RULE and lines[-1] == email_proposals.RULE + assert "hello" in out + + def test_render_card_has_context_note_and_actions(): card = email_proposals.render_card(ITEM) - assert "Acme Capital" in card and "Received" in card + assert "Acme Capital" in card assert "Jane Doe" in card assert "Re: Fund III" in card and "2026-06-02" in card assert "thanks for the deck" in card - assert "✉ Received: asked about terms" in card + assert "Jane Doe emailed the team: asked about terms" in card # the clear, named note assert "yes" in card.lower() and "no" in card.lower() -def test_render_card_sent_direction(): - assert "(Sent)" in email_proposals.render_card(dict(ITEM, direction="sent")) +def test_render_card_is_framed_and_dropless_direction(): + card = email_proposals.render_card(ITEM) + assert card.startswith(email_proposals.RULE) and card.rstrip().endswith(email_proposals.RULE) + # the bare Sent/Received label is gone — the note itself names who emailed whom + assert "(Received)" not in card and "(Sent)" not in card def test_render_card_truncates_long_snippet(): @@ -59,11 +70,6 @@ def test_revise_note_noop_or_empty_returns_none(): assert email_proposals.revise_note("n", "y", parse_fn=lambda *a, **k: None) is None -def test_closure_line_reflects_status(): - assert "approved" in email_proposals.closure_line("approved").lower() - assert "dismiss" in email_proposals.closure_line("dismissed").lower() - - if __name__ == "__main__": fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] for fn in fns: diff --git a/backend/server.py b/backend/server.py index e595f3f..13cf857 100644 --- a/backend/server.py +++ b/backend/server.py @@ -5731,8 +5731,45 @@ def _append_grid_note(conn, inv_id, inv_name, note, updated_by=None): return True -def _activity_note_text(sent_at, direction, gist): - return f"{_ACTIVITY_MARKER} {_fmt_activity_date(sent_at)} — {direction}: {gist}" +# Public mailbox providers: a teammate's enrolled gmail must NOT make every gmail sender read +# as "ours" (that would flip inbound LP gmail to outbound). Only corporate domains imply us. +_PUBLIC_EMAIL_DOMAINS = { + "gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com", "msn.com", + "yahoo.com", "ymail.com", "icloud.com", "me.com", "mac.com", "aol.com", + "proton.me", "protonmail.com", "pm.me", +} + + +def _our_email_domains(own_addresses): + """Corporate domains we send from, derived from the enrolled mailboxes (public providers + excluded). Lets an @ten31.xyz sender read as outbound even when that mailbox isn't enrolled — + the misclassification behind a teammate's email showing as 'Received'.""" + out = set() + for a in own_addresses: + if "@" in a: + dom = a.split("@", 1)[1] + if dom and dom not in _PUBLIC_EMAIL_DOMAINS: + out.add(dom) + return out + + +def _sender_display(from_name, from_email): + """A human name for the sender: their display name, else the email's local part, else a stub.""" + if from_name and from_name.strip(): + return from_name.strip() + if from_email and "@" in from_email: + return from_email.split("@", 1)[0] + return (from_email or "").strip() or "Someone" + + +def _activity_note_text(sent_at, outbound, gist, sender, investor_name): + """Name WHO emailed WHOM rather than a bare Sent/Received (which left 'who received?' + ambiguous): outbound -> "{teammate} emailed {investor}", inbound -> "{sender} emailed the team".""" + if outbound: + rel = f"{sender} emailed {investor_name}" if investor_name else f"{sender} sent an email" + else: + rel = f"{sender} emailed the team" + return f"{_ACTIVITY_MARKER} {_fmt_activity_date(sent_at)} — {rel}: {gist}" def propose_email_activity_notes(limit=50): @@ -5752,7 +5789,7 @@ def propose_email_activity_notes(limit=50): conn.commit() try: rows = conn.execute( - "SELECT id, subject, body_text, snippet, from_email, sent_at FROM emails " + "SELECT id, subject, body_text, snippet, from_name, from_email, sent_at FROM emails " "WHERE is_matched=1 AND sent_at >= ? " "AND id NOT IN (SELECT email_id FROM email_activity_proposals) " "ORDER BY sent_at ASC LIMIT ?", (since, limit)).fetchall() @@ -5765,14 +5802,19 @@ def propose_email_activity_notes(limit=50): own = {(r[0] or "").lower() for r in conn.execute("SELECT email_address FROM email_accounts")} except Exception: pass + our_domains = _our_email_domains(own) done = 0 for r in rows: inv_id, inv_name = _activity_investor(conn, r["id"]) - direction = "Sent" if (r["from_email"] or "").lower() in own else "Received" + from_email = (r["from_email"] or "").lower() + # Outbound = sent by us: an enrolled mailbox, OR any address on our corporate domain + # (covers teammates whose mailbox isn't enrolled — the screenshot's "Received" bug). + outbound = from_email in own or (from_email.rsplit("@", 1)[-1] in our_domains if "@" in from_email else False) + direction = "Sent" if outbound else "Received" gist = _summarize_email_gist(r["subject"], r["body_text"] or r["snippet"] or "") if not gist: continue # leave unproposed; a later pass retries once the model answers - note = _activity_note_text(r["sent_at"], direction, gist) + note = _activity_note_text(r["sent_at"], outbound, gist, _sender_display(r["from_name"], r["from_email"]), inv_name) conn.execute( "INSERT OR IGNORE INTO email_activity_proposals " "(id,email_id,investor_id,investor_name,direction,summary,proposed_note," diff --git a/backend/test_email_activity.py b/backend/test_email_activity.py index 56f05a9..73a481c 100644 --- a/backend/test_email_activity.py +++ b/backend/test_email_activity.py @@ -33,7 +33,7 @@ def setup(): 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 emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, from_name 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', @@ -51,10 +51,10 @@ def setup(): 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 emails (id,subject,body_text,from_name,from_email,sent_at,is_matched,match_status) VALUES (?,?,?,?,?,?,1,'matched')", [ + ("e1", "Fund III", "Here is the update", "Grant", "grant@ten31.xyz", "2026-06-01T10:00:00"), + ("e2", "Re: Fund III", "Thanks, a question", "Harbor LP", "lp@harborvine.example", "2026-06-02T10:00:00"), + ("e3", "Old", "ancient", "Harbor LP", "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")]) @@ -77,7 +77,9 @@ def main(): 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(e1["direction"] == "sent" and "Grant emailed Harbor & Vine" in e1["proposed_note"], "e1 (from us) names sender + investor") + e2 = next(p for p in props if p["email_id"] == "e2") + check(e2["direction"] == "received" and "emailed the team" in e2["proposed_note"], "e2 (inbound) reads ' emailed the team'") check("✉" in e1["proposed_note"] and "fundraising update" in e1["proposed_note"], "proposed note marked + has gist") # grid must be UNTOUCHED before approval diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts index fe76381..23012c3 100644 --- a/start9/0.4/startos/utils.ts +++ b/start9/0.4/startos/utils.ts @@ -55,8 +55,9 @@ export const PACKAGE_TITLE = 'Ten31 Database' // * 0.1.0:87 (Adopt the Pipeline — grid drives the deal board: new "Add to Pipeline" row action creates+links an opportunity via opportunities.fundraising_investor_id [migration 0005, additive], reusing the grid's synced contact [no POST /api/contacts side-door] and mapping the grid lead→owner; idempotent [one live opp/investor, re-link never reseeds board-owned stage/probability]; read-only Pipeline + Pipeline Stage grid columns derived live from the linked opp; "Remove from Pipeline" soft-deletes the opp [grid row untouched]; deleting a grid investor archives its orphaned opp; folds in the soft-delete fix for the pipeline report + dashboard aggregates [archived opps no longer counted]) // * 0.1.0:88 (frontend-only: retire the Pipeline page's "+ New Opportunity" button + its create-by-contact modal — opportunities are now born only from a fundraising-grid investor row ["+ Pipeline"], so the board is a view + stage-management surface; replaced the button with a muted "Add deals from the Fundraising Grid" hint; removed the now-dead handler/state + the page's unused /api/contacts fetch) // * 0.1.0:89 (email-proposal review over Matrix + a dedicated agent role: Email Capture's proposed grid notes gain a click-to-view inline popup of the source email [from/to/cc/date/subject/scrollable body, via the existing GET /api/email/detail]; and a CRM→Matrix review bridge — the intake bot [Spark] pulls pending proposals, posts a review card to a dedicated review room [MATRIX_EMAIL_REVIEW_ROOM], and relays in-thread yes/no/NL-edit back to the CRM, with web panel ↔ Matrix kept in sync [decide on either surface; the other reflects it]. New side table email_proposal_matrix [email-integration migration 0003, additive + idempotent] holds per-proposal Matrix thread state; new bot-or-admin endpoints GET /api/intake/email-proposals + .../{id}/matrix + .../{id}/decide, gated by a new 'bot' role [authenticated, never admin]. Bot poll loop + review-room handling ship on the Spark, not the s9pk) -// * Current: 0.1.0:90 (give admins a UI path to provision the 'bot' role added in v89: the Settings → Admin edit-user role dropdown now offers "bot" alongside member/admin [the teammate-invite form stays member/admin only — provisioning an agent account is an admin re-classification, not an invite]; backend already accepted it; frontend-only, no schema change) -export const PACKAGE_VERSION = '0.1.0:90' +// * 0.1.0:90 (give admins a UI path to provision the 'bot' role added in v89: the Settings → Admin edit-user role dropdown now offers "bot" alongside member/admin [the teammate-invite form stays member/admin only — provisioning an agent account is an admin re-classification, not an invite]; backend already accepted it; frontend-only, no schema change) +// * Current: 0.1.0:91 (clarify email-proposal note wording: the proposed grid note now NAMES who emailed whom — "{teammate} emailed {investor}" outbound / "{sender} emailed the team" inbound — instead of a bare "Sent"/"Received"; also fixes a misclassification where a sender on our corporate domain whose mailbox isn't enrolled read as "Received" [outbound now also matches our domain, public providers like gmail excluded so an LP's gmail never reads as ours]; going-forward only, no schema change. Matrix-side review tweaks — dash separators + redacting decided cards — ship on the Spark, not the s9pk) +export const PACKAGE_VERSION = '0.1.0:91' export const DATA_MOUNT_PATH = '/data' export const WEB_PORT = 8080 diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 61ef9ea..f60130d 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -51,8 +51,9 @@ import { v_0_1_0_87 } from './v0.1.0.87' import { v_0_1_0_88 } from './v0.1.0.88' import { v_0_1_0_89 } from './v0.1.0.89' import { v_0_1_0_90 } from './v0.1.0.90' +import { v_0_1_0_91 } from './v0.1.0.91' export const versionGraph = VersionGraph.of({ - current: v_0_1_0_90, - other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89], + current: v_0_1_0_91, + other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90], }) diff --git a/start9/0.4/startos/versions/v0.1.0.91.ts b/start9/0.4/startos/versions/v0.1.0.91.ts new file mode 100644 index 0000000..466e0a2 --- /dev/null +++ b/start9/0.4/startos/versions/v0.1.0.91.ts @@ -0,0 +1,21 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +// Clarify email-proposal note wording (part of a review-UX refinement; the matching Matrix-side +// tweaks — dash separators + redacting decided cards — ship on the Spark, not here). The proposed +// grid note now NAMES who emailed whom — "{teammate} emailed {investor}" (outbound) / +// "{sender} emailed the team" (inbound) — instead of a bare "Sent"/"Received" that left +// "who received?" ambiguous. Also fixes a misclassification where a sender on our corporate +// domain whose mailbox isn't enrolled read as "Received": outbound now also matches our domain +// (public providers like gmail excluded, so an LP's gmail never reads as ours). Going-forward +// only (existing proposals keep their text); no schema change. +export const v_0_1_0_91 = VersionInfo.of({ + version: '0.1.0:91', + releaseNotes: { + en_US: [ + 'Proposed grid notes now name who emailed whom (e.g. "Jane emailed Acme Capital")', + 'instead of an ambiguous "Sent"/"Received", and correctly classify mail from your', + 'own domain as outbound even when that mailbox isn\'t enrolled.', + ].join(' '), + }, + migrations: { up: async () => {}, down: async () => {} }, +})