email: keep an account's sync position inside its own emails.db

The messages were in emails.db and the position — last_sync_at, and per-folder uidvalidity/lastuid —
was a jsonb column on email_accounts in Postgres. Two stores for one fact, with an edge that only shows
up when you try to move a mailbox to another machine.

The expensive part of an email account is the first sync: hours of IMAP for a large mailbox, which is
exactly why "copy emails.db to the new server" is the obvious way to bring one across. With the position
in Postgres that silently does not work — the new server's column is empty, !last_sync_at says first
sync, and the whole mailbox downloads again on top of the one just restored.

The other direction is quieter and worse. Restore an OLDER emails.db while Postgres holds a NEWER
position and the sidecar skips every message between the two, permanently, because nothing looks below
lastuid again. Re-syncing is slow; skipping mail is data loss nobody notices.

Not a new idea — the Gmail path already read SQLite and fell back to Postgres, backfilling so the
fallback was taken once. Only the IMAP path had not followed. This extracts that pattern so both use one
copy, and unifies the isFirstSync fork in accounts.ts, which is how the two drifted apart to begin with.

The file wins over Postgres, always, and only migrates when it holds nothing at all. Topping up a
partial position from Postgres would reintroduce precisely the divergence this removes.

email_accounts.sync_meta is kept and marked legacy rather than dropped: it is the one-time backfill
source for every account created before this, and dropping it would strand any that has not synced
since. Nothing writes to it now.

11 tests on the migration, aimed at both expensive failures — migrating when we should not, and failing
to migrate an account that predates the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:39:13 +00:00
co-authored by Claude Opus 5
parent 41663bc207
commit 0c90216c7a
6 changed files with 317 additions and 49 deletions
@@ -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(),
},