feat(jmap): enumerate changes so deleted mail stops haunting the store #414

Merged
KofTwentyTwo merged 1 commits from story/jmap-email-changes into dev 2026-08-14 22:56:48 +00:00
1 Commits
Author SHA1 Message Date
james.maes b4292b00b0 feat(jmap): enumerate changes so deleted mail stops haunting the store
JMAP's folder sync could only ever add. It returned `removals: []` and
`present: None` unconditionally, so mail deleted or moved by any other
client -- another daemon, a phone, webmail -- stayed in the local store
forever and every JMAP account accumulated ghosts indefinitely.

`sync_changes` now resumes through `Email/changes` (RFC 8621 s4.2 over
the RFC 8620 s5.2 core method):

- `created`/`updated` become upserts, with bodies fetched by `Email/get`
- `destroyed` becomes placement removals
- `hasMoreChanges` is followed to convergence, bounded so a server that
  never advances its `newState` cannot pin the sync task
- `cannotCalculateChanges` falls back to a full enumeration, the one
  pass entitled to report `present: Some(...)` because it is the only
  one that genuinely looked at the whole mailbox

An ordinary incremental pass still reports `present: None`: it never
enumerated the folder, and claiming otherwise would delete every message
that simply did not change.

`Email/changes` is account-wide, so a folder pass sees ids belonging to
other mailboxes. `Email/get` now requests `mailboxIds`, which both keeps
those ids from being placed here and reconciles away a message another
client moved out of this folder.

Checkpoints are scheme-tagged (`v1:jmapstate:{state}`) like the IMAP
ones, since a JMAP state is an opaque token with no self-describing
shape. An untagged or unknown token re-enumerates rather than being
handed back to a server that never issued it.

Placements now carry the mailbox that was actually synced. Removals are
addressed by that same key, so a placement stored under any other folder
id could never be reconciled away again.

Refs #408
2026-08-14 17:49:46 -05:00