From ee02ccfd64d3465074390e0d4fae55337146da4e Mon Sep 17 00:00:00 2001 From: Keysat Date: Fri, 5 Jun 2026 21:00:14 -0500 Subject: [PATCH] =?UTF-8?q?email:=20Email=20Capture=20admin=20panel=20(sta?= =?UTF-8?q?tus=20/=20enroll=20/=20sync=20/=20re-match)=20=E2=80=94=20v0.1.?= =?UTF-8?q?0:59?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/index.html | 168 +++++++++++++++++++++++ start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.59.ts | 20 +++ 4 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 start9/0.4/startos/versions/v0.1.0.59.ts 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
+
+ + + +
+
+ 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 @@ + {user?.role === 'admin' && ( + + )} @@ -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 () => {} }, +})