Files
email/db/schema.ts
T
Claude Opus 5 980a9d3c38 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.
2026-08-18 00:29:50 +00:00

46 lines
2.3 KiB
TypeScript

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)],
);