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:
@@ -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 user’s 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' && (
|
||||
|
||||
Reference in New Issue
Block a user