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,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));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
export const emailAccounts = pgTable(
|
||||
'email_accounts',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
provider: text('provider').notNull(),
|
||||
email: text('email').notNull(),
|
||||
displayName: text('display_name'),
|
||||
imapHost: text('imap_host').notNull(),
|
||||
imapPort: integer('imap_port').notNull(),
|
||||
imapSecure: boolean('imap_secure').notNull().default(true),
|
||||
authType: text('auth_type').notNull(),
|
||||
/**
|
||||
* The account's IMAP/SMTP secret, AES-256-GCM ciphertext under the `email` secret-store purpose.
|
||||
*
|
||||
* `text`, not `jsonb`: this is an opaque blob and typing it as JSON would invite something to read it
|
||||
* as structure. It was plaintext jsonb in the platform — every query and the SMTP transport read the
|
||||
* app password directly — and that is the one thing this extraction deliberately did NOT carry over.
|
||||
*
|
||||
* Encrypted and decrypted in `queries.ts` alone, so nothing above it ever handles ciphertext, and one
|
||||
* leaked key opens this plugin's column rather than every plugin's.
|
||||
*/
|
||||
credentials: text('credentials').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
status: text('status').notNull().default('connected'),
|
||||
/**
|
||||
* LEGACY, read-only. The sync position now lives in the account's own `emails.db`
|
||||
* (`sidecar/email/sync-meta.ts`), so the file is self-describing and an account can be moved to
|
||||
* another machine by copying it.
|
||||
*
|
||||
* Kept because it is the one-time backfill source for every account created before that move; the
|
||||
* first sync after it migrates these keys into SQLite and nothing writes here again. Dropping it
|
||||
* would strand any account that has not synced since.
|
||||
*/
|
||||
syncMeta: jsonb('sync_meta').notNull().default({}),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [uniqueIndex('uq_email_accounts_user_email').on(table.userId, table.email)],
|
||||
);
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type * as EmailSchema from './schema';
|
||||
|
||||
export type EmailAccountSelect = typeof EmailSchema.emailAccounts.$inferSelect;
|
||||
export type EmailAccountInsert = typeof EmailSchema.emailAccounts.$inferInsert;
|
||||
|
||||
/** What an account's secret actually holds — a password, or an OAuth pair, depending on `authType`. */
|
||||
export type EmailCredentials = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* An account as every caller sees it: `credentials` decrypted and parsed.
|
||||
*
|
||||
* The stored row and the used row are different types on purpose. `EmailAccountSelect` has a ciphertext
|
||||
* string in that field, and nothing outside `queries.ts` should be able to hold one by accident.
|
||||
*/
|
||||
export type EmailAccount = Omit<EmailAccountSelect, 'credentials'> & { credentials: EmailCredentials };
|
||||
export type EmailAccountCreate = Omit<EmailAccountInsert, 'credentials'> & { credentials: EmailCredentials };
|
||||
|
||||
export type EmailSummary = {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
snippet: string;
|
||||
attachmentCount?: number;
|
||||
read?: boolean;
|
||||
fromDomain?: string;
|
||||
labels?: string[];
|
||||
// Conversation grouping: total messages in the thread, and how many are unread (folder view only).
|
||||
threadCount?: number;
|
||||
threadUnread?: number;
|
||||
};
|
||||
|
||||
export type EmailMessage = EmailSummary & {
|
||||
cc?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
attachments: Array<{ filename: string; size: number; contentType: string }>;
|
||||
};
|
||||
|
||||
// A full conversation: every non-deleted message in the thread, oldest first.
|
||||
export type EmailThread = {
|
||||
id: string; // the representative message id used to open the thread
|
||||
subject: string;
|
||||
messages: EmailMessage[];
|
||||
};
|
||||
Reference in New Issue
Block a user