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,124 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { readSyncMeta, writeSyncMeta, hasSyncedBefore } from './sync-meta';
|
||||
|
||||
// Moving an account's sync position out of Postgres and into its own emails.db. Both failure modes are
|
||||
// expensive and neither is loud, so they are pinned here:
|
||||
//
|
||||
// migrate when we should not -> a restored older mailbox inherits a newer position and SKIPS mail
|
||||
// fail to migrate -> an existing account looks brand new and re-downloads everything
|
||||
|
||||
const freshDb = () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT)');
|
||||
return db;
|
||||
};
|
||||
|
||||
describe('readSyncMeta', () => {
|
||||
it('reads what the file already knows', () => {
|
||||
const db = freshDb();
|
||||
writeSyncMeta(db, { last_sync_at: '2026-01-01T00:00:00Z', 'imap_lastuid:INBOX': '4200' });
|
||||
|
||||
expect(readSyncMeta(db)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_lastuid:INBOX': '4200' });
|
||||
});
|
||||
|
||||
it('migrates keys the file is MISSING, even when it already has others', () => {
|
||||
// The case that broke a live 6.9 GB mailbox. The older Gmail backfill wrote last_sync_at and the
|
||||
// uidvalidity set into SQLite and never the imap_lastuid ones, so the file was non-empty and
|
||||
// half-migrated at once. All-or-nothing skipped it, leaving no lastuid for any folder — and a
|
||||
// missing lastuid refetches that folder from UID 1.
|
||||
const db = freshDb();
|
||||
writeSyncMeta(db, { last_sync_at: '2026-07-23T00:00:00Z', 'imap_uidvalidity:INBOX': '2' });
|
||||
|
||||
const meta = readSyncMeta(db, {
|
||||
last_sync_at: '2026-08-10T00:00:00Z', // newer, but the file's own answer stands
|
||||
'imap_uidvalidity:INBOX': '2',
|
||||
'imap_lastuid:INBOX': '4200', // absent from the file — this is what must arrive
|
||||
});
|
||||
|
||||
expect(meta['imap_lastuid:INBOX']).toBe('4200');
|
||||
expect(meta.last_sync_at).toBe('2026-07-23T00:00:00Z');
|
||||
// Persisted, so the fallback is not needed next time.
|
||||
expect(readSyncMeta(db)['imap_lastuid:INBOX']).toBe('4200');
|
||||
});
|
||||
|
||||
it('migrates from Postgres exactly once, when the file has nothing', () => {
|
||||
// The account existed before the move. Without this it looks like a first sync and re-downloads
|
||||
// the entire mailbox.
|
||||
const db = freshDb();
|
||||
const pg = { last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' };
|
||||
|
||||
expect(readSyncMeta(db, pg)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' });
|
||||
// Backfilled, so the fallback is never consulted again.
|
||||
expect(readSyncMeta(db)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' });
|
||||
});
|
||||
|
||||
it('NEVER lets Postgres override a position the file already has', () => {
|
||||
// The dangerous direction. Restore an older emails.db onto a server whose Postgres row is newer,
|
||||
// and topping up from Postgres would advance past mail this file does not contain — skipped
|
||||
// permanently, because nothing looks below lastuid again. The file wins, always.
|
||||
const db = freshDb();
|
||||
writeSyncMeta(db, { 'imap_lastuid:INBOX': '100' });
|
||||
|
||||
const meta = readSyncMeta(db, { 'imap_lastuid:INBOX': '9999' });
|
||||
|
||||
// The key the file has is never overwritten — that is what stops a restored older mailbox being
|
||||
// advanced past mail it does not contain.
|
||||
expect(meta['imap_lastuid:INBOX']).toBe('100');
|
||||
});
|
||||
|
||||
it('ignores non-string values rather than stringifying them', () => {
|
||||
// The Postgres column is free-form jsonb. An object coerced to "[object Object]" would be a
|
||||
// position that parses as a number nowhere and compares as garbage.
|
||||
const db = freshDb();
|
||||
expect(readSyncMeta(db, { good: 'yes', bad: { nested: true }, alsoBad: 42 } as never)).toEqual({ good: 'yes' });
|
||||
});
|
||||
|
||||
it('is empty for a genuinely new account', () => {
|
||||
expect(readSyncMeta(freshDb(), {})).toEqual({});
|
||||
expect(readSyncMeta(freshDb(), null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeSyncMeta', () => {
|
||||
it('upserts rather than duplicating, so a checkpoint mid-sync is safe to repeat', () => {
|
||||
const db = freshDb();
|
||||
writeSyncMeta(db, { 'imap_lastuid:INBOX': '10' });
|
||||
writeSyncMeta(db, { 'imap_lastuid:INBOX': '20' });
|
||||
|
||||
expect(readSyncMeta(db)).toEqual({ 'imap_lastuid:INBOX': '20' });
|
||||
});
|
||||
|
||||
it('skips null and undefined instead of writing them as text', () => {
|
||||
// "null" as a stored lastuid would parse to NaN and compare false against every real UID.
|
||||
const db = freshDb();
|
||||
writeSyncMeta(db, { a: '1', b: null, c: undefined });
|
||||
expect(readSyncMeta(db)).toEqual({ a: '1' });
|
||||
});
|
||||
|
||||
it('stores numbers as their text form, since UIDs arrive both ways', () => {
|
||||
const db = freshDb();
|
||||
writeSyncMeta(db, { 'imap_lastuid:INBOX': 4200 });
|
||||
expect(readSyncMeta(db)['imap_lastuid:INBOX']).toBe('4200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSyncedBefore', () => {
|
||||
it('is false for a new account and true once the file records a sync', () => {
|
||||
const db = freshDb();
|
||||
expect(hasSyncedBefore(db)).toBe(false);
|
||||
writeSyncMeta(db, { last_sync_at: '2026-01-01T00:00:00Z' });
|
||||
expect(hasSyncedBefore(db)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for an account that synced before the move but has not migrated yet', () => {
|
||||
// The whole point: an unmigrated account must not be treated as new. This is the check that stands
|
||||
// between an existing user and an unnecessary full re-sync of their mailbox.
|
||||
expect(hasSyncedBefore(freshDb(), { last_sync_at: '2026-01-01T00:00:00Z' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when Postgres holds a position but no completed sync', () => {
|
||||
// Folder UIDs without last_sync_at means a sync started and never finished.
|
||||
expect(hasSyncedBefore(freshDb(), { 'imap_uidvalidity:INBOX': '99' })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user