CONDSTORE and QRESYNC: Sync Only What Changed in IMAP
A naive IMAP client re-scans the whole mailbox on every poll just to notice one flag changed. CONDSTORE and QRESYNC turn resync into O(changes) — a single round trip that returns the changed flags and the vanished UIDs together. Here are the exact command sequences, the state your client must persist, and the UIDVALIDITY traps that silently corrupt caches.
EvilMail TeamJuly 30, 202610 min read
A poll loop that runs UID FETCH 1:* (FLAGS) against a 50,000-message INBOX moves roughly 1–3 MB across the wire every cycle, and it does all that work to discover that exactly one message got marked \Seen — or, more often, that nothing changed at all. Multiply by every folder, every account, every poll interval, and you have a sync engine that spends nearly all of its bandwidth confirming the mailbox is idle.
Worse, that full flag scan still can't tell you what got deleted. To detect expunges without extensions you have to pull the entire UID set and diff it against your cached set — another full-mailbox operation. So the "cheap" poll is really two O(mailbox) passes stacked on top of each other, and it runs on every cycle forever.
CONDSTORE and QRESYNC, both defined in RFC 7162 (2014, obsoleting the older RFC 4551 and RFC 5162), fix exactly these two problems. CONDSTORE gives every message a modification sequence, so you can ask for "only what changed since N." QRESYNC makes reconnect itself incremental: a single SELECT returns the changed flags *and* the vanished UIDs in one round trip. Done right, resyncing a large idle mailbox costs a few hundred bytes instead of megabytes — and lost-update protection comes free with it.
MODSEQ: the mailbox's version counter
The primitive underneath everything is the
CONDSTORE & QRESYNC: Efficient Incremental IMAP Sync — EvilMail Blog
modification sequence
, or MODSEQ: an unsigned 63-bit value that increases monotonically within a single mailbox. Every mailbox exposes a
HIGHESTMODSEQ
— the largest MODSEQ any message in it currently holds — and every message carries its own MODSEQ, bumped whenever its metadata changes: a flag set or cleared, a keyword added, the message expunged or arriving.
Keep the distinction sharp: a UID identifies a message, a MODSEQ timestamps a change to it. UID 8 is always UID 8; its MODSEQ moves from 12345 to 12346 the instant someone marks it \Seen. Because MODSEQ only ever climbs, a single saved integer — the highest MODSEQ you've processed — becomes a resumable cursor over the entire mailbox. "Give me everything after 12345" is one number on the wire.
MODSEQ is scoped per mailbox, not per account, and it is only meaningful inside one UIDVALIDITY epoch (more on that trap later). The value you persist is a cursor; the server's job is to replay everything to the right of it.
Detecting support and turning it on
The server advertises the two extensions as separate tokens on the CAPABILITY line:
CONDSTORE's CHANGEDSINCE/UNCHANGEDSINCE modifiers work as soon as the capability is present. QRESYNC is stronger and must be explicitly enabled per session, before the SELECT you want it to affect:
a1 ENABLE QRESYNC
* ENABLED QRESYNC
a1 OK Enabled
Enabling QRESYNC implicitly enables CONDSTORE too, so one ENABLE covers both. After selecting a mailbox you'll see the current counter in the untagged responses:
a2 SELECT INBOX
* OK [HIGHESTMODSEQ 90060128194045007] Highest
...
a2 OK [READ-WRITE] Select completed
Not every mailbox can do this. One that can't maintain permanent modsequences returns [NOMODSEQ] instead:
* OK [NOMODSEQ] No permanent modsequences
When you see NOMODSEQ, there is no cursor to save — fall back to a full sync for that mailbox and don't persist a MODSEQ for it.
Incremental flag sync with CONDSTORE
The read-side win is the CHANGEDSINCE fetch modifier. Instead of pulling flags for everything, you pull them only for the messages whose metadata moved past your saved cursor:
Two messages changed since MODSEQ 12345, so two come back, each stamped with its new MODSEQ (CONDSTORE forces MODSEQ into every FETCH response once enabled). On an idle mailbox this returns zero FETCH lines and a tagged OK — a handful of bytes. That is the entire point: the cost is proportional to the changes, not to the mailbox.
If you don't even want the FLAGS payload, CONDSTORE also adds a search criterion so you can pull just the UIDs that moved:
The underappreciated half of CONDSTORE is the write side. UNCHANGEDSINCE turns a store into a conditional, optimistic-concurrency operation:
a5 UID STORE 8 (UNCHANGEDSINCE 12345) +FLAGS (\Deleted)
a5 OK [MODIFIED 8] Conditional STORE failed
If UID 8's MODSEQ is still ≤ 12345, the flag is set and its MODSEQ bumps. If someone else touched the message in the meantime, the server refuses the store for that UID and names it in a MODIFIED response code. You now know your view was stale *before* you clobbered a change — genuine lost-update protection, built into the protocol. Any client that writes flags from more than one device should be using this.
QRESYNC: reconnect in a single round trip
CONDSTORE handles the steady-state poll. QRESYNC handles the expensive moment — reconnecting after being offline, when both flags *and* the set of existing messages may have changed. You feed your saved state straight into SELECT:
The four QRESYNC parameters are: last-known UIDVALIDITY (67890), last-known HIGHESTMODSEQ, your known-UID set, and an optional fourth element (sequence-match data) that lets the server detect and repair skew between its UID→sequence map and yours.
In one reply the server hands you everything: VANISHED (EARLIER) lists the UIDs expunged while you were gone (here 43 and 60), and the FETCH lines carry the flags that changed. No separate SEARCH ALL, no FETCH 1:* FLAGS, no diffing UID sets. The naive reconnect is three round trips and a full payload; QRESYNC is one round trip and a delta.
The edge cases that corrupt caches
This is the part shallow summaries skip, and it's where real sync engines rot silently.
UIDVALIDITY is the master key. Before you trust a single cached UID or MODSEQ, compare the mailbox's current UIDVALIDITY against your stored value. If they differ, every UID you cached now points at a different message or at nothing. Discard the entire local cache — UIDs, MODSEQ, everything — and do a full resync. MODSEQ is only meaningful *within one UIDVALIDITY epoch*; never carry a saved MODSEQ across a UIDVALIDITY change. This is also the only situation in which HIGHESTMODSEQ is allowed to move backward.
VANISHED, not EXPUNGE. Without QRESYNC, expunges arrive as * n EXPUNGE, where n is a *sequence number* — and every expunge renumbers all the messages after it, so you must process them in strict order and adjust your indices as you go. QRESYNC replaces this with VANISHED, which is UID-based and sequence-stable: * VANISHED 41,43:52,60 names UIDs directly and never renumbers anything. Handle VANISHED and stop caring about EXPUNGE sequence shuffles.
Commit the cursor last. Persist your new HIGHESTMODSEQ only *after* the resync has fully applied and committed to local storage. Save the cursor first, then crash mid-apply, and you've told your future self "I already processed everything up to N" while the changes never landed — a permanently lost update with no error anywhere.
Take the server's number, not your own max.HIGHESTMODSEQ advances on message arrival and expunge, not only on flag edits, so it can be higher than the largest MODSEQ in the FETCH lines you received. Always save the HIGHESTMODSEQ the server reports at the end of SELECT, never max(message MODSEQs) — otherwise you'll re-request phantom changes or, worse, skip real ones.
Verifying and operating on Dovecot
evilmail's stack runs Dovecot, which supports CONDSTORE and QRESYNC out of the box. Inspect a mailbox's counters directly:
For efficient per-mailbox status and modseq lookups, keep the mailbox list index on:
mailbox_list_index = yes
The modseq is persisted per mailbox in Dovecot's dovecot.index / dovecot.list.index files. One operational warning worth wiring into your client: a doveadm force-resync or an index rebuild can bump UIDVALIDITY. Treat that as a normal, expected event — detect the mismatch, nuke the local cache, full-sync — not as an error to retry against a stale cursor.
Checklist: a correct QRESYNC client
Persist UIDVALIDITY + HIGHESTMODSEQ + the known-UID set per mailbox, not per account.
Send ENABLE QRESYNCbefore the first SELECT you want QRESYNC behavior on.
Compare UIDVALIDITY first on every reselect; on mismatch, discard the cache and full-sync.
Use CHANGEDSINCE <modseq> for flag polls; expect zero FETCH lines when idle.
Use UNCHANGEDSINCE <modseq> for every flag write and honor the MODIFIED response instead of blindly retrying.
Handle VANISHED (EARLIER) (UID-based); stop parsing * n EXPUNGE for QRESYNC sessions.
Save the server's end-of-SELECTHIGHESTMODSEQ, never max(message MODSEQs).
Commit the saved MODSEQ only after the resync fully lands on disk.
Fall back to full sync on NOMODSEQ and don't persist a cursor for that mailbox.
The numbers are the whole argument. A full flag scan of a 50k-message mailbox is megabytes per poll and grows with the mailbox; a QRESYNC reconnect of that same mailbox, when little changed, is one SELECT round trip returning a short VANISHED list and the handful of FETCH lines that actually moved — a few hundred bytes. Multiply that saving across every folder and every account on the server and the difference isn't an optimization, it's whether the sync engine scales at all.