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 => encryptSecret(PURPOSE, JSON.stringify(credentials)); const open = (row: EmailAccountSelect): EmailAccount => ({ ...row, credentials: JSON.parse(decryptSecret(PURPOSE, row.credentials)) as Record, }); export async function getEmailAccounts(userId: number): Promise { const rows = await db.select().from(emailAccounts).where(eq(emailAccounts.userId, userId)); return rows.map(open); } export async function getEmailAccount(id: number): Promise { const [row] = await db.select().from(emailAccounts).where(eq(emailAccounts.id, id)); return row ? open(row) : undefined; } export async function createEmailAccount(params: EmailAccountCreate): Promise { 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 { 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 { await db.update(emailAccounts).set({ status }).where(eq(emailAccounts.id, id)); } export async function updateEmailAccountSyncMeta(id: number, syncMeta: Record): Promise { 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 { 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): Promise { await db.update(emailAccounts).set({ credentials: seal(credentials) }).where(eq(emailAccounts.id, id)); }