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; 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//email_accounts//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; }