Email-proposal review over Matrix + a bot role (v0.1.0:89)
The email-capture "proposed grid notes" gain two review surfaces:
1. Inline source email — each proposed-note card on the Email Capture page
gets a "View email" toggle that lazily fetches the existing
GET /api/email/detail and shows from/to/cc/date/subject + scrollable body,
so a reviewer can judge the note against the email it was drafted from.
2. CRM->Matrix review bridge — the CRM (box, stdlib, no matrix-nio) can't post
to Matrix, so the intake bot (Spark) PULLS: GET /api/intake/email-proposals
returns to_post/open/to_close work-lists; the bot posts a review card
(metadata + snippet + draft note) to a dedicated review room
(MATRIX_EMAIL_REVIEW_ROOM) and relays in-thread yes / no / NL-edit
(POST .../{id}/decide, note revised via local Qwen). Decisions sync both
ways: web decide -> bot announces + closes the thread; Matrix decide -> the
web panel's ~25s poll clears the card. State lives CRM-side in the new
email_proposal_matrix side row (email-integration migration 0003, additive
+ idempotent CREATE TABLE IF NOT EXISTS), so it survives a bot restart.
Adds a 'bot' role (authenticated, never admin; require_bot_or_admin) to gate
the email-proposal endpoints rather than handing the bot full admin — the
principled base for the coming agentic capabilities. Role controls reach;
the draft->approve gate still controls autonomy (a human approves every write).
Deploy split: endpoints + migration + role + frontend ship in the s9pk; the
bot poll loop + review-room handling ship on the Spark. The bot's CRM user
must be flipped member->bot and joined to the review room (one-time).
Tests: backend/test_email_proposal_matrix.py + matrix_intake/test_email_proposals.py
(30/30 suite green, render-smoke green, migration verified twice on a DB copy).
This commit is contained in:
@@ -98,6 +98,47 @@ def match(proposal):
|
||||
return {"match": match_out, "candidates": candidates}
|
||||
|
||||
|
||||
def list_email_proposals():
|
||||
"""Pull the email-activity review work-lists for the poll loop: {to_post, open, to_close}.
|
||||
to_post = pending, un-posted (post a card); open = posted, awaiting a decision (rebuild the
|
||||
reply-routing map after a restart); to_close = decided on the web (announce in-thread + close)."""
|
||||
status, data = _authed("GET", "/api/intake/email-proposals")
|
||||
if status != 200:
|
||||
raise RuntimeError(f"email-proposals list failed ({status}): {data.get('error') or data}")
|
||||
payload = data.get("data") or {}
|
||||
return {k: (payload.get(k) or []) for k in ("to_post", "open", "to_close")}
|
||||
|
||||
|
||||
def mark_email_proposal_posted(proposal_id, event_id):
|
||||
"""Record the Matrix thread-root event id so the proposal's review state survives a restart."""
|
||||
status, data = _authed("POST", f"/api/intake/email-proposals/{proposal_id}/matrix",
|
||||
{"event_id": event_id})
|
||||
if status != 200:
|
||||
raise RuntimeError(f"mark posted failed ({status}): {data.get('error') or data}")
|
||||
return data.get("data") or {}
|
||||
|
||||
|
||||
def mark_email_proposal_closed(proposal_id):
|
||||
"""Mark the review thread resolved after announcing a web-side decision in it."""
|
||||
status, data = _authed("POST", f"/api/intake/email-proposals/{proposal_id}/matrix",
|
||||
{"closed": True})
|
||||
if status != 200:
|
||||
raise RuntimeError(f"mark closed failed ({status}): {data.get('error') or data}")
|
||||
return data.get("data") or {}
|
||||
|
||||
|
||||
def decide_email_proposal(proposal_id, decision, note=None):
|
||||
"""Relay an in-thread approve/dismiss (with the possibly-revised note) to the CRM. The server
|
||||
appends the note to the grid on approve, tags source='matrix', and closes the thread."""
|
||||
body = {"decision": decision}
|
||||
if note is not None:
|
||||
body["note"] = note
|
||||
status, data = _authed("POST", f"/api/intake/email-proposals/{proposal_id}/decide", body)
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(f"email-proposal decide failed ({status}): {data.get('error') or data}")
|
||||
return data.get("data") or {}
|
||||
|
||||
|
||||
def build_commit_payload(proposal):
|
||||
"""Pure: map a proposal to the /api/fundraising/log-communication request body.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user