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:
Claude Opus 5
2026-08-18 00:29:50 +00:00
commit 980a9d3c38
30 changed files with 5616 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import { eq, and } from 'drizzle-orm';
import { db } from 'officerdb/db';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
import { emailAccounts } from './schema';
import type { EmailAccount, EmailAccountCreate, EmailAccountSelect } from './types';
/*
The encryption boundary is this file, and nothing above it.
`email_accounts.credentials` holds an IMAP app password or an OAuth pair. In the platform it was plaintext
jsonb — the SMTP transport read the password straight out of the row — and the extraction was the moment to
stop that, because the platform now has a secret store with a key per purpose and a plugin is expected to
use it rather than invent its own.
`'email'` is this plugin's purpose. `getKey` creates it on first use, so a core install carries no email
key until this plugin asks for one, and a leaked key opens this column rather than every plugin's.
Encrypt on the way in, decrypt on the way out, in one place: callers keep the object they always had, and
a ciphertext string cannot escape into the rest of the plugin by accident. There is deliberately NO
plaintext fallback on read — a value that does not verify is an error, not something to shrug at.
*/
const PURPOSE = 'email';
const seal = (credentials: Record<string, unknown>): string => encryptSecret(PURPOSE, JSON.stringify(credentials));
const open = (row: EmailAccountSelect): EmailAccount => ({
...row,
credentials: JSON.parse(decryptSecret(PURPOSE, row.credentials)) as Record<string, unknown>,
});
export async function getEmailAccounts(userId: number): Promise<EmailAccount[]> {
const rows = await db.select().from(emailAccounts).where(eq(emailAccounts.userId, userId));
return rows.map(open);
}
export async function getEmailAccount(id: number): Promise<EmailAccount | undefined> {
const [row] = await db.select().from(emailAccounts).where(eq(emailAccounts.id, id));
return row ? open(row) : undefined;
}
export async function createEmailAccount(params: EmailAccountCreate): Promise<EmailAccount> {
const [row] = await db
.insert(emailAccounts)
.values({ ...params, credentials: seal(params.credentials) })
.returning();
return open(row!);
}
export async function deleteEmailAccount(id: number, userId: number): Promise<boolean> {
const [row] = await db
.delete(emailAccounts)
.where(and(eq(emailAccounts.id, id), eq(emailAccounts.userId, userId)))
.returning({ id: emailAccounts.id });
return !!row;
}
export async function updateEmailAccountStatus(id: number, status: string): Promise<void> {
await db.update(emailAccounts).set({ status }).where(eq(emailAccounts.id, id));
}
export async function updateEmailAccountSyncMeta(id: number, syncMeta: Record<string, unknown>): Promise<void> {
await db.update(emailAccounts).set({ syncMeta }).where(eq(emailAccounts.id, id));
}
/** Every account due for a background sync, across all users — the cron and IDLE watchers' entry point. */
export async function getAllSyncedAccounts(): Promise<EmailAccount[]> {
const rows = await db
.select()
.from(emailAccounts)
.where(and(eq(emailAccounts.status, 'synced'), eq(emailAccounts.enabled, true)));
return rows.map(open);
}
/** Replace an account's stored secret — used when an OAuth token is refreshed. */
export async function updateEmailAccountCredentials(id: number, credentials: Record<string, unknown>): Promise<void> {
await db.update(emailAccounts).set({ credentials: seal(credentials) }).where(eq(emailAccounts.id, id));
}