Personal executive assistant dashboard that consolidates emails, calendars, weather, Todoist-backed deadlines/tasks, and finances into a current operational workspace. Single-user app built with React 19 + Express.js, backed by Turso (LibSQL) and provider-backed email triage. Deployed on Render.
graph TB
subgraph Browser
SPA[React 19 SPA]
end
subgraph Server["Express.js (port 3001)"]
MW[Middleware Stack]
Routes[Route Handlers]
Pipeline[Current Dashboard Pipeline]
Scheduler[node-cron Scheduler]
end
subgraph External["External Services"]
Gmail[Gmail API<br/>OAuth 2.0]
iCloud[iCloud IMAP<br/>App Passwords]
GCal[Google Calendar API]
Weather[Pirate Weather API]
Todoist[Todoist API]
Actual[Actual Budget API]
EmailAI[Email AI<br/>Anthropic or OpenAI]
end
subgraph Storage
Turso[(Turso / LibSQL<br/>Main DB)]
end
SPA <-->|/api/*| MW
MW --> Routes
Routes --> Pipeline
Scheduler -->|cron triggers| Pipeline
Pipeline --> Gmail & iCloud & GCal & Weather & Todoist & Actual
Pipeline --> EmailAI
Routes --> Turso
Pipeline --> Turso
| Layer | Technology | Purpose |
|---|---|---|
| Frontend | React 19, React Router 7 | SPA with client-side routing |
| Build | Vite 8, Tailwind CSS 4 | Bundling, dev server, utility-first CSS |
| UI | shadcn/ui, Radix, Framer Motion | Component primitives, animations |
| Backend | Express 4 | HTTP API server |
| Database | Turso (LibSQL) | SQLite-compatible cloud DB |
| AI | Anthropic Messages API, OpenAI Responses API | Email triage and bill signals |
| Search | SQLite FTS5 | Full-text email search |
| Gmail API, ImapFlow (iCloud) | Multi-account email fetching | |
| Calendar | Google Calendar API | Event sync (reuses Gmail OAuth) |
| Weather | Pirate Weather | Forecast data |
| Tasks | Todoist API | Deadline items + personal tasks |
| Finance | @actual-app/api behind provider worker + EA mirrors | Budget tracking, bill management |
| Auth | bcrypt, WebAuthn passkeys, cookie sessions | Password-or-passkey default, optional strict mode, offline recovery |
| Encryption | AES-256-GCM | Credentials encrypted at rest |
| Scheduling | node-cron | Snapshot boundary checks and background workers |
File-level detail lives in per-area CLAUDE.md maps (see "Area Maps" in AGENTS.md); this section stays at directory granularity.
Structural directory layout (depth 3 under src/ and server/). Regenerated by scripts/regen-architecture.mts on npm run dev and via the pre-commit hook — do not hand-edit between markers.
src/
├── auth/
├── components/
│ ├── alfred/
│ ├── bills/
│ │ └── bill-badge/
│ ├── briefing/
│ ├── calendar/
│ │ ├── events/
│ │ ├── modal/
│ │ ├── reminders/
│ │ └── views/
│ ├── dashboard/
│ │ ├── context/
│ │ ├── layout/
│ │ ├── needsYou/
│ │ ├── rails/
│ │ └── timeline/
│ ├── email/
│ ├── inbox/
│ │ ├── mobile/
│ │ ├── reader/
│ │ └── test-utils/
│ ├── layout/
│ ├── news/
│ ├── notes/
│ ├── settings/
│ │ ├── cards/
│ │ ├── sections/
│ │ └── shared/
│ ├── shared/
│ │ └── pickers/
│ ├── shell/
│ │ └── analytics/
│ ├── todoist/
│ │ └── add-task-panel/
│ └── ui/
├── context/
├── demo/
├── dev/
├── hooks/
│ ├── calendar/
│ ├── email/
│ └── settings/
├── lib/
└── pages/
server/
├── actual/
├── alfred/
├── auth/
├── bills/
│ └── bill-extractors/
├── calendar/
├── dashboard/
│ └── current-providers/
├── db/
│ ├── migrations/
│ └── tldraw-assets/
├── email/
│ ├── search/
│ │ └── evals/
│ └── test-utils/
├── middleware/
├── news/
├── platform/
├── reminders/
├── routes/
│ └── briefing/
├── scripts/
├── snapshots/
├── tasks/
├── test-utils/
├── tldraw/
├── transaction-imports/
│ └── parsers/
├── transactions/
└── triage/
/ ──────── Dashboard (auth required)
/login ─── Login
/settings ─ Settings (auth required)
Auth guard in App.tsx: authenticated ? <Component /> : <Navigate to="/login" />. Auth state: null = loading spinner, true/false = route.
The calendar modal has two top-level workspaces: Events and Bills. Deadlines are not a standalone workspace; Todoist-backed deadline items render as an Events overlay with Events-owned detail, floating-detail, create, edit, and completion flows.
No global state library. Three layers:
graph LR
subgraph Hooks["Custom Hooks (data fetching)"]
UCD[useCurrentDashboard]
UN[useNotifications]
end
subgraph Context["DashboardContext (shared UI state)"]
AE[activeAccount]
SE[selectedEmail]
ET[expandedTask]
Handlers[dismiss / complete / markRead]
end
subgraph Components
Sections[Section Components]
end
UCD -->|briefing adapter, liveData adapter, activeSnapshot| Context
UN -->|monitors liveData| Browser[Browser Notifications]
Context --> Sections
useCurrentDashboard — Normal dashboard boot/runtime hook. Fetches /api/dashboard/current, listens to /api/dashboard/current/events, and exposes stable briefingData, liveData, and activeSnapshot adapters for the existing dashboard component tree. Event refetch scope is typed: email_triage reads only /api/briefing/snapshot/active, while every other or unknown source keeps the full-current path. Concurrent bursts coalesce to the strongest pending scope (current dominates), and a failed snapshot-only read falls back once to the full envelope.
DashboardContext — Shared across all dashboard sections. Derives emailAccounts, billEmails, totalBills, totalNoiseCount via useMemo. Provides action handlers that update both API and local state.
Top-level React hooks enumerated from src/hooks/**/use*.{js,ts} and src/components/**/use*.{js,ts} (test files excluded). Each entry shows the file's first exported name. Model helpers (e.g. calendarRangeModel.ts) live alongside hooks but are excluded from this list — they're pure modules, not React hooks.
| Export | File |
|---|---|
useAlfredChat |
src/components/alfred/useAlfredChat.ts |
useBillBadgeForm |
src/components/bills/useBillBadgeForm.ts |
useCalendarEditorHistory |
src/components/calendar/events/useCalendarEditorHistory.ts |
useCalendarEditorPickers |
src/components/calendar/events/useCalendarEditorPickers.ts |
useCalendarEventCreateCoordination |
src/components/calendar/events/useCalendarEventCreateCoordination.ts |
useCalendarEventEditor |
src/components/calendar/events/useCalendarEventEditor.ts |
useCalendarEventEditorSession |
src/components/calendar/events/useCalendarEventEditorSession.ts |
useCalendarEventMutations |
src/components/calendar/events/useCalendarEventMutations.ts |
useCalendarEventQuickActionMutations |
src/components/calendar/events/useCalendarEventQuickActionMutations.ts |
useCalendarEventTitleComposer |
src/components/calendar/events/useCalendarEventTitleComposer.ts |
useCalendarLocationSuggestions |
src/components/calendar/events/useCalendarLocationSuggestions.ts |
useCalendarQuickActions |
src/components/calendar/events/useCalendarQuickActions.ts |
useCalendarSources |
src/components/calendar/events/useCalendarSources.ts |
useEventRecurrenceDraft |
src/components/calendar/events/useEventRecurrenceDraft.ts |
useEventReminderDrafts |
src/components/calendar/events/useEventReminderDrafts.ts |
useCalendarGridEffects |
src/components/calendar/modal/useCalendarGridEffects.ts |
useCalendarGridOverflow |
src/components/calendar/modal/useCalendarGridOverflow.ts |
useFloatingDetailDrag |
src/components/calendar/modal/useFloatingDetailDrag.ts |
useFloatingDetailPlacement |
src/components/calendar/modal/useFloatingDetailPlacement.ts |
useCalendarGhostPreview |
src/components/calendar/useCalendarGhostPreview.ts |
useDeadlineQuickActions |
src/components/calendar/views/deadlines/useDeadlineQuickActions.ts |
useAlfredPanelState |
src/components/dashboard/useAlfredPanelState.ts |
useCalendarWorkspaceState |
src/components/dashboard/useCalendarWorkspaceState.ts |
useDashboardItemSheet |
src/components/dashboard/useDashboardItemSheet.ts |
useDashboardShellHotkeys |
src/components/dashboard/useDashboardShellHotkeys.ts |
useLiveReadOverrides |
src/components/dashboard/useLiveReadOverrides.ts |
useMobileDashboardScrollRestoration |
src/components/dashboard/useMobileDashboardScrollRestoration.ts |
useMobileInboxNavigation |
src/components/dashboard/useMobileInboxNavigation.ts |
useSnapshotNavigation |
src/components/dashboard/useSnapshotNavigation.ts |
useBillPayResolver |
src/components/inbox/reader/useBillPayResolver.ts |
useEmailBody |
src/components/inbox/reader/useEmailBody.ts |
useTransactionImportStatus |
src/components/inbox/reader/useTransactionImportStatus.ts |
useInboxActionDispatch |
src/components/inbox/useInboxActionDispatch.ts |
useInboxController |
src/components/inbox/useInboxController.ts |
useInboxKeyboardCommands |
src/components/inbox/useInboxKeyboardCommands.ts |
useInboxSessionStore |
src/components/inbox/useInboxSessionState.ts |
useInboxUndoSlot |
src/components/inbox/useInboxUndoSlot.ts |
useIndexedSearch |
src/components/inbox/useIndexedSearch.ts |
useSnapshotOptimisticOverlay |
src/components/inbox/useSnapshotOptimisticOverlay.ts |
useTldrawAutosave |
src/components/notes/useTldrawAutosave.ts |
useAddTaskPanelController |
src/components/todoist/add-task-panel/useAddTaskPanelController.ts |
useAddTaskPanelPlacement |
src/components/todoist/add-task-panel/useAddTaskPanelPlacement.ts |
useDirtyCloseConfirmation |
src/components/todoist/add-task-panel/useDirtyCloseConfirmation.ts |
useTodoistReminderDrafts |
src/components/todoist/add-task-panel/useTodoistReminderDrafts.ts |
useAgendaFetch |
src/hooks/calendar/useAgendaFetch.ts |
useAgendaSyncPolicy |
src/hooks/calendar/useAgendaSyncPolicy.ts |
useCalendarAgendaInteractions |
src/hooks/calendar/useCalendarAgendaInteractions.ts |
useCalendarAgendaScroll |
src/hooks/calendar/useCalendarAgendaScroll.ts |
useCalendarControllerActions |
src/hooks/calendar/useCalendarControllerActions.ts |
useCalendarControllerHotkeys |
src/hooks/calendar/useCalendarControllerHotkeys.ts |
useCalendarControllerLifecycle |
src/hooks/calendar/useCalendarControllerLifecycle.ts |
useCalendarControllerShell |
src/hooks/calendar/useCalendarControllerShell.tsx |
useCalendarControllerViewData |
src/hooks/calendar/useCalendarControllerViewData.ts |
useCalendarDeadlineOverlay |
src/hooks/calendar/useCalendarDeadlineOverlay.ts |
useCalendarDomainRange |
src/hooks/calendar/useCalendarDomainRange.ts |
useCalendarEditorScrollRouting |
src/hooks/calendar/useCalendarEditorScrollRouting.ts |
useCalendarEventSelectionSet |
src/hooks/calendar/useCalendarEventSelectionSet.ts |
useCalendarFloatingDetail |
src/hooks/calendar/useCalendarFloatingDetail.ts |
useCalendarModalController |
src/hooks/calendar/useCalendarModalController.tsx |
useCalendarModalHotkeys |
src/hooks/calendar/useCalendarModalHotkeys.ts |
useCalendarModalSearch |
src/hooks/calendar/useCalendarModalSearch.ts |
useCalendarModalSelection |
src/hooks/calendar/useCalendarModalSelection.ts |
useCalendarModalViewModel |
src/hooks/calendar/useCalendarModalViewModel.ts |
useCalendarMonthNavigation |
src/hooks/calendar/useCalendarMonthNavigation.ts |
useCalendarOpenRequestRouting |
src/hooks/calendar/useCalendarOpenRequestRouting.ts |
useCalendarRange |
src/hooks/calendar/useCalendarRange.ts |
useCalendarScrollSync |
src/hooks/calendar/useCalendarScrollSync.ts |
useCalendarScrollViewport |
src/hooks/calendar/useCalendarScrollViewport.ts |
useCalendarSearchActivation |
src/hooks/calendar/useCalendarSearchActivation.ts |
useDashboardDetailFocus |
src/hooks/calendar/useDashboardDetailFocus.ts |
useDashboardFocusRetry |
src/hooks/calendar/useDashboardFocusRetry.ts |
useDeadlineOverlayState |
src/hooks/calendar/useDeadlineOverlayState.ts |
useEditorCancelOnScroll |
src/hooks/calendar/useEditorCancelOnScroll.ts |
useFloatingEditorRouting |
src/hooks/calendar/useFloatingEditorRouting.ts |
usePlanningReadinessState |
src/hooks/calendar/usePlanningReadinessState.ts |
useStaleDomainCache |
src/hooks/calendar/useStaleDomainCache.ts |
useViewportWidth |
src/hooks/calendar/useViewportWidth.ts |
useInboxSelectionHistory |
src/hooks/email/useInboxSelectionHistory.ts |
useSettingsPage |
src/hooks/settings/useSettingsPage.ts |
useTransactionImports |
src/hooks/settings/useTransactionImports.ts |
useActiveSnapshot |
src/hooks/useActiveSnapshot.ts |
useAutoRefresh |
src/hooks/useAutoRefresh.ts |
useBrowserBackDismiss |
src/hooks/useBrowserBackDismiss.ts |
useCurrentDashboard |
src/hooks/useCurrentDashboard.ts |
useDismissablePortal |
src/hooks/useDismissablePortal.ts |
useIsMobile |
src/hooks/useIsMobile.ts |
useMediaQuery |
src/hooks/useMediaQuery.ts |
useMotionPresence |
src/hooks/useMotionPresence.ts |
useNews |
src/hooks/useNews.ts |
useNotifications |
src/hooks/useNotifications.ts |
useRemoteContentTrust |
src/hooks/useRemoteContentTrust.ts |
useTriageNotificationSounds |
src/hooks/useTriageNotificationSounds.ts |
useUtilityPayLinks |
src/hooks/useUtilityPayLinks.ts |
useWarmImport |
src/hooks/useWarmImport.ts |
Calendar is the largest frontend feature area (~240 files). File-level maps live in src/components/calendar/CLAUDE.md (router for modal/, events/, views/, root rail files) and src/hooks/calendar/CLAUDE.md (hooks + pure models); server-side calendar files are mapped in server/calendar/CLAUDE.md and server/routes/CLAUDE.md.
Placement rule: new calendar hooks and models go in src/hooks/calendar/. Colocate a hook under src/components/calendar/ only when it is private to one component subtree (the events/ editor hooks are the precedent). Do not add calendar files outside these two client roots.
Calendar also exposes an internal, behavior-neutral event create-seed bridge. shared/types/calendar.ts owns the serializable seed, explicit source intent, origin, acknowledgement, and completion values; src/hooks/calendar/calendarEventCreateBridge.ts owns the client-only callback envelope. useCalendarWorkspaceState carries one pending request through the existing open-request counter, useCalendarOpenRequestRouting consumes and acknowledges it once, and the existing event editor retains origin/completion only in memory until cancel or successful save. Provider writes still occur exclusively through the editor's existing Create event action, and completion uses the normalized event already returned by that mutation flow.
GET /api/dashboard/current
→ current-service reads durable current rows + active snapshot view
→ useCurrentDashboard adapts the envelope into briefingData/liveData/activeSnapshot
→ DashboardContext derives computed values and action handlers
→ Section components render via useDashboard()
401 responses from any API call → automatic redirect to /login.
| Gesture | Action |
|---|---|
| Tap R key | Sync current dashboard data and active snapshot |
| Hold Suspend 1.5s | Suspend Render service |
| Click email | Expand EmailBody panel (iframe with sanitized HTML) |
| Click task status dot | Cycle task status (incomplete → in_progress → complete) |
| Type in Inbox search | FTS5 email keyword search across indexed INBOX mail |
graph LR
Request --> TP[trust proxy]
TP --> Sec[security headers]
Sec --> JSON[express.json]
JSON --> Cookie[cookieParser]
Cookie --> CSRF{"CSRF Check\n(x-requested-with header OR\nBearer token OR login path)"}
CSRF -->|non-GET| Validate
CSRF -->|GET/HEAD/OPTIONS| Route
Validate --> Route
Route --> Auth["/api/auth"]
Route --> Briefing["/api/briefing"]
Route --> EA["/api/ea"]
Route --> Cal["/api/calendar"]
Briefing --> ReqAuth[requireAuth middleware]
EA --> ReqAuth
Cal --> ReqAuth
ReqAuth --> Handler[Route Handler]
| Group | Mount | Endpoints | Key Responsibilities |
|---|---|---|---|
| Auth | /api/auth |
23 | First-run owner claim, canonical-domain management, password/passkey login, recovery and step-up, passkey management, session check/logout, scoped API tokens |
| Briefing | /api/briefing |
domain routers | Email ops (read/trash/snooze/dismiss), snapshots, FTS email search, task ops, Actual Budget |
| Dashboard | /api/dashboard |
5 | Current dashboard envelope, current refresh/sync, health, SSE change events |
| Accounts | /api/ea |
15 | Account CRUD, Gmail OAuth, settings, schedules, geocode, important senders |
| Calendar | /api/calendar |
1 | Read-only calendar slice exposed separately from briefing |
sequenceDiagram
participant B as Browser
participant S as Server
participant DB as Turso
B->>S: GET /api/auth/setup/status
alt Instance is unclaimed
B->>S: POST /api/auth/setup/claim {password, canonicalOrigin}
S->>S: Generate stable owner UUID and bcrypt hash
S->>DB: Atomically INSERT OR IGNORE ea_owner + confirmed ea_instance_metadata
S->>DB: INSERT ea_sessions (hashed token, expires_at)
S->>B: Set-Cookie: ea_session; close public setup
end
B->>S: POST /api/auth/login {password}
S->>DB: SELECT password_hash FROM ea_owner singleton
S->>S: bcrypt.compare(password, stored password_hash)
alt Default password-or-passkey mode
S->>DB: INSERT ea_sessions (token, generation, auth method, password proof time, expires_at)
S->>B: Set-Cookie: ea_session (httpOnly, secure, sameSite=strict)
else Explicit password-plus-passkey mode
S->>DB: INSERT ea_pending_auth (5-min password proof + security generation)
S->>B: Set-Cookie: ea_pending_auth (httpOnly, secure, sameSite=strict)
B->>S: POST /api/auth/passkey/authentication/options
S->>DB: INSERT ea_webauthn_challenges (one-time challenge + generation)
S->>B: WebAuthn authentication options
B->>S: POST /api/auth/passkey/authentication/verify
S->>DB: Atomically consume challenge/pending auth and update passkey usage
S->>DB: INSERT ea_sessions only if owner generation is unchanged
S->>B: Set-Cookie: ea_session, clear ea_pending_auth
end
B->>S: GET /api/dashboard/current (cookie)
S->>DB: JOIN ea_sessions to ea_owner on security_generation
S->>S: Check expires_at > now
S->>B: 200 current dashboard envelope (or 401 if expired)
The browser auth model has six distinct states:
- Unclaimed Instance - no
ea_ownersingleton row. Only static/setup auth routes andGET /healthzare available; provider APIs and all background workers are gated. - Authenticated Session -
ea_sessioncookie. The browser receives a raw 32-byte hex session token, butea_sessionsstores onlysha256:<digest>, its authentication method, password-proof timestamp, and owner security generation. Every validation joins the session to the current owner generation, so a credential transition or operator reset invalidates every older session immediately, including across processes and even if a deletion races. Used by the SPA and required by normal dashboard routes; the app does not prompt for passkey on every request. - Pending Passkey Authentication -
ea_pending_authcookie plus a row inea_pending_auth. Created after a correct password in explicit strict mode, or when default-mode passwordless passkey login begins. It can request and verify WebAuthn options but cannot access dashboard routes. - Registered Passkey - row in
ea_passkey_credentialscontaining credential ID, public key, sign count, label, transports, backup state, and device type. Public key material never leaves the server in management responses. - Recent Password Authentication - the authenticated session's
password_authenticated_atis within ten minutes. Required for password, passkey, recovery-code, auth-mode, canonical-domain, and powerful API-token changes. Passkey-only and recovery-created sessions do not satisfy this boundary until the owner confirms the current password; failed confirmations are throttled in the session row before more bcrypt work is accepted. - Recovery or Operator Reset - one offline recovery code can replace credentials in-app; the local
npm run auth:reset-passkeys -- --confirmpath remains the last-resort operator reset.
The browser keeps a separate, shorter Security Settings unlock. Sensitive controls start locked whenever the System section mounts and lock on pagehide; the server's recent-auth timestamp can authorize requests during that visit but never auto-opens a later section visit or restored page.
Ownership is database-backed in the singleton ea_owner row. Fresh claims rely on
an out-of-band EA_SETUP_TOKEN plus the singleton primary-key invariant so only
an authorized claimant can attempt the one concurrent insert that succeeds.
Existing EA_USER_ID plus EA_PASSWORD_HASH values are an optional startup
compatibility source: startup imports the exact pair when no owner exists and
fails closed for partial or conflicting state.
Every sensitive credential or security mutation is a compare-and-swap
transaction against ea_owner.security_generation. The transaction increments
the generation, performs the mutation, and clears sessions, pending auth, and
WebAuthn challenges before commit; the initiating browser receives a replacement
session only after the commit. Offline recovery additionally revokes all scoped
API tokens.
Two credential paths exist, but they no longer feed a single shared "any auth works" guard:
- Cookie session - normal dashboard access after password, passwordless passkey, strict password-plus-passkey, or successful recovery.
- Scoped API token -
Authorization: Bearer <token>validated againstea_api_tokens(token hash, scopes, expiry). Used only by explicitly opted-in external integration endpoints (currentlyPOST /api/briefing/actual/quick-txn). New tokens expire by default after 90 days unless overridden by env. Bearer requests are exempt from thex-requested-withCSRF check because they carry their own unforgeable secret.
Production WebAuthn configuration prefers the persisted canonical HTTPS origin, deriving RP name Setpoint, RP ID from its hostname, and the exact expected origin. Compatible legacy EA_WEBAUTHN_* and GOOGLE_REDIRECT_URI values import only when they resolve to one origin; otherwise the explicit values remain compatibility fallbacks. Development defaults remain Setpoint, localhost, and http://localhost:5173.
Gmail OAuth: separate CSRF token flow (UUID, 10-min TTL, one-time use) stored in ea_csrf_tokens, plus a short-lived SameSite=Lax browser-bind cookie for callback binding.
The callback credential is a 256-bit random bearer token whose lifetime lasts until the owner generates, imports, revokes, or switches away from it. Setpoint persists only its SHA-256 hash (or an explicit disabled tombstone); the one-time generated callback URL is the only response that contains a newly generated plaintext token. Callback verification hashes the candidate in-process and uses a fixed-length timingSafeEqual comparison against the stored hash.
Every callback performs one narrow authoritative read of push_token_hash and token_disabled from the shared database. There is intentionally no TTL or process-local verification cache: a cache would delay revocation and rotation, let application instances disagree, and repopulate inconsistently after restart. Consequently, generation, environment-token import, revocation, and switching to the host token take effect on the next callback across all instances and survive restarts without a local invalidation protocol. A database read failure fails closed with a retryable 503; callback logs and database query arguments contain neither plaintext tokens nor ciphertext.
Generating or importing a token invalidates the previous Setpoint credential immediately, but Google Pub/Sub subscription configuration is external. Until the subscription's push endpoint is updated to the newly returned callback URL, deliveries using the old URL fail authorization and rely on Pub/Sub retry plus the periodic Gmail reconciliation path. Operators should therefore rotate the external subscription promptly and treat the one-time callback URL as a secret.
Email data flows through the durable email index, triage rows, snapshot windows/items, snooze state, dismissed-email state, and current-data cache. Weather, calendar, Todoist deadlines/tasks, bills, and Actual are fetched through domain services and assembled into the /api/dashboard/current envelope. Desktop Notes loads a fresh tldraw document independently from /api/tldraw/bootstrap only after the tab is opened, reconciles it with a device-local IndexedDB recovery envelope, and clears that envelope only after the matching server revision is confirmed.
flowchart TD
Fetch["Provider fetch or push sync"]
Index["ea_email_index + ea_email_fts"]
Jobs["ea_triage_jobs"]
Triage["ea_email_triage"]
Snapshot["ea_briefing_snapshots + items"]
Cache["ea_current_data_cache"]
Dashboard["/api/dashboard/current"]
Fetch --> Index --> Jobs --> Triage --> Snapshot --> Dashboard
Cache --> Dashboard
Incoming email classification is handled by server/triage/triage-worker.ts against durable ea_email_triage rows and ea_triage_jobs. Deterministic preflight in triage-preflight.ts can finalize trusted-sender and obvious-noise cases or route ambiguous and high-risk mail to the appropriate model tier. Security notifications that are not obvious verification-code noise route immediately to the cheap model. Provider-backed calls use the selected email AI provider/model from email-ai-models.ts; bill extraction uses bill-extract.ts and the bill extraction provider/model settings.
Email interests from settings influence classification. Scheduled payments from Actual Budget are cross-referenced during bill extraction to suppress duplicate bill detections.
Model selection is user-configurable through /api/ea/models, defaults to Anthropic claude-sonnet-4-6, and can use OpenAI gpt-5.5. Anthropic uses temperature 0 for format adherence; OpenAI triage uses structured Responses API output with cache-key hints where supported.
Settings is the durable authority for Alfred's provider/model selection through ea_settings.alfred_provider and alfred_model; /api/ea/alfred-models projects the centralized catalog and provider availability. POST /api/alfred/run resolves that selection only when creating a conversation, then binds the provider/model to the in-memory conversation so later Settings changes apply after New chat rather than mid-thread.
server/alfred/alfred-run.ts owns the provider-neutral tool loop. Domain tools remain read-only. propose_calendar_event is a non-mutating exception that requires exact evidence from a separate ephemeral trusted-owner turn, stages at most one run-local proposal, and commits it to the conversation only immediately before a successful run_end; failed runs preserve the prior proposal and trusted-turn boundary. Owner intent is interpreted semantically by Alfred rather than through an instruction/confirmation keyword list, while exact-message provenance prevents email text from supplying authority. Anthropic uses the Messages adapter and transient cache breakpoints. OpenAI uses the Responses adapter with SSE, function tools, store: false, and full returned output/reasoning-item replay for stateless tool continuation. Both adapters normalize text, tool calls, proposal events, stop state, and token usage into the existing Alfred SSE and analytics contracts.
Calendar proposal identity, revision status, duplicate confirmation, and expiry remain in the existing in-memory conversation. The browser maps a validated proposal into the typed Calendar create-seed bridge; Review in Calendar performs no write and Alfred closes only after editor acceptance. The existing Calendar editor is still the sole provider-write boundary. Its normalized completion value becomes the mounted Alfred card's Created truth, while POST /api/alfred/conversations/:id/proposals/:proposalId/created coordinates identity only and never receives saved-event content.
The desktop reader's email-context handoff is a separate model-free preparation boundary. POST /api/alfred/email-context fetches and canonicalizes one provider email into a 50k-character-bounded, trust-fenced snapshot held behind an owner-scoped four-hour in-memory handle. POST /api/alfred/run claims that handle, includes it once with the owner prompt, consumes it on run_end, and releases it on failure. The browser and usage ledger never receive or persist the canonical body.
Durable Triage Queue — Provider sync creates pending triage rows and deduped jobs. Workers can resume from durable rows after process restarts. Arrival-grace jobs retain their 30-second durable scheduled_for; successful writes arm one process-local earliest-deadline wake-up, and startup/completed drains rediscover the earliest queued timestamp. A five-minute cron remains the restart, missed-signal, and stale-claim recovery fallback without continuously polling the idle queue.
Email Indexing & Push Ingestion — All fetched emails (read + unread) are persisted to ea_email_index with an FTS5 virtual table for cross-account keyword search. Gmail accounts can register an INBOX Pub/Sub watch through GMAIL_PUBSUB_TOPIC; /api/gmail/push decodes the Pub/Sub envelope, queues an account-level gmail_history_sync job, acknowledges promptly, and requests an immediate coalesced history drain. The per-minute history cron remains durable recovery. The history-sync worker uses the stored Gmail last_history_id cursor to fetch new INBOX messages, index them, create pending durable triage rows, and enqueue message-level email_triage jobs. The 2-hour background indexer remains a reconciliation path for missed push events, downtime, watch expiry, and iCloud polling. Historical completeness is handled separately by the resumable INBOX backfill worker, which defaults to 365 days, scans fixed 7-day windows newest-to-oldest, and records per-account state in ea_email_backfill_state.
Scheduler Lifecycle & Timing Evidence — Scheduler-owned cron callbacks, timers, immediates, and worker drains enter a shared work registry. Shutdown closes admission sources first and then awaits all admitted work; the process shutdown timeout remains the outer bound. Gmail history completion emits content-free [EA Timing] stages for provider delivery, durable queue wait, sync/index work, and snapshot attachment. Dashboard event refetches emit receipt-to-accepted-state duration with the selected active_snapshot or current scope. Invalid timestamps omit dependent durations, and no timing path changes queue completion or state-application behavior.
Current Data Cache — Non-email boot-critical data is cached by user and cache key, with health metadata exposed in the current dashboard envelope.
The fifth shell tab: RSS/Atom headlines only, no AI classification or summarization, $0 running cost. Migration 026_news.sql adds ea_news_topics (owner-named topic sections), ea_news_sources (per-topic feed rows; kind='hn' sources build their hnrss.org URL from hn_query/min_points instead of storing one), and ea_news_items (a rolling window keyed unique on (source_id, guid), plus ea_settings.news_last_seen_at for the single seen-marker); 029_news_retry_after.sql adds durable provider retry windows. server/news/news-poller.ts is an in-process interval worker (mirrors the bills-mirror-sync/calendar-search-mirror pattern): a 20-minute sweep does conditional-GET fetches (ETag/Last-Modified, 10s timeout), parses with rss-parser, upserts items, self-heals redirected feed URLs, and backs a source off to a ~6h retry cadence after 5 consecutive failures. Reddit 429s pause the shared Reddit host until the provider's persisted Retry-After expires, with six hours as the fallback. Retention keeps the newest 30 items per source and additionally deletes anything older than 14 days beyond that. server/routes/news.ts exposes the page payload (GET /api/news), topic/source CRUD, starter-catalog import, an add-source preview endpoint (fetches the pasted URL and follows one autodiscovered <link rel=alternate> if it isn't already a feed), the seen-marker bump, and a debounced manual refresh.
| Source | Module | API | Auth | Failure Behavior |
|---|---|---|---|---|
| Gmail | server/email/gmail.ts |
Gmail REST API | OAuth 2.0 (auto-refresh tokens) | Empty array, continue |
| iCloud | server/email/icloud.ts |
IMAP (imap.mail.me.com:993) | App-specific password | Empty array, continue |
| Calendar | server/calendar/calendar.ts |
Google Calendar API | Reuses Gmail OAuth | Empty array, continue |
| Weather | server/platform/weather.ts |
Pirate Weather | API key | Cached data or placeholder |
| Todoist | server/tasks/todoist.ts |
Todoist REST v1 | Bearer token (encrypted) | Empty array, continue |
| Actual Budget | server/actual/actual.ts + server/bills/bills-service.ts mirrors |
@actual-app/api SDK in persistent worker | Server URL + password (encrypted) | Mirrored data, degraded sync health |
| Email triage AI | server/triage/triage-worker.ts |
Anthropic Messages API or OpenAI Responses API | Provider API key | Durable job remains retryable or falls back by mode |
| Bill extraction AI | server/bills/bill-extract.ts |
Anthropic Messages API or OpenAI Responses API | Provider API key | Bill extraction returns no bill signal |
| Alfred AI | server/alfred/ |
Anthropic Messages API or OpenAI Responses API | Conversation-bound provider API key | Current run emits an error; conversation transcript rolls back to its prior valid boundary |
| All data source failures are caught individually — one source going down never blocks the current dashboard. Email triage and bill extraction failures are isolated to durable jobs or the specific bill-signal request. |
erDiagram
ea_accounts {
text id PK "email or icloud-prefix"
text user_id
text type "gmail | icloud"
text email
text label
text color
text icon
int calendar_enabled
text credentials_encrypted "AES-256-GCM"
int sort_order
datetime created_at
datetime updated_at
}
ea_settings {
text user_id PK
text schedules_json "cron schedule array"
int email_lookback_hours
real weather_lat
real weather_lng
text weather_location
text actual_budget_url
text actual_budget_password_encrypted
text actual_budget_sync_id
text email_ai_provider
text email_ai_model
text email_interests_json
text todoist_api_token_encrypted
text important_senders_json
datetime created_at
}
ea_sessions {
text token PK "sha256 digest"
int expires_at "Unix ms, 30-day TTL"
int authenticated_at "Unix ms"
int password_authenticated_at "Unix ms or 0"
int security_generation
text auth_method
int step_up_failure_count
int step_up_blocked_until
datetime created_at
}
ea_csrf_tokens {
text token PK "UUID"
text account_label
int expires_at "Unix ms, 10-min TTL"
datetime created_at
}
ea_dismissed_emails {
text user_id PK
text email_id PK
datetime dismissed_at
}
ea_completed_tasks {
text user_id PK
text todoist_id PK
text due_date "snapshot due for visibility window"
text snapshot_json "JSON of last-known task for render after source drop"
datetime completed_at
}
ea_snoozed_emails {
text user_id PK
text email_id PK
int until_ts "Unix ms; snooze-waker resurfaces when passed"
text email_snapshot
text snoozed_at
}
ea_api_tokens {
int id PK
text token_hash UK "hash-only; raw token shown once on create"
text label
text scopes "CSV or JSON of permitted scopes"
int created_at
int last_used_at
int expires_at
}
ea_email_index {
text uid PK "gmail-acct-id or icloud-id"
text user_id
text account_id
text account_label
text account_email
text account_color
text account_icon
text from_name
text from_address
text subject
text body_snippet "short UI preview"
text body_text "full plain-text body for FTS"
text email_date
int read
datetime indexed_at
}
ea_email_backfill_state {
text user_id PK
text account_id PK
text mailbox_scope PK "inbox"
text status "queued/running/retry/paused/completed"
int target_days
text oldest_target_date
text oldest_indexed_date
text last_scanned_at
text cursor_json "current window/page cursor"
int indexed_count
text last_error
int attempts
text started_at
text completed_at
text updated_at
}
ea_gmail_watch_state {
text user_id
text account_id
text email_address
text last_history_id "Gmail history cursor"
text watch_expiration_at
text watch_status "active/inactive/error"
text last_notification_at
text last_renewed_at
text last_sync_at
text last_error
}
ea_triage_jobs {
text user_id
text account_id
text email_id "nullable for account-level jobs"
text job_type "gmail_history_sync/email_triage"
text status "queued/running/complete/failed"
text idempotency_key
int priority
int attempts
text payload_json
text locked_at
text last_error
text scheduled_for
text completed_at
}
ea_email_fts {
text uid "UNINDEXED join key"
text from_name "FTS5 indexed"
text from_address "FTS5 indexed"
text subject "FTS5 indexed"
text body_snippet "FTS5 indexed"
text body_text "FTS5 indexed (full body)"
}
server/db/migrations/001_ea_tables.sql is the current-schema baseline and is auto-run on server start for a new database. The table below is generated from the migration files — each row maps a logical table to the migrations that create, alter, or rename it.
| Table | Migrations |
|---|---|
ea_accounts |
001_ea_tables.sql, 028_provider_needs_reauth.sql |
ea_actual_metadata_mirror |
009_actual_metadata_mirror.sql |
ea_ai_usage_cutover |
057_email_ai_usage.sql |
ea_ai_usage_events |
057_email_ai_usage.sql |
ea_ai_usage_legacy_triage |
057_email_ai_usage.sql |
ea_alfred_usage |
016_alfred_usage.sql, 019_alfred_usage_cache_creation.sql |
ea_api_tokens |
001_ea_tables.sql |
ea_bill_occurrence_mirror |
001_ea_tables.sql, 002_bills_mirror.sql |
ea_bill_schedule_mirror |
001_ea_tables.sql, 002_bills_mirror.sql |
ea_bills_mirror_state |
001_ea_tables.sql, 002_bills_mirror.sql |
ea_briefing_snapshot_items |
001_ea_tables.sql, 018_carryover_depth_bound.sql |
ea_briefing_snapshots |
001_ea_tables.sql |
ea_calendar_search_mirror_state |
011_calendar_search_mirror.sql, 049_calendar_mirror_snapshot_hash.sql |
ea_calendar_search_occurrences |
011_calendar_search_mirror.sql |
ea_completed_tasks |
001_ea_tables.sql, 014_completed_deadline_occurrences.sql |
ea_csrf_tokens |
001_ea_tables.sql, 034_google_oauth_binding.sql |
ea_current_data_cache |
001_ea_tables.sql |
ea_dismissed_emails |
001_ea_tables.sql |
ea_email_backfill_state |
001_ea_tables.sql |
ea_email_fts |
001_ea_tables.sql |
ea_email_index |
001_ea_tables.sql, 013_email_index_normalized_date.sql, 025_email_thread_identity.sql, 047_email_verification_codes.sql, 054_email_sender_authentication.sql |
ea_email_remote_content_trust |
045_email_remote_content_trust.sql |
ea_email_search_ai_usage |
007_email_search_ai_usage.sql |
ea_email_search_embedding_state |
006_email_search_embedding_state.sql |
ea_email_search_embeddings |
005_email_search_embeddings.sql |
ea_email_triage |
001_ea_tables.sql, 015_triage_last_decision_reason.sql, 052_financial_email_plans.sql |
ea_gmail_pubsub_config |
035_gmail_pubsub_config.sql |
ea_gmail_watch_state |
001_ea_tables.sql |
ea_instance_credentials |
033_instance_credentials.sql, 040_pending_credential_lifecycle.sql |
ea_instance_metadata |
032_canonical_url.sql |
ea_news_items |
026_news.sql |
ea_news_sources |
026_news.sql, 029_news_retry_after.sql |
ea_news_topics |
026_news.sql, 027_news_mute_terms.sql |
ea_onboarding_progress |
037_onboarding_progress.sql |
ea_owner |
030_owner_bootstrap.sql, 031_auth_recovery.sql, 038_auth_security_generation.sql |
ea_owner_recovery_codes |
031_auth_recovery.sql |
ea_passkey_credentials |
012_passkey_auth.sql |
ea_pending_auth |
012_passkey_auth.sql, 038_auth_security_generation.sql |
ea_pinned_emails |
022_pinned_emails.sql, 023_pinned_emails_rebuild.sql |
ea_reminders |
010_discord_reminders.sql, 046_time_to_leave_foundation.sql |
ea_sessions |
001_ea_tables.sql, 031_auth_recovery.sql, 038_auth_security_generation.sql, 039_password_step_up_window.sql |
ea_settings |
001_ea_tables.sql, 003_triage_sound_settings.sql, 008_bill_pay_mappings.sql, 010_discord_reminders.sql, 020_utility_pay_links.sql, 026_news.sql, 028_provider_needs_reauth.sql, 036_todoist_oauth_setup.sql, 043_email_triage_classify_read_arrivals.sql, 044_alfred_model_settings.sql, 046_time_to_leave_foundation.sql |
ea_snoozed_emails |
001_ea_tables.sql |
ea_tldraw_documents |
050_tldraw_workspace.sql |
ea_todoist_items |
001_ea_tables.sql |
ea_todoist_labels |
001_ea_tables.sql |
ea_todoist_oauth_states |
036_todoist_oauth_setup.sql |
ea_todoist_projects |
001_ea_tables.sql |
ea_todoist_sync_state |
001_ea_tables.sql |
ea_todoist_webhook_deliveries |
001_ea_tables.sql |
ea_transaction_import_items_before_income_automation |
041_email_transaction_imports.sql, 042_transaction_import_item_subject.sql, 053_transaction_import_financial_plans.sql, 055_generic_financial_email_imports.sql, 056_generic_financial_email_automation.sql, 058_generic_financial_email_income_automation.sql |
ea_transaction_import_mappings |
041_email_transaction_imports.sql |
ea_transaction_import_runs |
041_email_transaction_imports.sql |
ea_triage_feedback |
001_ea_tables.sql |
ea_triage_jobs |
001_ea_tables.sql |
ea_triage_rules |
001_ea_tables.sql |
ea_webauthn_challenges |
012_passkey_auth.sql, 038_auth_security_generation.sql |
migrations |
024_retire_legacy_ledger_rows.sql |
The active dashboard is served from current snapshot, triage, cache, and provider-domain tables.
All new stored credentials use AES-256-GCM with a single EA_ENCRYPTION_KEY and the explicit format gcm:v2:iv:ciphertext:authTag. GCM additional authenticated data binds each value to its table, logical field, and primary-key identity, so ciphertext moved to another credential record or field fails authentication. Instance-credential active and pending slots intentionally share the same logical credential-key context because promotion atomically moves the encrypted candidate between those slots. Existing unversioned gcm:iv:ciphertext:authTag values remain read-only compatible until an operator completes root-key rotation; every normal write and rotation output emits v2.
npm run security:rotate-encryption-key is the dry-run-first offline rotation tool. It inventories and verifies every encrypted field without writing by default. --apply --confirm-offline re-encrypts the complete inventory inside one write transaction using EA_ENCRYPTION_KEY_NEXT, verifies every replacement before commit, and rolls back on any error or concurrent row change. Runtime decryption remains single-key and fail-closed; there is no old-key fallback that could conceal a partial rotation.
Write-only credential candidates expire 24 hours after staging. Dedicated pending_staged_at and pending_expires_at fields keep candidate lifetime separate from connection history; metadata responses expose only those timestamps and opaque versions, never values. Reads prune expired candidates transactionally, and promotion, provider tests, and OAuth callbacks require the exact unexpired version that initiated the operation. Google and Todoist application credential pairs expire, discard, test, and promote atomically.
Settings provides an explicit recent-password-protected discard action. Discard is version-bound so a stale browser cannot remove a newer candidate, preserves the active stored or environment-backed credential, and leaves historical success/failure timestamps intact. Migration 040 gives already-pending candidates the same bounded lifetime using their last recorded update as the compatibility anchor.
Current-data fetches degrade independently. A Gmail outage can leave the snapshot stale or empty while calendar, weather, deadlines, bills, and provider health still render from live fetches or cache. Email triage and bill extraction failures are isolated to their durable job/request paths and do not block dashboard boot.
- iCloud IMAP: Persistent connections per email address with 10-minute idle TTL. Reused across fetches, auto-reconnect on loss.
- Actual Budget: Persistent provider worker owns the singleton SDK session. Calls are serialized through the worker, worker health is tracked in-process, and EA reads normally use mirrored metadata/bill rows instead of opening Actual.
- Gmail: Token refresh on-demand before each API call (5-minute expiry buffer).
All dropdowns, popovers, and panels use:
createPortal(..., document.body)— escape parent DOM treeposition: fixedwith coords fromgetBoundingClientRect()overscrollBehavior: contain+ wheel boundary preventionisolation: isolate+ opaque background (#16161e)- Click-outside via
pointerdownondocument
Reference implementations: BriefingHistoryPanel.tsx, src/components/shared/pickers/AnchoredFloatingPanel.tsx.
Database-driven cron jobs via node-cron. Schedules stored as JSON array in ea_settings.schedules_json. Each entry: { label, time, tz, enabled, skipped_until? }. Hot-reloaded on settings update (all jobs cleared and recreated). Schedule ticks advance the active email snapshot boundary through snapshot-service; they do not run a batch generator. Skip functionality sets skipped_until to midnight tomorrow in the schedule's timezone.
Reminder work stays under the scheduler's existing single-flight/drain ownership. Reminder mutations arm a one-shot timer for their known durable delivery or route-check timestamp; startup and completed batches query the earliest pending delivery, retry, route check, or event anchor, while a five-minute interval remains only as a missed-signal safety backstop. Each admitted batch refreshes a bounded set of due time_to_leave rows from the exact current calendar mirror occurrence and complete Home tuple before selecting deliveries. Conditional updates bind the reminder check version, Home tuple, and occurrence version so concurrent edits win; fixed reminders retain their existing path. Dynamic delivery uses Discord once and has an event-start cutoff instead of the fixed reminder grace window.
When a recurring Todoist task is completed, the Todoist API advances it to the next occurrence and the prior instance disappears from the live list. That would make the dashboard row flicker out before the user's "completed" strikethrough animation finishes.
server/tasks/tombstones.ts's hydrateRecurringTombstones(userId, todoistTaskIdSet) compensates: it reads ea_completed_tasks entries whose due_date is still within the visibility window and whose todoist_id is no longer in the live set, then emits synthetic task rows rebuilt from snapshot_json (migration 025). The orchestrator merges these with the separated Todoist list so the completed instance keeps rendering until its due date falls off the window. DeadlinesSection treats tombstoned rows specially to avoid shared-id collisions (see recent commits 217286f, eb17d23).
ea_snoozed_emails holds (user_id, email_id, until_ts, email_snapshot). server/snapshots/snooze-waker.ts runs periodically; when until_ts has passed it re-injects the email into the live feed using the stored snapshot (so the email stays visible even if it's already been fetched-and-filed in the underlying mailbox).
The structural route table below is regenerated from server/index.ts and server/routes/**/*.ts. It enumerates every router.METHOD(path, …) declaration with its mount prefix from app.use(…). The per-domain prose tables that follow add operational context (purpose, auth posture) but may drift; the structural list above is canonical for "does this route exist".
| Method | Path | File |
|---|---|---|
| GET | / |
server/routes/auth-canonical-origin.ts |
| PATCH | / |
server/routes/auth-canonical-origin.ts |
| GET | /api-tokens |
server/routes/auth-security.ts |
| POST | /api-tokens |
server/routes/auth-security.ts |
| DELETE | /api-tokens/:id |
server/routes/auth-security.ts |
| DELETE | /api/alfred/conversations/:id |
server/routes/alfred.ts |
| POST | /api/alfred/conversations/:id/proposals/:proposalId/created |
server/routes/alfred.ts |
| POST | /api/alfred/email-context |
server/routes/alfred.ts |
| DELETE | /api/alfred/email-context/:id |
server/routes/alfred.ts |
| POST | /api/alfred/run |
server/routes/alfred.ts |
| GET | /api/alfred/usage |
server/routes/alfred.ts |
| GET | /api/auth/check |
server/routes/auth.ts |
| POST | /api/auth/login |
server/routes/auth.ts |
| POST | /api/auth/logout |
server/routes/auth.ts |
| POST | /api/auth/passkey/authentication/cancel |
server/routes/auth.ts |
| POST | /api/auth/passkey/authentication/options |
server/routes/auth.ts |
| POST | /api/auth/passkey/authentication/verify |
server/routes/auth.ts |
| GET | /api/auth/passkeys |
server/routes/auth.ts |
| DELETE | /api/auth/passkeys/:credentialId |
server/routes/auth.ts |
| POST | /api/auth/passkeys/registration/options |
server/routes/auth.ts |
| POST | /api/auth/passkeys/registration/verify |
server/routes/auth.ts |
| POST | /api/auth/setup/claim |
server/routes/auth.ts |
| GET | /api/auth/setup/status |
server/routes/auth.ts |
| GET | /api/briefing/actual/accounts |
server/routes/briefing/bills.ts |
| POST | /api/briefing/actual/bills/:id/mark-paid |
server/routes/briefing/bills.ts |
| POST | /api/briefing/actual/cache/hydrate |
server/routes/briefing/bills.ts |
| GET | /api/briefing/actual/cache/status |
server/routes/briefing/bills.ts |
| GET | /api/briefing/actual/categories |
server/routes/briefing/bills.ts |
| DELETE | /api/briefing/actual/connection |
server/routes/briefing/bills.ts |
| POST | /api/briefing/actual/connection |
server/routes/briefing/bills.ts |
| GET | /api/briefing/actual/metadata |
server/routes/briefing/bills.ts |
| GET | /api/briefing/actual/payees |
server/routes/briefing/bills.ts |
| POST | /api/briefing/actual/send |
server/routes/briefing/bills.ts |
| POST | /api/briefing/actual/test |
server/routes/briefing/bills.ts |
| POST | /api/briefing/bills/extract |
server/routes/briefing/bills.ts |
| POST | /api/briefing/bills/resolve |
server/routes/briefing/bills.ts |
| POST | /api/briefing/dev-reindex-emails |
server/routes/briefing/dev.ts |
| POST | /api/briefing/dismiss/:emailId |
server/routes/briefing/email.ts |
| POST | /api/briefing/email-index/backfill |
server/routes/briefing/email-index.ts |
| GET | /api/briefing/email-index/health |
server/routes/briefing/email-index.ts |
| GET | /api/briefing/email-search |
server/routes/briefing/email.ts |
| GET | /api/briefing/email/:uid |
server/routes/briefing/email.ts |
| GET | /api/briefing/email/:uid/attachments/:attachmentId |
server/routes/briefing/email.ts |
| POST | /api/briefing/email/:uid/mark-read |
server/routes/briefing/email.ts |
| POST | /api/briefing/email/:uid/mark-unread |
server/routes/briefing/email.ts |
| DELETE | /api/briefing/email/:uid/pin |
server/routes/briefing/email.ts |
| POST | /api/briefing/email/:uid/pin |
server/routes/briefing/email.ts |
| DELETE | /api/briefing/email/:uid/snooze |
server/routes/briefing/email.ts |
| POST | /api/briefing/email/:uid/snooze |
server/routes/briefing/email.ts |
| POST | /api/briefing/email/:uid/trash |
server/routes/briefing/email.ts |
| POST | /api/briefing/email/arrival-grace/settle |
server/routes/briefing/email.ts |
| POST | /api/briefing/email/mark-all-read |
server/routes/briefing/email.ts |
| GET | /api/briefing/email/remote-content-trust |
server/routes/briefing/email.ts |
| POST | /api/briefing/email/remote-content-trust |
server/routes/briefing/email.ts |
| DELETE | /api/briefing/email/remote-content-trust/:id |
server/routes/briefing/email.ts |
| GET | /api/briefing/snapshot/:id |
server/routes/briefing/snapshot.ts |
| GET | /api/briefing/snapshot/active |
server/routes/briefing/snapshot.ts |
| GET | /api/briefing/snapshot/history |
server/routes/briefing/snapshot.ts |
| POST | /api/briefing/snapshot/items/:itemId/dismiss |
server/routes/briefing/snapshot.ts |
| POST | /api/briefing/snapshot/items/:itemId/handled |
server/routes/briefing/snapshot.ts |
| PATCH | /api/briefing/snapshot/items/:itemId/lane |
server/routes/briefing/snapshot.ts |
| POST | /api/briefing/snapshot/items/:itemId/reopen |
server/routes/briefing/snapshot.ts |
| POST | /api/briefing/snapshot/items/:itemId/restore |
server/routes/briefing/snapshot.ts |
| POST | /api/briefing/snapshot/sync |
server/routes/briefing/snapshot.ts |
| GET | /api/briefing/todoist/labels |
server/routes/briefing/tasks.ts |
| GET | /api/briefing/todoist/projects |
server/routes/briefing/tasks.ts |
| GET | /api/briefing/transaction-imports/email-status |
server/routes/briefing/transaction-imports.ts |
| POST | /api/briefing/transaction-imports/items/:itemId/dismiss |
server/routes/briefing/transaction-imports.ts |
| POST | /api/briefing/transaction-imports/items/:itemId/retry |
server/routes/briefing/transaction-imports.ts |
| GET | /api/briefing/transaction-imports/runs |
server/routes/briefing/transaction-imports.ts |
| POST | /api/briefing/transaction-imports/runs |
server/routes/briefing/transaction-imports.ts |
| GET | /api/briefing/transaction-imports/runs/:runId |
server/routes/briefing/transaction-imports.ts |
| POST | /api/briefing/transaction-imports/runs/:runId/commit |
server/routes/briefing/transaction-imports.ts |
| GET | /api/calendar/bills/range |
server/routes/calendar.ts |
| GET | /api/calendar/calendars |
server/routes/calendar.ts |
| GET | /api/calendar/deadlines |
server/routes/calendar.ts |
| POST | /api/calendar/deadlines |
server/routes/calendar.ts |
| DELETE | /api/calendar/deadlines/:deadlineId |
server/routes/calendar.ts |
| PATCH | /api/calendar/deadlines/:deadlineId |
server/routes/calendar.ts |
| POST | /api/calendar/deadlines/:deadlineId/completed-occurrences/:date |
server/routes/calendar.ts |
| GET | /api/calendar/deadlines/range |
server/routes/calendar.ts |
| POST | /api/calendar/events |
server/routes/calendar.ts |
| DELETE | /api/calendar/events/:eventId |
server/routes/calendar.ts |
| GET | /api/calendar/events/:eventId |
server/routes/calendar.ts |
| PATCH | /api/calendar/events/:eventId |
server/routes/calendar.ts |
| POST | /api/calendar/events/batch |
server/routes/calendar.ts |
| GET | /api/calendar/places/:placeId |
server/routes/calendar.ts |
| GET | /api/calendar/places/suggest |
server/routes/calendar.ts |
| GET | /api/calendar/range |
server/routes/calendar.ts |
| GET | /api/calendar/search |
server/routes/calendar.ts |
| GET | /api/capabilities/ |
server/routes/capabilities.ts |
| GET | /api/dashboard/current |
server/routes/dashboard.ts |
| GET | /api/dashboard/current/events |
server/routes/dashboard.ts |
| POST | /api/dashboard/current/refresh |
server/routes/dashboard.ts |
| POST | /api/dashboard/current/sync |
server/routes/dashboard.ts |
| GET | /api/dashboard/health |
server/routes/dashboard.ts |
| GET | /api/ea/accounts/todoist/auth |
server/routes/todoist-oauth.ts |
| GET | /api/ea/accounts/todoist/callback |
server/routes/todoist-oauth.ts |
| DELETE | /api/ea/accounts/todoist/connection |
server/routes/todoist-oauth.ts |
| POST | /api/ea/accounts/todoist/personal-token |
server/routes/todoist-oauth.ts |
| GET | /api/ea/accounts/todoist/status |
server/routes/todoist-oauth.ts |
| POST | /api/gmail/push |
server/routes/gmail-push.ts |
| GET | /api/instance-credentials/ |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/:key/disable |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/:key/import-environment |
server/routes/instance-credentials.ts |
| DELETE | /api/instance-credentials/:key/pending |
server/routes/instance-credentials.ts |
| PUT | /api/instance-credentials/:key/pending |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/:key/test |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/:key/use-host |
server/routes/instance-credentials.ts |
| GET | /api/instance-credentials/gmail-pubsub |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/gmail-pubsub/generate-callback |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/gmail-pubsub/import-environment-token |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/gmail-pubsub/revoke-token |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/gmail-pubsub/test-watches |
server/routes/instance-credentials.ts |
| PUT | /api/instance-credentials/gmail-pubsub/topic |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/gmail-pubsub/use-host-token |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/google-oauth/disable |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/google-oauth/import-environment |
server/routes/instance-credentials.ts |
| DELETE | /api/instance-credentials/google-oauth/pending |
server/routes/instance-credentials.ts |
| PUT | /api/instance-credentials/google-oauth/pending |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/google-oauth/use-host |
server/routes/instance-credentials.ts |
| POST | /api/instance-credentials/todoist-oauth/import-environment |
server/routes/instance-credentials.ts |
| DELETE | /api/instance-credentials/todoist-oauth/pending |
server/routes/instance-credentials.ts |
| PUT | /api/instance-credentials/todoist-oauth/pending |
server/routes/instance-credentials.ts |
| GET | /api/news/ |
server/routes/news.ts |
| GET | /api/news/catalog |
server/routes/news.ts |
| POST | /api/news/refresh |
server/routes/news.ts |
| POST | /api/news/seen |
server/routes/news.ts |
| POST | /api/news/sources |
server/routes/news.ts |
| DELETE | /api/news/sources/:id |
server/routes/news.ts |
| PATCH | /api/news/sources/:id |
server/routes/news.ts |
| POST | /api/news/sources/preview |
server/routes/news.ts |
| POST | /api/news/topics |
server/routes/news.ts |
| DELETE | /api/news/topics/:id |
server/routes/news.ts |
| PATCH | /api/news/topics/:id |
server/routes/news.ts |
| POST | /api/news/topics/import-starter |
server/routes/news.ts |
| POST | /api/news/topics/reorder |
server/routes/news.ts |
| GET | /api/onboarding/ |
server/routes/onboarding.ts |
| PATCH | /api/onboarding/ |
server/routes/onboarding.ts |
| POST | /api/todoist/webhook/ |
server/routes/todoist-webhook.ts |
| GET | /email-ai/usage |
server/routes/settings.ts |
| GET | /email-search/usage |
server/routes/settings.ts |
| POST | /preview |
server/routes/auth-canonical-origin.ts |
| POST | /recovery |
server/routes/auth-security.ts |
| POST | /recovery-codes/regenerate |
server/routes/auth-security.ts |
| GET | /reminders |
server/routes/reminders.ts |
| POST | /reminders |
server/routes/reminders.ts |
| DELETE | /reminders/:id |
server/routes/reminders.ts |
| PATCH | /security/auth-mode |
server/routes/auth-security.ts |
| POST | /security/password |
server/routes/auth-security.ts |
| POST | /security/step-up/password |
server/routes/auth-security.ts |
| POST | /settings/discord-reminder-test |
server/routes/reminders.ts |
| GET | /triage/cache-stats |
server/routes/settings.ts |
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/auth/login |
No | Password login; issues a session in default mode or pending auth in strict mode |
| POST | /api/auth/passkey/authentication/options |
No or pending password auth | Start default-mode passwordless passkey or continue strict login |
| POST | /api/auth/passkey/authentication/verify |
Pending passkey auth | Verify passkey assertion and issue ea_session |
| POST | /api/auth/passkey/authentication/cancel |
Pending password auth | Cancel pending password auth and clear challenges |
| GET | /api/auth/passkeys |
Cookie | List registered passkey metadata |
| POST | /api/auth/passkeys/registration/options |
Recent cookie | Create passkey registration challenge |
| POST | /api/auth/passkeys/registration/verify |
Recent cookie | Verify and store registered passkey |
| DELETE | /api/auth/passkeys/:credentialId |
Recent cookie | Delete one registered passkey and rotate browser sessions |
| POST | /api/auth/security/step-up/password |
Cookie | Refresh recent-auth state after password confirmation |
| PATCH | /api/auth/security/auth-mode |
Recent cookie | Explicitly change password-or-passkey vs. strict mode |
| GET | /api/auth/security/canonical-origin |
Cookie | Read the confirmed origin and derived callback metadata |
| POST | /api/auth/security/canonical-origin/preview |
Cookie | Preview passkey and external callback impact without mutation |
| PATCH | /api/auth/security/canonical-origin |
Recent cookie | Confirm a canonical-domain change after impact review |
| POST | /api/auth/security/password |
Recent cookie | Replace the owner password and rotate sessions |
| POST | /api/auth/recovery-codes/regenerate |
Recent cookie | Replace and reveal offline recovery codes once |
| POST | /api/auth/recovery |
No | Consume one recovery code and establish replacement credentials |
| GET | /api/auth/check |
Cookie | Session validation |
| POST | /api/auth/logout |
Cookie | Destroy session |
The /api/briefing namespace contains operational subroutes for inbox, snapshot, task, bill, and dev-reindex actions.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/dashboard/current |
Normal dashboard boot/runtime envelope from durable current-data cache plus active snapshot |
| POST | /api/dashboard/current/refresh |
Light background refresh of current rows |
| POST | /api/dashboard/current/sync |
Explicit bounded sync of current rows plus active snapshot |
| GET | /api/dashboard/health |
Authenticated system/provider health shape |
| GET | /api/dashboard/current/events |
SSE notifications when current dashboard data changes |
The current dashboard envelope is the production runtime contract. It includes weather, calendar, deadlines, bills, providerHealth/systemStatus, and the active snapshot inbox view. Non-email boot-critical data is stored in the durable current-data cache keyed by user_id and cache key; email rows come from active snapshot/domain tables.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/briefing/email-search?q= |
FTS5 keyword search for the Inbox tab across indexed INBOX mail |
| GET | /api/briefing/email-index/health |
Production-available index/backfill health by account |
| POST | /api/briefing/email-index/backfill |
Queue/resume historical INBOX backfill and wake the worker |
Search contract:
- Email search queries
ea_email_ftsjoined toea_email_index; it is not limited to the latest briefing JSON or live polling payload. - Current historical completeness target is INBOX mail only. Archived, sent, trash, and provider-wide all-mail history are intentionally out of scope.
- The default historical backfill target is 365 days. This is minimum desired coverage, not a retention cutoff.
- Indexed rows are not pruned by default. Older rows can remain searchable.
- Stale rows can remain searchable if a provider message later leaves INBOX or is deleted. Provider reconciliation/deletion cleanup is not part of the current contract.
Operational runbook:
# Check indexed coverage and backfill state by account.
curl -s https://<app-host>/api/briefing/email-index/health \
-H 'Cookie: ea_session=<session>'
# Queue/resume the default 365-day INBOX backfill and wake the worker.
curl -s -X POST https://<app-host>/api/briefing/email-index/backfill \
-H 'Cookie: ea_session=<session>' \
-H 'Content-Type: application/json' \
-d '{}'
# Optional shorter diagnostic target.
curl -s -X POST https://<app-host>/api/briefing/email-index/backfill \
-H 'Cookie: ea_session=<session>' \
-H 'Content-Type: application/json' \
-d '{"targetDays":90}'Health responses intentionally avoid email bodies. Use indexed_count, oldest_indexed_date, newest_indexed_date, last_indexed_at, backfill.status, backfill.current_window, backfill.attempts, and backfill.last_error to diagnose coverage or stuck accounts. paused generally means auth/rate-limit intervention is needed; retry means the worker can resume a transient failure.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/briefing/email/:uid |
Fetch full email body |
| POST | /api/briefing/email/:uid/mark-read |
Mark email as read in source |
| POST | /api/briefing/email/:uid/trash |
Move email to trash |
| POST | /api/briefing/email/mark-all-read |
Batch mark as read |
| POST | /api/briefing/dismiss/:emailId |
Permanently dismiss email |
| POST | /api/briefing/email/:uid/snooze |
Snooze email until until_ts |
| DELETE | /api/briefing/email/:uid/snooze |
Cancel snooze and resurface |
Exact paths drift; the source of truth is server/routes/briefing/*.ts (per-domain sub-routers: email.ts, email-index.ts, snapshot.ts, tasks.ts, bills.ts, and dev.ts, all composed by index.ts). Route handlers stay thin; business logic and DB access live in the per-domain server/<domain>/ service modules and current worker modules.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/briefing/complete-task/:taskId |
Complete Todoist-backed deadline/task |
| Method | Path | Purpose |
|---|---|---|
| POST | /api/briefing/actual/send |
Send bill as transaction |
| GET | /api/briefing/actual/metadata |
Mirrored accounts + categories + payees |
| GET | /api/briefing/actual/accounts |
Account list |
| GET | /api/briefing/actual/payees |
Payee list |
| GET | /api/briefing/actual/categories |
Category tree |
| POST | /api/briefing/actual/test |
Test connection |
Remote cache hydration streams the archive through a 128 MiB download cap, then uses the bounded Node reader in actual-budget-archive.ts to validate its central and local headers, entry count, stored/deflate methods, actual expanded sizes, and CRCs before returning only db.sqlite and metadata.json. It accepts only a path-safe local budget identifier. The in-process SDK loads that validated on-disk budget and does not receive a remote ZIP directly.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/ea/accounts |
List all accounts |
| GET | /api/ea/accounts/gmail/auth |
Generate OAuth consent URL |
| GET | /api/ea/accounts/gmail/callback |
OAuth redirect handler (no auth) |
| POST | /api/ea/accounts/icloud |
Add iCloud account |
| PATCH | /api/ea/accounts/:id |
Update account |
| DELETE | /api/ea/accounts/:id |
Delete account |
| POST | /api/ea/accounts/test/:id |
Test account connection |
| PATCH | /api/ea/accounts/reorder |
Reorder accounts |
| GET | /api/ea/settings |
Fetch all settings |
| PUT | /api/ea/settings |
Update settings |
| Method | Path | Purpose |
|---|---|---|
| POST | /api/gmail/push |
Pub/Sub webhook; requires GMAIL_PUBSUB_PUSH_TOKEN, decodes Gmail emailAddress/historyId, and queues gmail_history_sync |
| POST | /api/ea/schedules/skip |
Skip scheduled snapshot boundary |
| GET | /api/ea/models |
Available email AI providers and models |
| GET | /api/ea/alfred-models |
Available Alfred AI providers and models |
| GET | /api/ea/geocode |
Location string to lat/lng |
| GET | /api/ea/important-senders |
Get important senders |
| PUT | /api/ea/important-senders |
Update important senders |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/calendar |
Read-only calendar slice (today/tomorrow/next-week) exposed outside the briefing envelope |
Token management endpoints live under /api/auth. Bearer tokens authenticate by Authorization: Bearer <token> and bypass the x-requested-with CSRF check, but they are not general dashboard auth. They are accepted only on explicitly opted-in automation endpoints, currently POST /api/briefing/actual/quick-txn. Raw tokens are shown once on creation; only token_hash is persisted, and new tokens receive a default 90-day expiry.
Passkeys and API tokens are separate auth surfaces. A registered passkey can unlock the browser directly in password-or-passkey mode or complete login after the password in strict mode; a scoped API token can only call specifically opted-in automation endpoints and cannot satisfy the dashboard route guard.
Hosting: Render (inferred from OAuth redirect URI and RENDER_* env vars)
Build flow:
npm run build→ Vite producesdist/npm start→ Express servesdist/as static files with client-side route handoff- API routes served on same process/port
Dev flow:
npm run dev→ concurrently runs Vite (HMR) + Express (--watch)- Vite proxies
/api/*to Express on port 3001
Environment variables: See .env.example for full reference. Key secrets: EA_ENCRYPTION_KEY (AES-256), ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID/SECRET, and database tokens. EA_USER_ID plus EA_PASSWORD_HASH remain an optional legacy owner-import pair.
Security defaults: production enables HSTS + CSP + frame/referrer/permissions headers. trust proxy defaults to 1 only in production and can be overridden via TRUST_PROXY.