diff --git a/src/databases/officer_db/src/schema/email.ts b/src/databases/officer_db/src/schema/email.ts index 6512f183..1a6d1eff 100644 --- a/src/databases/officer_db/src/schema/email.ts +++ b/src/databases/officer_db/src/schema/email.ts @@ -19,6 +19,15 @@ export const emailAccounts = pgTable( 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(), }, diff --git a/src/servers/sidecar/email/accounts.ts b/src/servers/sidecar/email/accounts.ts index 8d77689c..dab64156 100644 --- a/src/servers/sidecar/email/accounts.ts +++ b/src/servers/sidecar/email/accounts.ts @@ -12,7 +12,8 @@ import { validateImapConnection } from './imap-validate'; import { startSync, isSyncing, getSyncStates } from './sync-runner'; import { imapSyncSteps } from './imap-sync'; import { gmailSyncSteps } from './gmail-api'; -import { openEmailDb, getSyncMeta } from './store'; +import { openEmailDb } from './store'; +import { hasSyncedBefore } from './sync-meta'; import { performResync } from './resync'; type CreateAccountBody = { @@ -49,7 +50,11 @@ accountsRouter.get('/', async (ctx) => { if (hasActiveAccounts) { // In-process now: a running sync is one this process started, not a queued job row. An account marked // syncing with nothing running is stale — the usual cause is a restart mid-sync. - activeJobAccountIds = new Set(getSyncStates().filter((s) => s.status === 'running').map((s) => s.accountId)); + activeJobAccountIds = new Set( + getSyncStates() + .filter((s) => s.status === 'running') + .map((s) => s.accountId), + ); for (const a of accounts) { if ((a.status === 'syncing' || a.status === 'queued') && !activeJobAccountIds.has(a.id)) { @@ -133,17 +138,16 @@ accountsRouter.post('/:id/sync', async (ctx) => { if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing'); // Determine if this is a first sync or resync + // One answer for both providers now. This used to fork: Gmail read SQLite, IMAP read the Postgres + // column, which is how the two drifted apart in the first place. let isFirstSync = true; - if (account.provider === 'gmail' && account.authType === 'oauth') { + { const db = openEmailDb(user.email, account.email); try { - isFirstSync = !getSyncMeta(db, 'last_sync_at'); + isFirstSync = !hasSyncedBefore(db, account.syncMeta as Record); } finally { db.close(); } - } else { - const syncMeta = (account.syncMeta ?? {}) as Record; - isFirstSync = !syncMeta.last_sync_at; } // Resync: call directly, no job queue @@ -240,7 +244,10 @@ async function resolveAuth( try { accessToken = await getValidGoogleAccessToken(userId); } catch (err) { - return { ok: false, error: `Token refresh failed — reconnect Google account: ${err instanceof Error ? err.message : err}` }; + return { + ok: false, + error: `Token refresh failed — reconnect Google account: ${err instanceof Error ? err.message : err}`, + }; } if (!accessToken) return { ok: false, error: 'No access token available — reconnect Google account' }; diff --git a/src/servers/sidecar/email/imap-sync.ts b/src/servers/sidecar/email/imap-sync.ts index e9bf56ad..b7bd8d48 100644 --- a/src/servers/sidecar/email/imap-sync.ts +++ b/src/servers/sidecar/email/imap-sync.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { openEmailDb, upsertFromRawEml } from './store'; +import { readSyncMeta, writeSyncMeta } from './sync-meta'; import { refreshGoogleAccessToken } from '../../api/integrations/google-auth'; import { getEmailAccount, @@ -7,7 +8,6 @@ import { upsertUserIntegration, getServerIntegration, updateEmailAccountStatus, - updateEmailAccountSyncMeta, getDockPaths, setDockPaths, } from 'officerdb'; @@ -60,7 +60,12 @@ const SPECIAL_USE_LABEL_MAP: Record = { const SKIP_SPECIAL_USE = new Set(['\\Trash', '\\Junk', '\\All']); -type FolderInfo = { specialUse?: string; path: string; flags: Set; status?: { uidNext?: number; uidValidity?: number } }; +type FolderInfo = { + specialUse?: string; + path: string; + flags: Set; + status?: { uidNext?: number; uidValidity?: number }; +}; function shouldSkipFolder(folder: FolderInfo): boolean { if (folder.flags.has('\\Noselect') || folder.flags.has('\\NonExistent')) return true; @@ -125,10 +130,14 @@ const emailSyncHandler = { // Resolve auth (refreshes OAuth token if needed) const imapAuth = await resolveImapAuth(meta); - // Load syncMeta from DB (always fresh, not from job meta) + // Always re-read the position rather than trusting job meta, which can be stale after a retry. const freshAccount = await getEmailAccount(account.id); if (!freshAccount) throw new PermanentError(`Email account ${account.id} not found`); - const syncMeta = (freshAccount.syncMeta ?? {}) as Record; + + // Opened here rather than further down because the position now lives inside it. Postgres is + // passed only as the one-time backfill for accounts that predate the move — see sync-meta.ts. + const db = openEmailDb(userEmail, account.email); + const syncMeta: Record = readSyncMeta(db, freshAccount.syncMeta as Record); const isIncremental = !!syncMeta.last_sync_at; await updateEmailAccountStatus(account.id, 'syncing'); @@ -142,8 +151,6 @@ const emailSyncHandler = { let errors = 0; let allDone = false; - const db = openEmailDb(userEmail, account.email); - // Load existing IDs for dedup (once, shared across reconnections) const existingIds = new Set(); const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>; @@ -152,8 +159,14 @@ const emailSyncHandler = { try { while (!allDone && reconnects <= MAX_RECONNECTS) { if (reconnects > 0) { - console.log(`[email-sync] Reconnecting (${reconnects}/${MAX_RECONNECTS}) after ${RECONNECT_DELAY_MS / 1000}s...`); - await ctx.updateProgress({ current: saved + skipped, total: 0, label: `Reconnecting (${reconnects}/${MAX_RECONNECTS})...` }); + console.log( + `[email-sync] Reconnecting (${reconnects}/${MAX_RECONNECTS}) after ${RECONNECT_DELAY_MS / 1000}s...`, + ); + await ctx.updateProgress({ + current: saved + skipped, + total: 0, + label: `Reconnecting (${reconnects}/${MAX_RECONNECTS})...`, + }); await new Promise((r) => setTimeout(r, RECONNECT_DELAY_MS)); // Re-read syncMeta from DB to get latest saved UIDs @@ -184,7 +197,9 @@ const emailSyncHandler = { await client.connect(); console.log(`[email-sync] IMAP connected${reconnects > 0 ? ` (reconnect ${reconnects})` : ''}`); - const folders = await client.list({ statusQuery: { uidNext: true, uidValidity: true } }) as FolderInfo[]; + const folders = (await client.list({ + statusQuery: { uidNext: true, uidValidity: true }, + })) as FolderInfo[]; // Filter to folders that still need syncing const foldersToSync: Array<{ folder: FolderInfo; lastUid: number }> = []; @@ -198,7 +213,10 @@ const emailSyncHandler = { const uidValidity = folder.status?.uidValidity ? String(folder.status.uidValidity) : null; const uidNext = folder.status?.uidNext ?? 0; - let lastUid = storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0; + let lastUid = + storedUidValidity && uidValidity === storedUidValidity && storedLastUid + ? parseInt(storedLastUid, 10) + : 0; if (storedUidValidity && uidValidity && storedUidValidity !== uidValidity) { console.log(`[email-sync] UIDVALIDITY changed for ${folder.path} — will re-scan`); @@ -224,7 +242,10 @@ const emailSyncHandler = { for (let fi = 0; fi < foldersToSync.length; fi++) { const { folder, lastUid } = foldersToSync[fi]!; - if (connState.error) { connectionLost = true; break; } + if (connState.error) { + connectionLost = true; + break; + } console.log(`[email-sync] Syncing folder ${fi + 1}/${foldersToSync.length}: ${folder.path}`); @@ -261,17 +282,33 @@ const emailSyncHandler = { maxUid = Math.max(maxUid, msg.uid); - if (!msg.source) { errors++; continue; } + if (!msg.source) { + errors++; + continue; + } const raw = msg.source.toString('utf-8'); const id = messageIdToStableId(raw); - if (!id) { errors++; continue; } + if (!id) { + errors++; + continue; + } - if (existingIds.has(id)) { skipped++; continue; } + if (existingIds.has(id)) { + skipped++; + continue; + } const labels = [label]; try { - upsertFromRawEml({ db, id, raw, integration: account.provider, emailAccount: account.email, labels }); + upsertFromRawEml({ + db, + id, + raw, + integration: account.provider, + emailAccount: account.email, + labels, + }); existingIds.add(id); saved++; } catch { @@ -285,7 +322,7 @@ const emailSyncHandler = { if (maxUid > effectiveLastUid) { syncMeta[`imap_lastuid:${folder.path}`] = String(maxUid); - await updateEmailAccountSyncMeta(account.id, syncMeta as Record); + writeSyncMeta(db, syncMeta); } } } @@ -293,7 +330,11 @@ const emailSyncHandler = { const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr); if (errMsg.includes('Nothing to fetch')) { // No messages in range — normal - } else if (errMsg.includes('not available') || errMsg.includes('timeout') || errMsg.includes('closed')) { + } else if ( + errMsg.includes('not available') || + errMsg.includes('timeout') || + errMsg.includes('closed') + ) { console.log(`[email-sync] Connection lost during fetch in ${folder.path}: ${errMsg}`); connectionLost = true; } else { @@ -308,8 +349,12 @@ const emailSyncHandler = { } console.log(`[email-sync] ${folder.path} done: ${saved} saved, ${skipped} skipped, ${errors} errors`); - await ctx.updateProgress({ current: saved + skipped, total: 0, label: `${folder.path}: ${saved} saved` }); - await updateEmailAccountSyncMeta(account.id, syncMeta as Record); + await ctx.updateProgress({ + current: saved + skipped, + total: 0, + label: `${folder.path}: ${saved} saved`, + }); + writeSyncMeta(db, syncMeta); } finally { lock.release(); } @@ -341,14 +386,18 @@ const emailSyncHandler = { throw new Error(`IMAP sync incomplete after ${MAX_RECONNECTS} reconnection attempts`); } - // Mark sync complete + // Mark sync complete. Last, and only after every message above is stored: if this never runs, + // the position stays behind and those messages are fetched again. Re-fetching is wasteful; + // advancing past mail that was never stored would lose it silently. syncMeta.last_sync_at = new Date().toISOString(); - await updateEmailAccountSyncMeta(account.id, syncMeta as Record); + writeSyncMeta(db, syncMeta); } finally { db.close(); } - console.log(`[email-sync] Done: saved ${saved}, skipped ${skipped}, errors ${errors}, reconnects ${reconnects}`); + console.log( + `[email-sync] Done: saved ${saved}, skipped ${skipped}, errors ${errors}, reconnects ${reconnects}`, + ); ctx.meta.saved = saved; await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${saved} new emails` }); }, diff --git a/src/servers/sidecar/email/resync.ts b/src/servers/sidecar/email/resync.ts index 8673afff..fcc3563f 100644 --- a/src/servers/sidecar/email/resync.ts +++ b/src/servers/sidecar/email/resync.ts @@ -1,18 +1,9 @@ import { createHash } from 'node:crypto'; -import { - getUserIntegration, - upsertUserIntegration, - getServerIntegration, - getEmailAccount, - updateEmailAccountSyncMeta, -} from 'officerdb'; +import { getUserIntegration, upsertUserIntegration, getServerIntegration, getEmailAccount } from 'officerdb'; import { refreshGoogleAccessToken } from '@@/api/integrations/google-auth'; import { openEmailDb, getSyncMeta, setSyncMeta, upsertFromRawEml } from './store'; -import { - type GmailCredentials, - loadGmailCredentials, - gmailApiSync, -} from './gmail-api'; +import { readSyncMeta, writeSyncMeta } from './sync-meta'; +import { type GmailCredentials, loadGmailCredentials, gmailApiSync } from './gmail-api'; export type ResyncResult = { saved: number; skipped: number; errors: number }; @@ -152,9 +143,11 @@ async function imapResync(account: ImapAccountInfo, userEmail: string): Promise< const freshAccount = await getEmailAccount(account.id); if (!freshAccount) throw new Error(`Email account ${account.id} not found`); - const syncMeta = (freshAccount.syncMeta ?? {}) as Record; const db = openEmailDb(userEmail, account.email); + // The position lives in this file now; the Postgres column is only a one-time backfill source for + // accounts that predate the move. See sync-meta.ts. + const syncMeta: Record = readSyncMeta(db, freshAccount.syncMeta as Record); const existingIds = new Set(); const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>; for (const row of rows) existingIds.add(row.id); @@ -187,7 +180,8 @@ async function imapResync(account: ImapAccountInfo, userEmail: string): Promise< const storedLastUid = syncMeta[lastUidKey] as string | undefined; const uidValidity = folder.status?.uidValidity ? String(folder.status.uidValidity) : null; const uidNext = folder.status?.uidNext ?? 0; - const lastUid = storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0; + const lastUid = + storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0; if (lastUid > 0 && uidNext <= lastUid + 1) continue; @@ -216,15 +210,31 @@ async function imapResync(account: ImapAccountInfo, userEmail: string): Promise< if (msg.uid <= effectiveLastUid) continue; maxUid = Math.max(maxUid, msg.uid); - if (!msg.source) { errors++; continue; } + if (!msg.source) { + errors++; + continue; + } const raw = msg.source.toString('utf-8'); const id = messageIdToStableId(raw); - if (!id) { errors++; continue; } - if (existingIds.has(id)) { skipped++; continue; } + if (!id) { + errors++; + continue; + } + if (existingIds.has(id)) { + skipped++; + continue; + } try { - upsertFromRawEml({ db, id, raw, integration: account.provider, emailAccount: account.email, labels: [label] }); + upsertFromRawEml({ + db, + id, + raw, + integration: account.provider, + emailAccount: account.email, + labels: [label], + }); existingIds.add(id); saved++; } catch { @@ -257,7 +267,14 @@ async function imapResync(account: ImapAccountInfo, userEmail: string): Promise< } syncMeta.last_sync_at = new Date().toISOString(); - await updateEmailAccountSyncMeta(account.id, syncMeta as Record); + // Reopened deliberately: the sync loop above closes the db in its `finally`, and the position must be + // written after the messages it describes are safely stored. + const metaDb = openEmailDb(userEmail, account.email); + try { + writeSyncMeta(metaDb, syncMeta); + } finally { + metaDb.close(); + } console.log(`[resync] IMAP done: saved ${saved}, skipped ${skipped}, errors ${errors}`); return { saved, skipped, errors }; diff --git a/src/servers/sidecar/email/sync-meta.test.ts b/src/servers/sidecar/email/sync-meta.test.ts new file mode 100644 index 00000000..89b1201b --- /dev/null +++ b/src/servers/sidecar/email/sync-meta.test.ts @@ -0,0 +1,103 @@ +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 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', last_sync_at: '2026-06-01T00:00:00Z' }); + + expect(meta['imap_lastuid:INBOX']).toBe('100'); + expect(meta.last_sync_at).toBeUndefined(); + }); + + 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); + }); +}); diff --git a/src/servers/sidecar/email/sync-meta.ts b/src/servers/sidecar/email/sync-meta.ts new file mode 100644 index 00000000..eb74e6bb --- /dev/null +++ b/src/servers/sidecar/email/sync-meta.ts @@ -0,0 +1,83 @@ +import type { Database } from 'bun:sqlite'; +import { getSyncMeta, setSyncMeta } from './store'; + +// Where an account's sync position lives: IN ITS OWN emails.db, not in Postgres. +// +// ── Why this moved ── +// +// The messages are in `emails.db` and the position — `last_sync_at`, and per-folder `uidvalidity` + +// `lastuid` — used to be a jsonb column on `email_accounts` in Postgres. Two stores for one fact, and +// the split had a sharp edge that only appears when you try to move a mailbox to another machine. +// +// The expensive thing about an email account is the initial sync: hours of IMAP for a large mailbox. So +// "copy emails.db to the new server" is the obvious way to bring an account across. With the position in +// Postgres that silently does not work — the new server's `sync_meta` is empty, `!last_sync_at` says +// first sync, and it downloads the whole mailbox again on top of the one you just restored. +// +// The worse direction is quieter. Restore an OLDER emails.db while Postgres still holds a NEWER +// position, and the sidecar skips every message between the two — permanently, because nothing ever +// looks below `lastuid` again. Re-syncing is slow; skipping mail is data loss you do not notice. +// +// Keeping both in one file makes the file self-describing: it says what it holds and where it got to, +// and the two cannot disagree because they travel together. +// +// ── Why this is a generalisation, not a new idea ── +// +// The Gmail path already did exactly this (`gmail-api.ts`): read SQLite, fall back to the Postgres +// column, backfill SQLite so the fallback is only ever taken once. That was the right shape and only +// the IMAP path had not followed. This is that pattern, extracted so both use one copy of it. +// +// ── The Postgres column stays ── +// +// `email_accounts.sync_meta` is not dropped. It is the backfill source for every account that exists +// today, and it costs nothing to leave. It is no longer written to; see `readSyncMeta` for the one-way +// migration, which happens the first time an account syncs after this change. + +/** + * The whole sync position for this account, from SQLite — migrating it out of Postgres once if this is + * the first read since the change. + * + * `pgFallback` is the Postgres jsonb as it stands. Pass it; the migration cannot happen without it, and + * an account whose position never migrates re-syncs its entire mailbox. + */ +export function readSyncMeta(db: Database, pgFallback?: Record | null): Record { + const rows = db.query('SELECT key, value FROM sync_meta').all() as Array<{ key: string; value: string }>; + const meta: Record = {}; + for (const row of rows) meta[row.key] = row.value; + + // Only when SQLite has nothing at all. A partially-written position is still this file's own, and + // topping it up from Postgres could reintroduce exactly the divergence this move exists to prevent. + if (Object.keys(meta).length > 0) return meta; + + if (pgFallback) { + for (const [key, value] of Object.entries(pgFallback)) { + if (typeof value !== 'string') continue; + meta[key] = value; + setSyncMeta(db, key, value); + } + } + return meta; +} + +/** + * Persist the position. SQLite only — Postgres is no longer written. + * + * Called after the messages are already stored, which keeps the safe ordering the previous code had: if + * this never runs, the position stays behind and those messages are fetched again. Re-fetching is + * wasteful; advancing past mail that was never stored would lose it. + */ +export function writeSyncMeta(db: Database, meta: Record): void { + for (const [key, value] of Object.entries(meta)) { + if (value === undefined || value === null) continue; + setSyncMeta(db, key, String(value)); + } +} + +/** Has this account ever completed a sync? The question `isFirstSync` is really asking. */ +export function hasSyncedBefore(db: Database, pgFallback?: Record | null): boolean { + if (getSyncMeta(db, 'last_sync_at')) return true; + // Not yet migrated: an account that synced before this change has its answer in Postgres, and + // treating it as a first sync would re-download the whole mailbox. + const pgValue = pgFallback?.last_sync_at; + return typeof pgValue === 'string' && pgValue.length > 0; +}