Files
email/sidecar/http.ts
T
Claude Opus 5 980a9d3c38 email, extracted from the officer platform
IMAP and Gmail sync into a per-account SQLite store, with the mail read and
composed in the browser.

moved whole from src/servers/sidecar/email (14 files), src/servers/api/email,
officer_db/src/email, and the Screens/Dashboard/Email tree. what changed on the
way out:

- credentials are encrypted. the column was plaintext jsonb holding an IMAP app
  password that the SMTP transport read straight out of the row; it is now
  AES-256-GCM under this plugin's own 'email' secret-store purpose, sealed and
  opened in queries.ts alone so nothing above it can hold ciphertext by
  accident. no plaintext fallback on read - a value that does not verify is an
  error.
- the router derives its prefix from mountPrefix() instead of the literal
  '/api/email', which was only correct for a first-party publisher.
- the frontend became panels.ts + layout.ts. the two panel appTypes that were
  null are now declared, because the platform screen used to override them by
  panel id and a plugin has no such hook.
- account management became a modal. it was a section in the platform's
  Settings, which a plugin cannot contribute to, and a panel does not work
  either: PluginScreen renders the workspace locked, so a panel outside the
  default layout is unreachable. it follows Compose, which had already solved
  this.
- 17 palette classes replaced with semantic tokens.

not verified: this has never run. it was switched off in the platform on
2026-08-13 and its table was never created, so nothing has exercised sync,
sending or the UI since. the first install is the first test.
2026-08-18 00:29:50 +00:00

79 lines
3.8 KiB
TypeScript

import { Hono } from 'hono';
import { getUserById } from 'officerdb';
import { CustomError } from '@@/custom-errors';
import { bodyParser } from '@@/_middlewares/body-parser';
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 three
// things the platform's middleware used to provide: the authenticated user on the context, the parsed
// body 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 the whole user row on the context from the JWT. The proxy injects only
// the id (X-Officer-User), so the rest is loaded here — and it has to be the WHOLE row, not just the id:
// the mail store is keyed by the owner's email (DATA_PATH/<owner>/email_accounts/<account>/emails.db), so
// a user object without `email` silently resolves to the wrong path and the mailbox reads as empty.
//
// Cached because this runs on every request and the owner does not change; single-user is a hard invariant.
let cachedUser: { id: number; email: string } | null = null;
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);
if (!cachedUser || cachedUser.id !== id) {
const row = await getUserById(id);
if (!row?.email) return ctx.json({ error: `unknown user ${id}` }, 401);
cachedUser = row as { id: number; email: string };
}
ctx.set('user', cachedUser as never);
await next();
});
// Handlers read the body off the context (`ctx.get('body')`), which only the platform's middleware used to
// provide. Without this every write route here destructured undefined — `POST /accounts` threw on
// `body.provider` and surfaced as a bare "failed to add account" toast. `isProxiedPath` is inert in this
// process (the prefix set is populated by `createSidecarProxy`, which runs in the platform), so nothing is
// skipped; multipart on `/send` is parsed once and served from Hono's cache when the route asks again.
app.use('*', bodyParser());
app.route('/', emailRouter as never);
// Mirrors the platform's own handler (`hono.ts` onError) rather than inventing a second error shape. Two
// bugs lived in the version that didn't: custom-errors carry `statusCode`, NOT `status`, so every
// deliberate 4xx fell through to the 500 branch with its message replaced by "internal error"; and the
// body was JSON where the whole API answers errors as plain text, which `useClient` reads with
// `res.text()` — so even a correct message reached the client wrapped in `{"error":…}`.
app.onError((err, ctx) => {
if (err instanceof CustomError) {
if (err.returnValue) {
return typeof err.returnValue === 'string'
? ctx.text(err.returnValue, err.statusCode)
: ctx.json(err.returnValue, err.statusCode);
}
return ctx.text(err.message, err.statusCode);
}
console.error('[email] unhandled error', err);
return ctx.text('Internal Server 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;
}