diff --git a/frontend/index.html b/frontend/index.html
index f2a2618..b2ed1d0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -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
;
+ if (error) return {error}
;
+ if (!status) return No data
;
+
+ 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 (
+
+
Email Capture
+
+
+
+ {!enabled ? (
+ Integration disabled — no Gmail service-account key on the server.
+ ) : nAccounts === 0 ? (
+ Enabled, but no mailbox is enrolled yet, so nothing is being captured.
+ ) : nError ? (
+ {nAccounts} mailbox(es), {nError} in error — see below.
+ ) : (
+ Capturing: {nAccounts} mailbox(es), {status.matched_emails || 0} emails matched to investors.
+ )}
+
+
+
+
+
+
Status
+
{statusWord}
+
+
+
Mailboxes
+
{nAccounts}
+ {nError ?
{nError} error
: null}
+
+
+
Emails matched to investors
+
{status.matched_emails ?? 0}
+
+
+
Last sync
+
{lastSync}
+
{status.interval_sec ? `every ${Math.round(status.interval_sec / 60)} min` : ''}
+
+
+
+ {isAdmin && (
+
+
Actions
+
+ 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'}
+
+ runAction('sync', '/api/email/sync/run-now', 'Sync started')}
+ disabled={!enabled || nAccounts === 0 || !!busy}
+ >
+ {busy === 'sync' ? 'Syncing…' : 'Sync now'}
+
+ runAction('rematch', '/api/email/rematch', 'Re-match started')}
+ disabled={!enabled || nAccounts === 0 || !!busy}
+ >
+ {busy === 'rematch' ? 'Re-matching…' : 'Re-match to investors'}
+
+
+
+ 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.
+
+
+ )}
+
+
+
Mailboxes
+ {accounts.length === 0 ? (
+
+
✉
+ No mailbox enrolled yet.{isAdmin ? ' Use “Enroll Ten31 mailboxes” above to start capturing.' : ''}
+
+ ) : (
+
+ {accounts.map((a) => (
+
+
{a.email_address}
+
{a.sync_status || 'pending'}{a.backfill_complete ? '' : ' · backfilling'}
+
{a.last_synced_at ? `last ${formatDate(a.last_synced_at)}` : 'never synced'}{a.sync_error ? ` · ${a.sync_error}` : ''}
+
+ ))}
+
+ )}
+
+
+ );
+ };
+
const SystemStatusPage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin';
const [data, setData] = useState(null);
@@ -10460,6 +10621,11 @@
setPage('system-status')}>
◉ System Status
+ {user?.role === 'admin' && (
+ setPage('email-capture')}>
+ ✉ Email Capture
+
+ )}
setPage('feature-requests')}>
✦ Feedback
@@ -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' && }
{page === 'thesis-workshop' && }
{page === 'system-status' && }
+ {page === 'email-capture' && }
{page === 'feature-requests' && }
{page === 'instructions' && }
{page === 'settings' && (
diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts
index 43e591f..0f7671b 100644
--- a/start9/0.4/startos/utils.ts
+++ b/start9/0.4/startos/utils.ts
@@ -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
diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts
index 8cf85fe..640ad4e 100644
--- a/start9/0.4/startos/versions/index.ts
+++ b/start9/0.4/startos/versions/index.ts
@@ -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],
})
diff --git a/start9/0.4/startos/versions/v0.1.0.59.ts b/start9/0.4/startos/versions/v0.1.0.59.ts
new file mode 100644
index 0000000..6b10627
--- /dev/null
+++ b/start9/0.4/startos/versions/v0.1.0.59.ts
@@ -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 () => {} },
+})