CollectionSync::enumerate_changes expresses absence through removals + present. IMAP implements it across a four-rung ladder; JMAP returned removals: [] and present: None unconditionally, so nothing was ever
reconciled away and every JMAP account accumulated ghosts indefinitely.
JmapEngine::sync_changes now runs Email/changes (RFC 8621 §4.2 over the
RFC 8620 §5.2 core method) and maps it onto FolderChanges.
What now converges for JMAP
Deletes.destroyed ids become PlacementKey removals, addressed exactly
as the occupancies this engine stores are, so the sync path deletes those
placements and nothing else.
Moves out of a folder.Email/changes is account-wide (§4.2 has no
mailbox filter), so Email/get now also requests mailboxIds. A changed
message whose mailboxes no longer include the folder being synced loses its
occupancy here instead of lingering; one whose mailboxes name a different
folder is not placed here at all. The message itself survives as long as
another placement does — delete_placements decides that, not this.
Paging.hasMoreChanges: true is followed to convergence, accumulating
across pages. Bounded, and a server whose newState does not advance is an
honest error rather than an infinite loop.
Stale state.cannotCalculateChanges is an outcome, not a failure: the
pass re-enumerates the folder and only then reports present: Some(...) —
it is the one pass that genuinely looked at the whole mailbox.
Checkpoints.newState round-trips into next_state and is sent back as
the following pass's sinceState. It is scheme-tagged (v1:jmapstate:{state})
like the IMAP checkpoints, because a JMAP state is an opaque server token with
no self-describing shape. An untagged or unknown token re-enumerates rather
than being handed to a server that never issued it — over-fetching is
recoverable, misreading a token is not.
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. No present-set is fabricated to make types line up.
What still does not
Flag/keyword-only changes elsewhere. A pass fetches every account-wide
changed id and discards the ones outside this folder. That is correct but
chatty on busy accounts; narrowing it would need Email/query + /queryChanges
per mailbox.
Push. Nothing here subscribes to the EventSource / StateChange push
channel; sync is still poll-driven.
Email/get still carries no octets, so content_hash stays None and
identity relies on the account-stable Email object id (tier 1, the same
guarantee RFC 8474 EMAILID gives). Unchanged by this PR.
parse_email_get_response hardcoded folder_id = "inbox", mislabelling every
non-inbox mailbox. This was load-bearing for the fix, not adjacent to it:
removals are keyed by (account, folder, uid_validity, remote_id), and the
daemon syncs folders by their JMAP mailbox id (mb-inbox), so a removal keyed mb-inbox could never match a placement stored as inbox. Every removal would
have been inert.
The production path therefore now labels placements with the mailbox actually
synced. The public parse_email_get_response helper keeps its fixed-folder
signature (it delegates with "inbox"); the folder-aware parse is what sync_changes uses. Two existing assertions that encoded the old mislabelling
were updated. #339 can likely be closed or re-scoped — flagging rather than
claiming it.
Testing
crates/nuncio-mail/tests/wiremock_jmap_test.rs (all mocked, no live network):
destroyed ids become exactly those removals, and nothing the pass placed
hasMoreChanges: true loops and accumulates across two pages
cannotCalculateChanges falls back to full enumeration and only then reports present: Some(...)
an incremental pass reports present: None
newState round-trips into next_state and is accepted verbatim on the next
pass (the second page's mock matches only that exact sinceState, so a token
that failed to round-trip would match nothing)
an untagged legacy checkpoint re-enumerates, with no Email/changes mock
mounted so any attempt would fail the test
a message moved to another mailbox is reconciled away, not placed
Plus unit tests for the checkpoint scheme round-trip, unknown-scheme rejection, cannotCalculateChanges vs. a genuine method error, and mailbox-membership
filtering.
Mutation-verified: breaking the paging loop, dropping removals, letting the
incremental pass claim present, and turning cannotCalculateChanges into an
error each failed exactly the intended test and nothing else.
Gate: cargo fmt --all -- --check, cargo check-all, cargo test-all all
green; RUST_TEST_THREADS=2 cargo test-all run three times with zero FAILED
lines each.
## What this fixes
`CollectionSync::enumerate_changes` expresses absence through `removals` +
`present`. IMAP implements it across a four-rung ladder; JMAP returned
`removals: []` and `present: None` unconditionally, so nothing was ever
reconciled away and **every JMAP account accumulated ghosts indefinitely**.
`JmapEngine::sync_changes` now runs `Email/changes` (RFC 8621 §4.2 over the
RFC 8620 §5.2 core method) and maps it onto `FolderChanges`.
## What now converges for JMAP
- **Deletes.** `destroyed` ids become `PlacementKey` removals, addressed exactly
as the occupancies this engine stores are, so the sync path deletes those
placements and nothing else.
- **Moves out of a folder.** `Email/changes` is account-wide (§4.2 has no
mailbox filter), so `Email/get` now also requests `mailboxIds`. A changed
message whose mailboxes no longer include the folder being synced loses its
occupancy here instead of lingering; one whose mailboxes name a *different*
folder is not placed here at all. The message itself survives as long as
another placement does — `delete_placements` decides that, not this.
- **Paging.** `hasMoreChanges: true` is followed to convergence, accumulating
across pages. Bounded, and a server whose `newState` does not advance is an
honest error rather than an infinite loop.
- **Stale state.** `cannotCalculateChanges` is an outcome, not a failure: the
pass re-enumerates the folder and *only then* reports `present: Some(...)` —
it is the one pass that genuinely looked at the whole mailbox.
- **Checkpoints.** `newState` round-trips into `next_state` and is sent back as
the following pass's `sinceState`. It is scheme-tagged (`v1:jmapstate:{state}`)
like the IMAP checkpoints, because a JMAP state is an opaque server token with
no self-describing shape. An untagged or unknown token re-enumerates rather
than being handed to a server that never issued it — over-fetching is
recoverable, misreading a token is not.
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. No present-set is fabricated to make types line up.
## What still does not
- **Flag/keyword-only changes elsewhere.** A pass fetches every account-wide
changed id and discards the ones outside this folder. That is correct but
chatty on busy accounts; narrowing it would need `Email/query` + `/queryChanges`
per mailbox.
- **Push.** Nothing here subscribes to the EventSource / `StateChange` push
channel; sync is still poll-driven.
- **`Email/get` still carries no octets**, so `content_hash` stays `None` and
identity relies on the account-stable Email object id (tier 1, the same
guarantee RFC 8474 `EMAILID` gives). Unchanged by this PR.
## Note on the adjacent hardcode (#339)
`parse_email_get_response` hardcoded `folder_id = "inbox"`, mislabelling every
non-inbox mailbox. This was **load-bearing for the fix, not adjacent to it**:
removals are keyed by `(account, folder, uid_validity, remote_id)`, and the
daemon syncs folders by their JMAP mailbox id (`mb-inbox`), so a removal keyed
`mb-inbox` could never match a placement stored as `inbox`. Every removal would
have been inert.
The production path therefore now labels placements with the mailbox actually
synced. The public `parse_email_get_response` helper keeps its fixed-folder
signature (it delegates with `"inbox"`); the folder-aware parse is what
`sync_changes` uses. Two existing assertions that encoded the old mislabelling
were updated. #339 can likely be closed or re-scoped — flagging rather than
claiming it.
## Testing
`crates/nuncio-mail/tests/wiremock_jmap_test.rs` (all mocked, no live network):
- `destroyed` ids become exactly those removals, and nothing the pass placed
- `hasMoreChanges: true` loops and accumulates across two pages
- `cannotCalculateChanges` falls back to full enumeration and only then reports
`present: Some(...)`
- an incremental pass reports `present: None`
- `newState` round-trips into `next_state` and is accepted verbatim on the next
pass (the second page's mock matches only that exact `sinceState`, so a token
that failed to round-trip would match nothing)
- an untagged legacy checkpoint re-enumerates, with no `Email/changes` mock
mounted so any attempt would fail the test
- a message moved to another mailbox is reconciled away, not placed
Plus unit tests for the checkpoint scheme round-trip, unknown-scheme rejection,
`cannotCalculateChanges` vs. a genuine method error, and mailbox-membership
filtering.
Mutation-verified: breaking the paging loop, dropping removals, letting the
incremental pass claim `present`, and turning `cannotCalculateChanges` into an
error each failed exactly the intended test and nothing else.
Gate: `cargo fmt --all -- --check`, `cargo check-all`, `cargo test-all` all
green; `RUST_TEST_THREADS=2 cargo test-all` run three times with zero `FAILED`
lines each.
Closes #408
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this fixes
CollectionSync::enumerate_changesexpresses absence throughremovals+present. IMAP implements it across a four-rung ladder; JMAP returnedremovals: []andpresent: Noneunconditionally, so nothing was everreconciled away and every JMAP account accumulated ghosts indefinitely.
JmapEngine::sync_changesnow runsEmail/changes(RFC 8621 §4.2 over theRFC 8620 §5.2 core method) and maps it onto
FolderChanges.What now converges for JMAP
destroyedids becomePlacementKeyremovals, addressed exactlyas the occupancies this engine stores are, so the sync path deletes those
placements and nothing else.
Email/changesis account-wide (§4.2 has nomailbox filter), so
Email/getnow also requestsmailboxIds. A changedmessage whose mailboxes no longer include the folder being synced loses its
occupancy here instead of lingering; one whose mailboxes name a different
folder is not placed here at all. The message itself survives as long as
another placement does —
delete_placementsdecides that, not this.hasMoreChanges: trueis followed to convergence, accumulatingacross pages. Bounded, and a server whose
newStatedoes not advance is anhonest error rather than an infinite loop.
cannotCalculateChangesis an outcome, not a failure: thepass re-enumerates the folder and only then reports
present: Some(...)—it is the one pass that genuinely looked at the whole mailbox.
newStateround-trips intonext_stateand is sent back asthe following pass's
sinceState. It is scheme-tagged (v1:jmapstate:{state})like the IMAP checkpoints, because a JMAP state is an opaque server token with
no self-describing shape. An untagged or unknown token re-enumerates rather
than being handed to a server that never issued it — over-fetching is
recoverable, misreading a token is not.
An ordinary incremental pass still reports
present: None. It never enumeratedthe folder, and claiming otherwise would delete every message that simply did
not change. No present-set is fabricated to make types line up.
What still does not
changed id and discards the ones outside this folder. That is correct but
chatty on busy accounts; narrowing it would need
Email/query+/queryChangesper mailbox.
StateChangepushchannel; sync is still poll-driven.
Email/getstill carries no octets, socontent_hashstaysNoneandidentity relies on the account-stable Email object id (tier 1, the same
guarantee RFC 8474
EMAILIDgives). Unchanged by this PR.Note on the adjacent hardcode (#339)
parse_email_get_responsehardcodedfolder_id = "inbox", mislabelling everynon-inbox mailbox. This was load-bearing for the fix, not adjacent to it:
removals are keyed by
(account, folder, uid_validity, remote_id), and thedaemon syncs folders by their JMAP mailbox id (
mb-inbox), so a removal keyedmb-inboxcould never match a placement stored asinbox. Every removal wouldhave been inert.
The production path therefore now labels placements with the mailbox actually
synced. The public
parse_email_get_responsehelper keeps its fixed-foldersignature (it delegates with
"inbox"); the folder-aware parse is whatsync_changesuses. Two existing assertions that encoded the old mislabellingwere updated. #339 can likely be closed or re-scoped — flagging rather than
claiming it.
Testing
crates/nuncio-mail/tests/wiremock_jmap_test.rs(all mocked, no live network):destroyedids become exactly those removals, and nothing the pass placedhasMoreChanges: trueloops and accumulates across two pagescannotCalculateChangesfalls back to full enumeration and only then reportspresent: Some(...)present: NonenewStateround-trips intonext_stateand is accepted verbatim on the nextpass (the second page's mock matches only that exact
sinceState, so a tokenthat failed to round-trip would match nothing)
Email/changesmockmounted so any attempt would fail the test
Plus unit tests for the checkpoint scheme round-trip, unknown-scheme rejection,
cannotCalculateChangesvs. a genuine method error, and mailbox-membershipfiltering.
Mutation-verified: breaking the paging loop, dropping removals, letting the
incremental pass claim
present, and turningcannotCalculateChangesinto anerror each failed exactly the intended test and nothing else.
Gate:
cargo fmt --all -- --check,cargo check-all,cargo test-allallgreen;
RUST_TEST_THREADS=2 cargo test-allrun three times with zeroFAILEDlines each.
Closes #408