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.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user