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:
@@ -10037,6 +10037,8 @@
|
||||
const [proposals, setProposals] = useState([]);
|
||||
const [edits, setEdits] = useState({});
|
||||
const [deciding, setDeciding] = useState(null);
|
||||
const [openEmail, setOpenEmail] = useState(null); // proposal id whose source email is expanded
|
||||
const [emailCache, setEmailCache] = useState({}); // email_id -> {loading, data, error}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
let s;
|
||||
@@ -10086,6 +10088,21 @@
|
||||
return () => { cancelled = true; clearInterval(iv); };
|
||||
}, [backfilling, load]);
|
||||
|
||||
// Steady-state poll of just the proposals so a decision made on Matrix (approve/dismiss
|
||||
// in the review room) clears its card here without a manual reload — the mirror of the
|
||||
// bot announcing a web-side decision in-thread. Admin-only (only admins see proposals).
|
||||
const refreshProposals = useCallback(async () => {
|
||||
try {
|
||||
const pr = await api('/api/activity/proposals', {}, token);
|
||||
setProposals(Array.isArray(pr?.proposals) ? pr.proposals : []);
|
||||
} catch (_) { /* admin-only / transient — leave the current list */ }
|
||||
}, [token]);
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return undefined;
|
||||
const iv = setInterval(() => { refreshProposals(); }, 25000);
|
||||
return () => clearInterval(iv);
|
||||
}, [isAdmin, refreshProposals]);
|
||||
|
||||
const runAction = async (key, endpoint, successMsg, confirmMsg, body) => {
|
||||
if (busy) return;
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
@@ -10120,6 +10137,51 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Click a proposal to see the email it was drafted from (from/to/cc/date/subject +
|
||||
// scrollable body) so you can judge whether the note is right. Lazily fetched +
|
||||
// cached per email; reuses the admin-only /api/email/detail used by Communications.
|
||||
const toggleEmail = async (p) => {
|
||||
if (openEmail === p.id) { setOpenEmail(null); return; }
|
||||
setOpenEmail(p.id);
|
||||
const eid = p.email_id;
|
||||
if (!eid || emailCache[eid]) return;
|
||||
setEmailCache((c) => ({ ...c, [eid]: { loading: true } }));
|
||||
try {
|
||||
const res = await api(`/api/email/detail?id=${encodeURIComponent(eid)}`, {}, token);
|
||||
setEmailCache((c) => ({ ...c, [eid]: { loading: false, data: res } }));
|
||||
} catch (err) {
|
||||
setEmailCache((c) => ({ ...c, [eid]: { loading: false, error: getErrorMessage(err, 'Failed to load email') } }));
|
||||
}
|
||||
};
|
||||
|
||||
const renderProposalEmail = (p) => {
|
||||
const det = emailCache[p.email_id];
|
||||
if (!det) return null;
|
||||
if (det.loading) return <div style={{ marginTop: '8px' }}><SkeletonBlock lines={4} /></div>;
|
||||
if (det.error) return <div className="toast error" style={{ position: 'static', marginTop: '8px' }}>{det.error}</div>;
|
||||
const d = det.data || {};
|
||||
const rcpt = (kind) => (d.recipients || []).filter((r) => r.kind === kind)
|
||||
.map((r) => r.display_name ? `${r.display_name} <${r.address}>` : r.address).join(', ');
|
||||
const to = rcpt('to'), cc = rcpt('cc');
|
||||
const from = d.from_name ? `${d.from_name} <${d.from_email || ''}>` : (d.from_email || '—');
|
||||
const lbl = { fontSize: '11px', color: '#8ea2b7' };
|
||||
return (
|
||||
<div style={{ marginTop: '8px', border: '1px solid #263548', borderRadius: '6px', background: '#0d1622', padding: '10px' }}>
|
||||
<div style={lbl}><b>From:</b> {from}</div>
|
||||
{to && <div style={lbl}><b>To:</b> {to}</div>}
|
||||
{cc && <div style={lbl}><b>Cc:</b> {cc}</div>}
|
||||
<div style={lbl}><b>Date:</b> {d.sent_at ? new Date(d.sent_at).toLocaleString() : '—'}</div>
|
||||
<div style={lbl}><b>Subject:</b> {d.subject || '(no subject)'}</div>
|
||||
{(d.attachments || []).length > 0 && (
|
||||
<div style={{ ...lbl, marginTop: '2px' }}><b>Attachments:</b> {d.attachments.map((a) => a.filename).join(', ')}</div>
|
||||
)}
|
||||
<pre style={{ margin: '8px 0 0', maxHeight: '280px', overflowY: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '12px', lineHeight: 1.5, color: '#cdd9e5', fontFamily: 'inherit' }}>
|
||||
{d.body_text || (d.has_html ? '(HTML-only email — open in Gmail to view formatting)' : '(no body captured)')}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
|
||||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||||
if (!status) return <div className="empty-state">No data</div>;
|
||||
@@ -10204,7 +10266,16 @@
|
||||
{deciding === p.id ? 'Adding…' : 'Approve & add to grid'}
|
||||
</button>
|
||||
<button onClick={() => decide(p, 'dismiss')} disabled={deciding === p.id}>Dismiss</button>
|
||||
{p.email_id && (
|
||||
<button
|
||||
onClick={() => toggleEmail(p)}
|
||||
style={{ background: 'transparent', border: '1px solid #263548', color: '#8ea2b7' }}
|
||||
>
|
||||
{openEmail === p.id ? 'Hide email' : 'View email'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{openEmail === p.id && renderProposalEmail(p)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user