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' && (
|
||||
|
||||
@@ -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: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)
|
||||
// * Current: 0.1.0:58 (seed 5 Architect positioning framings into the Workshop as candidate options)
|
||||
export const PACKAGE_VERSION = '0.1.0:58'
|
||||
// * 0.1.0:58 (seed 5 Architect positioning framings into the Workshop as candidate options)
|
||||
// * 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 WEB_PORT = 8080
|
||||
|
||||
@@ -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_57 } from './v0.1.0.57'
|
||||
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({
|
||||
current: v_0_1_0_58,
|
||||
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],
|
||||
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, v_0_1_0_58],
|
||||
})
|
||||
|
||||
@@ -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 () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user