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.
125 lines
5.7 KiB
TypeScript
125 lines
5.7 KiB
TypeScript
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);
|
|
});
|
|
});
|