Refine email-proposal review UX (v0.1.0:91)
Three post-smoke refinements to the Matrix email-proposal review:
1. Dash separators (bot): every card/reply is framed with a dash rule top and
bottom so threads stop bleeding together vertically on mobile.
2. Remove decided threads (bot): on a conclusive approve/dismiss from either
surface, the bot redacts the card (client.room_redact) so the room clears
down to only undecided items. Redacting the bot's own card needs no power;
the web->Matrix path now redacts instead of posting a closure note.
3. Clearer note wording (server v91 + bot): the proposed grid note now names who
emailed whom -- "{teammate} emailed {investor}" (outbound) / "{sender} emailed
the team" (inbound) -- instead of an ambiguous "Sent"/"Received". Outbound
detection also matches our corporate domain (public providers excluded), so a
teammate's mail from a non-enrolled @ten31.xyz address no longer reads as
"Received". Going-forward only; no schema change. The card drops its bare
direction label since the note now carries the relationship.
Tests updated; 30/30 green, render-smoke green.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
+47
-5
@@ -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,"
|
||||
|
||||
@@ -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 '<sender> 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
|
||||
|
||||
Reference in New Issue
Block a user