email: move the mail store and every route into the sidecar
Email was the one sidecar built inside out. The platform held ~1,800 lines — the per-account
SQLite store, all 14 HTTP routes, account CRUD, resync, IMAP validation — while the 314-line
sidecar was a scheduler that reached BACK into the platform to do anything
(`import { performResync } from '../../api/email/resync'`).
The sidecar now serves its own HTTP listener and announces `email:server`, and
/api/email/* on the platform is createSidecarProxy like every other one: 1,801 lines down
to 22, with no mail knowledge left in it — not a message, not a folder, not a credential.
The routes moved verbatim, Hono and all. http.ts only reconstructs what the platform's
middleware used to provide: `user` on the context, from the X-Officer-User header the proxy
injects (trusted because this server binds loopback), and an error handler that turns
custom-errors into status codes.
The /email/events SSE stream went with them, which removes a whole round trip: the IDLE
watcher used to send `email:new` over the registration socket so the platform could push to
its SSE clients. Those clients are here now, so it calls broadcastEmailNew in-process and
`email:new` is gone from the wire protocol.
DELIBERATELY NOT DONE YET, and left backwards on purpose rather than half-moved:
- The two sync handlers (email-sync 381 lines, gmail-sync 712) still run in the platform's
queue and now import the store from its new home — a platform → sidecar import, which is
the wrong direction and is temporary. Moving them is option (A) from the plan: the sidecar
schedules its own syncs, independent of the platform Jobs list.
- accounts.ts still imports queue/init to enqueue a sync and to report sync status, and
index.ts still carries the queue-over-WS shim that inversion needs.
- The three channel handlers still open the mail store directly rather than asking over HTTP.
Two things worth knowing while testing: a from-scratch sync holds a proxied request open
well past the 60s idle default, hence timeoutSeconds on the proxy; and `gmail-sync` is
hardcoded in all three channel handlers even though the only account is provider=gmail with
auth_type=password, which routes to IMAP — so "sync emails" from a chat channel is
almost certainly already broken, and folds into the next stage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,6 @@ import officerWeb from './apps/officer-web/index.gen.html';
|
||||
import { startBrowserRelay } from './servers/api/browser/relay';
|
||||
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
|
||||
import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report
|
||||
import { broadcastEmailNew } from './servers/api/email/email';
|
||||
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
||||
import { toShellUsername } from './servers/data-path';
|
||||
|
||||
@@ -75,12 +74,6 @@ const sidecarWebsocket = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Email IDLE watcher reporting new mail → push to that user's /email SSE clients.
|
||||
if (msg.type === 'email:new' && typeof msg.userEmail === 'string') {
|
||||
broadcastEmailNew(msg.userEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = sidecarConnections.get(ws);
|
||||
if (id) {
|
||||
handleSidecarMessage(id, msg);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// /api/email/* — auth, then forward to officer-email. No routes of its own and no mail knowledge: not a
|
||||
// message, not a folder, not an IMAP or SMTP credential.
|
||||
//
|
||||
// The mail store, the 14 HTTP routes, account CRUD, resync and IMAP validation all used to live in this
|
||||
// directory — ~1,800 lines against a 314-line sidecar, with the sidecar importing BACK into the platform
|
||||
// to reach them. They are the sidecar's now.
|
||||
//
|
||||
// A from-scratch mailbox sync holds a proxied request open well past the 60s idle default, hence the
|
||||
// timeout; it applies to the whole prefix because the proxy must not know which routes are slow.
|
||||
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'email',
|
||||
prefix: '/api/email',
|
||||
timeoutSeconds: 1800,
|
||||
});
|
||||
|
||||
export const emailRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getEmailServerUrl = proxy.getHttpUrl;
|
||||
@@ -6,7 +6,7 @@ import { chunkMessage } from './chunker';
|
||||
import { listChatModels } from '@@/api/chat/list-models';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openUserEmailDb } from '@@/api/email/email-db';
|
||||
import { openUserEmailDb } from '@@/sidecar/email/store';
|
||||
import type { ModelInfo } from '@@/api/chat/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getTelegramBot } from './bot';
|
||||
import { listChatModels } from '@@/api/chat/list-models';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openUserEmailDb } from '@@/api/email/email-db';
|
||||
import { openUserEmailDb } from '@@/sidecar/email/store';
|
||||
import type { ModelInfo } from '@@/api/chat/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getWhatsAppClient } from './bot';
|
||||
import { listChatModels } from '@@/api/chat/list-models';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openUserEmailDb } from '@@/api/email/email-db';
|
||||
import { openUserEmailDb } from '@@/sidecar/email/store';
|
||||
import type { ModelInfo } from '@@/api/chat/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ import './api/vault/sidecar-server'; // side-effect: capture the officer-vault r
|
||||
import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
import { queueRouter } from './api/queue/queue';
|
||||
import { emailRouter } from './api/email/email';
|
||||
import { emailRouter } from './api/email/router';
|
||||
import { channelsRouter } from './channels/routes';
|
||||
import { browserRouter } from './api/browser/router';
|
||||
import { desktopRouter } from './api/desktop/rest';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
import type { JobHandler } from '../types';
|
||||
import { PermanentError } from '../types';
|
||||
import { registerHandler } from '../handler-registry';
|
||||
import { openEmailDb, upsertFromRawEml } from '../../api/email/email-db';
|
||||
import { openEmailDb, upsertFromRawEml } from '../../sidecar/email/store';
|
||||
import { refreshGoogleAccessToken } from '../../api/integrations/google-auth';
|
||||
import {
|
||||
getEmailAccount,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Database } from 'bun:sqlite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { type JobHandler, PermanentError } from '../types';
|
||||
import { registerHandler } from '../handler-registry';
|
||||
import { openEmailDb, upsertFromRawEml, setSyncMeta, getSyncMeta } from '../../api/email/email-db';
|
||||
import { openEmailDb, upsertFromRawEml, setSyncMeta, getSyncMeta } from '../../sidecar/email/store';
|
||||
import {
|
||||
getUserByEmail,
|
||||
getUserIntegration,
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
deleteEmailAccount,
|
||||
updateEmailAccountStatus,
|
||||
} from 'officerdb';
|
||||
import { getValidGoogleAccessToken } from '../integrations/google-auth';
|
||||
import { getValidGoogleAccessToken } from '../../api/integrations/google-auth';
|
||||
import { validateImapConnection } from './imap-validate';
|
||||
import { enqueueJob, listAllJobs } from '../../queue/init';
|
||||
import { openEmailDb, getSyncMeta } from './email-db';
|
||||
import { openEmailDb, getSyncMeta } from './store';
|
||||
import { performResync } from './resync';
|
||||
|
||||
type CreateAccountBody = {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAllSyncedAccounts, getUserById } from 'officerdb';
|
||||
import { performResync } from '../../api/email/resync';
|
||||
import { performResync } from './resync';
|
||||
|
||||
const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ImapFlow } from 'imapflow';
|
||||
import { getAllSyncedAccounts, getUserById } from 'officerdb';
|
||||
import { performResync } from '../../api/email/resync';
|
||||
import { performResync } from './resync';
|
||||
import { getValidGoogleAccessToken } from '../../api/integrations/google-auth';
|
||||
|
||||
// Real-time email via IMAP IDLE: one persistent connection per account. imapflow auto-enters IDLE
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Hono } from 'hono';
|
||||
import { emailRouter } from './routes';
|
||||
|
||||
// The email sidecar's own listener. `/api/email/*` on the platform is a proxy onto this — the platform
|
||||
// authenticates the owner, injects X-Officer-User, and forwards without reading the body.
|
||||
//
|
||||
// The routes moved here verbatim, Hono and all, so this file's only real job is to reconstruct the two
|
||||
// things the platform's middleware used to provide: the authenticated user on the context, and an error
|
||||
// handler that turns thrown custom-errors into status codes.
|
||||
|
||||
type HonoVariables = { user: { id: number }; body: Record<string, unknown>; origin: string };
|
||||
|
||||
const app = new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
// The platform's userMiddleware set `user` from the JWT. Here it comes from the header the proxy injects —
|
||||
// trusted because this server binds loopback only and nothing else can reach it.
|
||||
app.use('*', async (ctx, next) => {
|
||||
const id = Number(ctx.req.header('X-Officer-User'));
|
||||
if (!Number.isFinite(id) || id <= 0) return ctx.json({ error: 'missing X-Officer-User' }, 401);
|
||||
ctx.set('user', { id } as never);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.route('/', emailRouter as never);
|
||||
|
||||
// custom-errors carry a `status`; anything else is a 500 with no detail leaked to the caller.
|
||||
app.onError((err, ctx) => {
|
||||
const status = (err as { status?: number }).status;
|
||||
if (typeof status === 'number' && status >= 400 && status < 600) {
|
||||
return ctx.json({ error: err.message }, status as 400);
|
||||
}
|
||||
console.error('[email] unhandled error', err);
|
||||
return ctx.json({ error: 'internal error' }, 500);
|
||||
});
|
||||
|
||||
export function startEmailServer(): number {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
// A from-scratch mailbox sync answers slowly; the platform proxy extends its own side to match.
|
||||
idleTimeout: 255,
|
||||
fetch: app.fetch,
|
||||
});
|
||||
const port = server.port;
|
||||
if (port == null) throw new Error('[email] failed to acquire a port');
|
||||
console.log(`[email] http server listening on http://127.0.0.1:${port}`);
|
||||
return port;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { SidecarEvent } from '../protocol';
|
||||
import type { Job, EnqueueParams } from '../../queue/types';
|
||||
import { initEmailCron, stopEmailCron } from './email-cron';
|
||||
import { initEmailIdle, stopEmailIdle } from './email-idle';
|
||||
import { broadcastEmailNew } from './routes';
|
||||
import { startEmailServer } from './http';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
@@ -73,6 +75,12 @@ function handleCommand(cmd: Record<string, unknown>, reply: ReplyFn) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP server ──
|
||||
//
|
||||
// Started before the registration socket so the port is known by the time we announce it. `/api/email/*`
|
||||
// on the platform is a proxy onto this.
|
||||
const serverPort = startEmailServer();
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
@@ -83,11 +91,16 @@ const connection = createSidecarConnector({
|
||||
handleCommand(cmd as Record<string, unknown>, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
// The platform forgets the port when the socket drops, and this listener outlives an officer restart,
|
||||
// so re-announce on every reconnect.
|
||||
connection.send({ type: 'email:server', port: serverPort });
|
||||
// Start email cron once connected (so queue commands can reach API server)
|
||||
initEmailCron();
|
||||
// Real-time push via IMAP IDLE; the cron above is the slow backstop. On new mail, tell the API
|
||||
// server so it can push an SSE event to that user's open /email page.
|
||||
initEmailIdle((userEmail) => connection.send({ type: 'email:new', userEmail }));
|
||||
// Straight to this process's own SSE clients — the /email/events stream lives here now, so the
|
||||
// round trip out to officer and back (the `email:new` wire event) is gone.
|
||||
initEmailIdle((userEmail) => broadcastEmailNew(userEmail));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
updateEmailAccountSyncMeta,
|
||||
} from 'officerdb';
|
||||
import { refreshGoogleAccessToken } from '@@/api/integrations/google-auth';
|
||||
import { openEmailDb, getSyncMeta, setSyncMeta, upsertFromRawEml } from './email-db';
|
||||
import { openEmailDb, getSyncMeta, setSyncMeta, upsertFromRawEml } from './store';
|
||||
import {
|
||||
type GmailCredentials,
|
||||
loadGmailCredentials,
|
||||
@@ -6,7 +6,7 @@ import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { getEmailAttachmentCacheDir } from '@@/data-path';
|
||||
import { getEmailAccounts } from 'officerdb';
|
||||
import { openEmailDb, openUserEmailDb, rowToSummary, getSyncMeta, searchEmails } from './email-db';
|
||||
import { openEmailDb, openUserEmailDb, rowToSummary, getSyncMeta, searchEmails } from './store';
|
||||
import { accountsRouter } from './accounts';
|
||||
|
||||
export const emailRouter = createRouter();
|
||||
@@ -50,8 +50,7 @@ export type SidecarEvent =
|
||||
| { type: 'vnc:stopped'; id: string }
|
||||
| { type: 'vnc:status'; id: string; session: VncSessionInfo | null }
|
||||
| { type: 'vnc:error'; id: string; error: string }
|
||||
// Email
|
||||
| { type: 'email:new'; userEmail: string }
|
||||
// Email — new mail no longer crosses this socket; the sidecar owns the SSE stream and pushes directly
|
||||
// OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect
|
||||
| { type: 'opencode:server'; port: number }
|
||||
// OpenCode turn streaming (analog of claude:*): spawned ack, per-message stream, session id report
|
||||
@@ -78,6 +77,8 @@ export type SidecarEvent =
|
||||
| { type: 'wallet:server'; port: number }
|
||||
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect
|
||||
| { type: 'pty:server'; port: number }
|
||||
// Email — the sidecar reports where its mail HTTP server is listening (random port) on connect
|
||||
| { type: 'email:server'; port: number }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user