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.
78 lines
3.5 KiB
TypeScript
78 lines
3.5 KiB
TypeScript
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));
|
|
}
|