email: Email Capture admin panel (status / enroll / sync / re-match) — v0.1.0:59

Adds an admin-only "Email Capture" page so Gmail capture can be turned on and
monitored from the UI instead of an API call: shows whether the integration is
enabled, how many mailboxes are enrolled, how many emails are matched to investors,
and last sync; with Enroll Ten31 mailboxes / Sync now / Re-match buttons and a hint
that domain-wide delegation must be authorized in Google Workspace first. Disabled
state renders cleanly (no scary error) when the integration is off. Bundles the
email-into-grounding corpus wiring (bf829b7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-06-05 21:00:14 -05:00
parent bf829b784a
commit ee02ccfd64
4 changed files with 194 additions and 4 deletions
+168
View File
@@ -9948,6 +9948,167 @@
);
};
const EmailCapturePage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin';
const [status, setStatus] = useState(null);
const [accounts, setAccounts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
const load = useCallback(async () => {
let s;
try {
s = await api('/api/email/status', {}, token);
} catch (err) {
// Integration off on the server -> render the informative disabled state, not an error.
if (err?.status === 503 || /disabl/i.test(err?.payload?.error || '')) {
setStatus({ enabled: false, accounts_summary: {}, matched_emails: 0 });
setAccounts([]);
return;
}
throw err;
}
let a = { accounts: [] };
try { a = await api('/api/email/accounts', {}, token); } catch (_) { /* none / disabled */ }
setStatus(s);
setAccounts(Array.isArray(a?.accounts) ? a.accounts : []);
}, [token]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
await load();
if (!cancelled) setError('');
} catch (err) {
if (!cancelled) setError(getErrorMessage(err, 'Failed to load email status'));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [load]);
const runAction = async (key, endpoint, successMsg, confirmMsg) => {
if (busy) return;
if (confirmMsg && !window.confirm(confirmMsg)) return;
try {
setBusy(key);
const res = await api(endpoint, { method: 'POST' }, token);
onShowToast(typeof successMsg === 'function' ? successMsg(res) : successMsg, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Action failed'), 'error');
} finally {
setBusy('');
try { await load(); } catch (_) { /* ignore refresh failure */ }
}
};
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>;
const sum = status.accounts_summary || {};
const enabled = !!status.enabled;
const nAccounts = sum.n_accounts || 0;
const nError = sum.n_error || 0;
const capturing = enabled && nAccounts > 0;
const lastSync = status.last_run_unix ? formatDate(new Date(status.last_run_unix * 1000).toISOString()) : '—';
const statusWord = capturing ? 'Capturing' : enabled ? 'Not enrolled' : 'Disabled';
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Email Capture</h2>
<div className="section">
<div className="index-action-status">
{!enabled ? (
<span className="index-job-pill idle"><span className="index-job-dot" /> Integration disabled — no Gmail service-account key on the server.</span>
) : nAccounts === 0 ? (
<span className="index-job-pill idle"><span className="index-job-dot" /> Enabled, but no mailbox is enrolled yet, so nothing is being captured.</span>
) : nError ? (
<span className="index-job-pill running"><span className="index-job-dot" /> {nAccounts} mailbox(es), {nError} in error — see below.</span>
) : (
<span className="index-job-pill"><span className="index-job-dot" /> Capturing: {nAccounts} mailbox(es), {status.matched_emails || 0} emails matched to investors.</span>
)}
</div>
</div>
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-label">Status</div>
<div className="kpi-value" style={{ fontSize: '16px' }}>{statusWord}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Mailboxes</div>
<div className="kpi-value">{nAccounts}</div>
{nError ? <div className="kpi-subtitle">{nError} error</div> : null}
</div>
<div className="kpi-card">
<div className="kpi-label">Emails matched to investors</div>
<div className="kpi-value">{status.matched_emails ?? 0}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">Last sync</div>
<div className="kpi-value" style={{ fontSize: '16px' }}>{lastSync}</div>
<div className="kpi-subtitle">{status.interval_sec ? `every ${Math.round(status.interval_sec / 60)} min` : ''}</div>
</div>
</div>
{isAdmin && (
<div className="section">
<div className="section-title">Actions</div>
<div className="index-action-buttons">
<button
onClick={() => runAction('enroll', '/api/email/accounts/enroll-all', (r) => `Enrolled ${r?.count ?? 0} mailbox(es)`, 'Enroll all Ten31 mailboxes for capture? This connects each active @ten31.xyz users Gmail via the service account. Domain-wide delegation must already be authorized in Google Workspace admin.')}
disabled={!enabled || !!busy}
>
{busy === 'enroll' ? 'Enrolling…' : 'Enroll Ten31 mailboxes'}
</button>
<button
onClick={() => runAction('sync', '/api/email/sync/run-now', 'Sync started')}
disabled={!enabled || nAccounts === 0 || !!busy}
>
{busy === 'sync' ? 'Syncing…' : 'Sync now'}
</button>
<button
onClick={() => runAction('rematch', '/api/email/rematch', 'Re-match started')}
disabled={!enabled || nAccounts === 0 || !!busy}
>
{busy === 'rematch' ? 'Re-matching…' : 'Re-match to investors'}
</button>
</div>
<div className="index-action-hint">
Enroll connects each Ten31 mailbox through the Google service account (requires domain-wide delegation authorized in Google Workspace admin first). Sync pulls new mail; Re-match links already-captured emails to investors. Captured email is sensitive and only ever feeds the Architect through the redaction boundary, never to Claude directly.
</div>
</div>
)}
<div className="section">
<div className="section-title">Mailboxes</div>
{accounts.length === 0 ? (
<div className="empty-state" style={{ padding: '20px 0' }}>
<div className="empty-state-icon"></div>
No mailbox enrolled yet.{isAdmin ? ' Use “Enroll Ten31 mailboxes” above to start capturing.' : ''}
</div>
) : (
<div className="kpi-grid" style={{ marginBottom: 0 }}>
{accounts.map((a) => (
<div key={a.id} className="kpi-card">
<div className="kpi-label">{a.email_address}</div>
<div className="kpi-value" style={{ fontSize: '15px' }}>{a.sync_status || 'pending'}{a.backfill_complete ? '' : ' · backfilling'}</div>
<div className="kpi-subtitle">{a.last_synced_at ? `last ${formatDate(a.last_synced_at)}` : 'never synced'}{a.sync_error ? ` · ${a.sync_error}` : ''}</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
const SystemStatusPage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin';
const [data, setData] = useState(null);
@@ -10460,6 +10621,11 @@
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
<span className="nav-item-icon"></span> System Status
</button>
{user?.role === 'admin' && (
<button className={`nav-item ${page === 'email-capture' ? 'active' : ''}`} onClick={() => setPage('email-capture')}>
<span className="nav-item-icon"></span> Email Capture
</button>
)}
<button className={`nav-item ${page === 'feature-requests' ? 'active' : ''}`} onClick={() => setPage('feature-requests')}>
<span className="nav-item-icon"></span> Feedback
</button>
@@ -10488,6 +10654,7 @@
{page === 'thesis' && 'Thesis'}
{page === 'thesis-workshop' && 'Thesis Workshop'}
{page === 'system-status' && 'System Status'}
{page === 'email-capture' && 'Email Capture'}
{page === 'feature-requests' && 'Feature Requests'}
{page === 'instructions' && 'Instructions'}
{page === 'settings' && 'Settings'}
@@ -10519,6 +10686,7 @@
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
{page === 'instructions' && <InstructionsPage />}
{page === 'settings' && (