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.
79 lines
3.8 KiB
TypeScript
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;
|
|
}
|