You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Address the mailbox correctness findings and README discrepancy in Cocoanetics/SwiftMail PR #236, a follow-up to #1. The reviewed implementation is on this fork's feature/enable-qresync branch at 5ecb03b59e996fdf249c4c50cf49bceaf258b202. Build on that implementation; these APIs may not yet exist on main.
The result should describe the newly selected mailbox through successful command completion, preserving historical and live deletions separately and excluding flags for deleted messages. This is one coherent update to selection handling and its documented public result.
Implementation
Preserve UIDONLY identifiers
In Sources/SwiftMail/IMAP/IMAP/Handler/ResyncSelectHandler.swift, split .start from .startUID(let uid). An ordinary FETCH starts an empty pending record; UIDFETCH starts a record whose UID is SwiftMail.UID(nio: uid). Both require FLAGS before committing on .finish.
For example, * 42 UIDFETCH (FLAGS (\Seen) MODSEQ (901)) must produce changedFlags[UID(42)] == [.seen] without a separate UID attribute. Preserve current behavior for ordinary FETCH, fragmented input, empty flag arrays, custom flags, latest complete update wins, and incomplete records not overwriting complete ones. See RFC 9586 sections 3.3 and 3.7.
Return and apply live deletions
Currently only .messageData(.vanishedEarlier) is accumulated. Plain .vanished is forwarded, but UntaggedResponseBuffer.hasActiveHandler prevents buffering it during SELECT. It therefore never reaches the caller. Do not rely on forwarding to fix this.
Extend Sources/SwiftMail/IMAP/Models/MailboxResyncSelection.swift with public let vanished: UIDSet for plain VANISHED notifications belonging to the newly selected mailbox during this command. Keep vanishedEarlier for historical deletions. Preserve the existing public initializer call shape by adding a trailing vanished: UIDSet = UIDSet() parameter. Keep both sets compact using the existing UID-set representations; do not enumerate a UID range into individual integers.
In ResyncSelectHandler:
Accumulate plain VANISHED separately from VANISHED (EARLIER), and include it in the successful result.
Historical deletions do not decrement selection.messageCount; live deletions do. Apply EXISTS and live deletions in arrival order so a later EXISTS replaces the current count. Use range cardinalities safely rather than iterating UIDs; malformed input must not cause integer underflow or negative counts.
Remove accumulated flag updates for deleted UIDs. Ensure the final changedFlags does not contain UIDs in either deletion set, including an inconsistent later flag update for an already deleted UID. Iterate actual changed-flag keys and check membership rather than expanding deletion ranges.
Clear both deletion sets, pending FETCH state, and changed flags at CLOSED. Data before CLOSED belongs to the old mailbox.
Keep tagged failure/disconnect behavior: never return partial success.
The distinction matters: RFC 7162 section 3.2.10 gives historical and live deletions different count/sequence semantics.
Worked response for an already enabled QRESYNC session, with matching stored UIDVALIDITY 777 and checkpoint 900 (each line ends in CRLF):
* OK [CLOSED] Previous mailbox closed
* 5 EXISTS
* 0 RECENT
* FLAGS (\Seen \Answered)
* OK [UIDVALIDITY 777] Current mailbox
* OK [UIDNEXT 100] Next UID
* OK [HIGHESTMODSEQ 905] Current checkpoint
* VANISHED (EARLIER) 10:11
* 2 FETCH (UID 42 FLAGS (\Seen) MODSEQ (901))
* VANISHED 42
* 2 FETCH (UID 43 FLAGS (\Answered) MODSEQ (902))
A002 OK [READ-WRITE] Selected
Expected: message count 4, vanishedEarlier.ranges == [10...11], vanished.ranges == [42...42], no flag update for UID 42, and .answered for UID 43. A subsequent * 6 EXISTS before completion would make the final count 6.
Honor CLOSED in ordinary selection
SelectHandler.swift currently sends CLOSED to MailboxSelectionAccumulator.apply, where it falls through. Reset selection metadata at that boundary. Prefer centralizing the metadata reset in MailboxSelectionAccumulator.swift so ordinary SELECT and EXAMINE share it, while the resync handler also clears its own deletion/FETCH state.
Regression example: old-mailbox UNSEEN 7 and UIDNEXT 999, then CLOSED, then new-mailbox data that omits UNSEEN. The returned firstUnseen must be its default, not 7. Test omitted optional metadata directly. Keep tagged READ-ONLY/READ-WRITE handling and HIGHESTMODSEQ/NOMODSEQ behavior. RFC 7162 section 3.2.11 applies the boundary to SELECT/EXAMINE, not just SELECT with QRESYNC parameters.
Documentation
Update the IMAPServer column of the ENABLE and QRESYNC rows in README.md to reflect support. Describe the supported QRESYNC selection workflow without claiming every optional QRESYNC operation is implemented; update its obsolete RFC 5162 reference to RFC 7162.
Update the QRESYNC example in Sources/SwiftMail/SwiftMail.docc/Articles/GettingStartedWithIMAP.md to apply both deletion sets before complete flag replacements, after validating UIDVALIDITY. Explain their different count semantics: the returned selection.messageCount already accounts for live deletions, so callers must not subtract them again. Preserve the reconnection guidance.
Correct the checkpoint example: after validating UIDVALIDITY, require a usable highestModSequence before treating the result as a completed incremental synchronization. When it is nil, discard the stored modification-sequence checkpoint and fall back to ordinary synchronization; merely skipping saveCheckpoint leaves a stale checkpoint in place. RFC 7162 section 6 requires removing the cached HIGHESTMODSEQ on NOMODSEQ. The current public model also uses nil when the response omits a checkpoint, so the example should take the conservative fallback in either case. Document caller responsibilities without adding automatic fallback or checkpoint persistence to the library.
Document the new property and initializer argument. Keep Implemented.md consistent with the final scope.
Focused tests and acceptance criteria
UIDFETCH with no UID attribute returns complete flags, including an empty array; incomplete records do not replace existing complete updates.
A flag update followed by a live deletion is returned as a deletion with no stale flags; live deletion count adjustments and later EXISTS behave in wire order.
For each deletion form, test both flags followed by deletion and deletion followed by a later complete flag update. The final changedFlags must exclude the deleted UID in both orders. Treat the later-update case as defensive handling of inconsistent server input.
Historical deletions remain separate and do not reduce counts. Large ranges remain compact. No UID enumeration proportional to the size of a deletion range is introduced.
CLOSED drops all old-mailbox resync state, including the new live-deletion set. Ordinary SELECT and EXAMINE reset optional metadata at the same boundary.
Replace the expectations in ResyncSelectMailboxCommandTests.plainVanishedIsNotHistoricalDeletion that currently tolerate losing the live deletion. Use a valid fixture (the current fixture has only 5 EXISTS but VANISHED 1:100). Repair the existing interleaved-response fixture as well as its expected count: it currently sends 5 EXISTS, then VANISHED 30:31, then FETCH responses for sequence numbers 4 and 5 without another EXISTS. Make the sequence numbers and UID mappings consistent with the remaining mailbox after each deletion. Keep deliberately incomplete FETCH records as explicit defensive cases.
Add a focused pipeline-level case with UntaggedResponseBuffer active to prove the deletion is present in the public result even though it is not buffered for later delivery. Reuse Tests/SwiftIMAPTests/QResyncPublicAPITests.swift fixtures where practical.
Extend Tests/SwiftIMAPTests/SelectionCheckpointTests.swift for the ordinary selection boundary. Existing result initializer calls remain source-compatible.
README and the usage example accurately describe the result and supported operations. The example applies both deletion sets without double-decrementing the returned count, discards an unusable cached checkpoint, and shows the caller falling back to ordinary synchronization when highestModSequence is nil.
Run the affected suites, then swift build, swift test, and the repository's configured SwiftLint check before handing off. Record actual results.
Do not broaden this into a notification-pipeline redesign, automatic enablement/reconnection policy, checkpoint persistence, QRESYNC EXAMINE API, or new optional QRESYNC parameters. ENABLE input validation is tracked separately. This issue does not authorize posting replies/reactions or resolving threads on the upstream PR.
Problem and upstream context
Address the mailbox correctness findings and README discrepancy in Cocoanetics/SwiftMail PR #236, a follow-up to #1. The reviewed implementation is on this fork's
feature/enable-qresyncbranch at5ecb03b59e996fdf249c4c50cf49bceaf258b202. Build on that implementation; these APIs may not yet exist onmain.The review identifies:
The result should describe the newly selected mailbox through successful command completion, preserving historical and live deletions separately and excluding flags for deleted messages. This is one coherent update to selection handling and its documented public result.
Implementation
Preserve UIDONLY identifiers
In
Sources/SwiftMail/IMAP/IMAP/Handler/ResyncSelectHandler.swift, split.startfrom.startUID(let uid). An ordinary FETCH starts an empty pending record; UIDFETCH starts a record whose UID isSwiftMail.UID(nio: uid). Both require FLAGS before committing on.finish.For example,
* 42 UIDFETCH (FLAGS (\Seen) MODSEQ (901))must producechangedFlags[UID(42)] == [.seen]without a separate UID attribute. Preserve current behavior for ordinary FETCH, fragmented input, empty flag arrays, custom flags, latest complete update wins, and incomplete records not overwriting complete ones. See RFC 9586 sections 3.3 and 3.7.Return and apply live deletions
Currently only
.messageData(.vanishedEarlier)is accumulated. Plain.vanishedis forwarded, butUntaggedResponseBuffer.hasActiveHandlerprevents buffering it during SELECT. It therefore never reaches the caller. Do not rely on forwarding to fix this.Extend
Sources/SwiftMail/IMAP/Models/MailboxResyncSelection.swiftwithpublic let vanished: UIDSetfor plain VANISHED notifications belonging to the newly selected mailbox during this command. KeepvanishedEarlierfor historical deletions. Preserve the existing public initializer call shape by adding a trailingvanished: UIDSet = UIDSet()parameter. Keep both sets compact using the existing UID-set representations; do not enumerate a UID range into individual integers.In
ResyncSelectHandler:selection.messageCount; live deletions do. Apply EXISTS and live deletions in arrival order so a later EXISTS replaces the current count. Use range cardinalities safely rather than iterating UIDs; malformed input must not cause integer underflow or negative counts.changedFlagsdoes not contain UIDs in either deletion set, including an inconsistent later flag update for an already deleted UID. Iterate actual changed-flag keys and check membership rather than expanding deletion ranges.The distinction matters: RFC 7162 section 3.2.10 gives historical and live deletions different count/sequence semantics.
Worked response for an already enabled QRESYNC session, with matching stored UIDVALIDITY 777 and checkpoint 900 (each line ends in CRLF):
Expected: message count 4,
vanishedEarlier.ranges == [10...11],vanished.ranges == [42...42], no flag update for UID 42, and.answeredfor UID 43. A subsequent* 6 EXISTSbefore completion would make the final count 6.Honor CLOSED in ordinary selection
SelectHandler.swiftcurrently sends CLOSED toMailboxSelectionAccumulator.apply, where it falls through. Reset selection metadata at that boundary. Prefer centralizing the metadata reset inMailboxSelectionAccumulator.swiftso ordinary SELECT and EXAMINE share it, while the resync handler also clears its own deletion/FETCH state.Regression example: old-mailbox
UNSEEN 7andUIDNEXT 999, then CLOSED, then new-mailbox data that omits UNSEEN. The returnedfirstUnseenmust be its default, not 7. Test omitted optional metadata directly. Keep tagged READ-ONLY/READ-WRITE handling and HIGHESTMODSEQ/NOMODSEQ behavior. RFC 7162 section 3.2.11 applies the boundary to SELECT/EXAMINE, not just SELECT with QRESYNC parameters.Documentation
README.mdto reflect support. Describe the supported QRESYNC selection workflow without claiming every optional QRESYNC operation is implemented; update its obsolete RFC 5162 reference to RFC 7162.Sources/SwiftMail/SwiftMail.docc/Articles/GettingStartedWithIMAP.mdto apply both deletion sets before complete flag replacements, after validating UIDVALIDITY. Explain their different count semantics: the returnedselection.messageCountalready accounts for live deletions, so callers must not subtract them again. Preserve the reconnection guidance.highestModSequencebefore treating the result as a completed incremental synchronization. When it is nil, discard the stored modification-sequence checkpoint and fall back to ordinary synchronization; merely skippingsaveCheckpointleaves a stale checkpoint in place. RFC 7162 section 6 requires removing the cached HIGHESTMODSEQ on NOMODSEQ. The current public model also uses nil when the response omits a checkpoint, so the example should take the conservative fallback in either case. Document caller responsibilities without adding automatic fallback or checkpoint persistence to the library.Implemented.mdconsistent with the final scope.Focused tests and acceptance criteria
changedFlagsmust exclude the deleted UID in both orders. Treat the later-update case as defensive handling of inconsistent server input.ResyncSelectMailboxCommandTests.plainVanishedIsNotHistoricalDeletionthat currently tolerate losing the live deletion. Use a valid fixture (the current fixture has only 5 EXISTS but VANISHED 1:100). Repair the existing interleaved-response fixture as well as its expected count: it currently sends5 EXISTS, thenVANISHED 30:31, then FETCH responses for sequence numbers 4 and 5 without another EXISTS. Make the sequence numbers and UID mappings consistent with the remaining mailbox after each deletion. Keep deliberately incomplete FETCH records as explicit defensive cases.UntaggedResponseBufferactive to prove the deletion is present in the public result even though it is not buffered for later delivery. ReuseTests/SwiftIMAPTests/QResyncPublicAPITests.swiftfixtures where practical.Tests/SwiftIMAPTests/SelectionCheckpointTests.swiftfor the ordinary selection boundary. Existing result initializer calls remain source-compatible.highestModSequenceis nil.swift build,swift test, and the repository's configured SwiftLint check before handing off. Record actual results.Do not broaden this into a notification-pipeline redesign, automatic enablement/reconnection policy, checkpoint persistence, QRESYNC EXAMINE API, or new optional QRESYNC parameters. ENABLE input validation is tracked separately. This issue does not authorize posting replies/reactions or resolving threads on the upstream PR.