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 SystemStatusPage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const [data, setData] = useState(null); const [data, setData] = useState(null);
@@ -10460,6 +10621,11 @@
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}> <button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
<span className="nav-item-icon"></span> System Status <span className="nav-item-icon"></span> System Status
</button> </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')}> <button className={`nav-item ${page === 'feature-requests' ? 'active' : ''}`} onClick={() => setPage('feature-requests')}>
<span className="nav-item-icon"></span> Feedback <span className="nav-item-icon"></span> Feedback
</button> </button>
@@ -10488,6 +10654,7 @@
{page === 'thesis' && 'Thesis'} {page === 'thesis' && 'Thesis'}
{page === 'thesis-workshop' && 'Thesis Workshop'} {page === 'thesis-workshop' && 'Thesis Workshop'}
{page === 'system-status' && 'System Status'} {page === 'system-status' && 'System Status'}
{page === 'email-capture' && 'Email Capture'}
{page === 'feature-requests' && 'Feature Requests'} {page === 'feature-requests' && 'Feature Requests'}
{page === 'instructions' && 'Instructions'} {page === 'instructions' && 'Instructions'}
{page === 'settings' && 'Settings'} {page === 'settings' && 'Settings'}
@@ -10519,6 +10686,7 @@
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />} {page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis-workshop' && <ThesisWorkshopPage 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 === '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 === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
{page === 'instructions' && <InstructionsPage />} {page === 'instructions' && <InstructionsPage />}
{page === 'settings' && ( {page === 'settings' && (
+3 -2
View File
@@ -23,8 +23,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:55 (Architect grounding boundary: redaction/re-hydration privacy gate) // * 0.1.0:55 (Architect grounding boundary: redaction/re-hydration privacy gate)
// * 0.1.0:56 (Thesis Workshop redesign: edit/choose/delete + approve-as-current) // * 0.1.0:56 (Thesis Workshop redesign: edit/choose/delete + approve-as-current)
// * 0.1.0:57 (redaction fix: magnitude regex no longer eats the word after an amount) // * 0.1.0:57 (redaction fix: magnitude regex no longer eats the word after an amount)
// * Current: 0.1.0:58 (seed 5 Architect positioning framings into the Workshop as candidate options) // * 0.1.0:58 (seed 5 Architect positioning framings into the Workshop as candidate options)
export const PACKAGE_VERSION = '0.1.0:58' // * Current: 0.1.0:59 (Email Capture admin panel + matched email into the grounding corpus)
export const PACKAGE_VERSION = '0.1.0:59'
export const DATA_MOUNT_PATH = '/data' export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080 export const WEB_PORT = 8080
+3 -2
View File
@@ -19,8 +19,9 @@ import { v_0_1_0_55 } from './v0.1.0.55'
import { v_0_1_0_56 } from './v0.1.0.56' import { v_0_1_0_56 } from './v0.1.0.56'
import { v_0_1_0_57 } from './v0.1.0.57' import { v_0_1_0_57 } from './v0.1.0.57'
import { v_0_1_0_58 } from './v0.1.0.58' import { v_0_1_0_58 } from './v0.1.0.58'
import { v_0_1_0_59 } from './v0.1.0.59'
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_0_1_0_58, current: v_0_1_0_59,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57], other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58],
}) })
+20
View File
@@ -0,0 +1,20 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Email Capture admin panel + email-into-grounding wiring. The new "Email Capture"
// page (admin-only) shows whether Gmail capture is on, lets an admin enroll Ten31
// mailboxes, sync now, and re-match emails to investors — so capture can be turned
// on from the UI instead of an API call. Backend: the Architect's grounding corpus
// now includes matched email bodies (through the redaction boundary, never to Claude
// directly), inert until a mailbox is enrolled. No schema migration.
export const v_0_1_0_59 = VersionInfo.of({
version: '0.1.0:59',
releaseNotes: {
en_US: [
'New Email Capture page (admin): see whether Gmail capture is on, enroll your Ten31',
'mailboxes, sync now, and re-match emails to investors — all from the UI. Once capture',
'is on, the Architect can ground the thesis in what LPs actually wrote, through the',
'redaction boundary. Requires domain-wide delegation authorized in Google Workspace.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})