fix(imap): error instead of silent success when STORE/COPY/MOVE target UIDs aren't present - #15
Conversation
… nothing DeleteMessages, CopyMessages, and MoveMessages reported success even when the target UIDs were not present in the selected mailbox. A STORE against absent UIDs is a valid no-op per RFC 3501 (no error), and many servers (Proton Bridge included) accept a COPY of a non-matching UID set without error. The code discarded both responses, so e.g. removing a label the message didn't have printed "Label removed from 1 message(s)" while changing nothing. - DeleteMessages: drain the FETCH responses the server streams from STORE and error when zero messages were modified. - CopyMessages: inspect the COPYUID data and error when both SourceUIDs and DestUIDs are empty. - MoveMessages: apply both checks to its inline COPY + STORE. No extra IMAP round-trips: the affected count and COPYUID data are already in the response streams. SetFlagsMultiple is intentionally left unchanged — the server returns zero affected items both for absent UIDs and for a flag that is already set, so that case needs a different approach. Closes bscott#11 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Ran this branch through the CI steps locally, since Actions does not appear to have run on the PR. All three checks from One data point on the COPY path, from running pm-cli against Proton Bridge daily in an automated triage pipeline. Bridge does return COPYUID for a copy that matches nothing, so The affected count in We had independently written a partial version of this fix (COPY guard only, no affected count) after hitting the same class in production. This one is more complete, so we are dropping ours and tracking this instead. |
|
Reviewed and verified locally: Merging, with one follow-up I'm pushing immediately afterward: The COPYUID check needs a capability guard. In go-imap v2, The fixture pins Worth knowing: on a non-UIDPLUS server this narrows COPY no-match detection, but Closes #11. |
#15 and #16 merge cleanly in git but collide semantically: #15's client_affected_test.go calls the pre-#16 ListMessages signature, so master did not build. Ported those three call sites to ListOptions. Also guards the no-match detection added in #15. CopyCommand.Wait() returns &cmd.data unconditionally in go-imap v2, so the `copyData != nil` half of the check was dead code and the result rested entirely on COPYUID. COPYUID is only sent by servers advertising UIDPLUS (folded into IMAP4rev2); without it an empty SourceUIDs/DestUIDs pair means the server never reported, not that nothing was copied — which would have failed every successful copy/label/move. Extracted copyMatchedNothing(), which draws that conclusion only when the capability guarantees the data. #15's fixture pins Caps to IMAP4rev2 and so could not cover this; added a plain IMAP4rev1 fixture. On such a server COPY no-match detection is necessarily weaker, but MoveMessages still catches it via the STORE affected-count — pinned by TestMoveMissingUIDWithoutUIDPlusStillErrors. Finally, adds #16's new mail list flags (--flagged, --fields, --compact) to the --help-json schema per CLAUDE.md, along with --offset and --page, which were already missing. That output is the documented agent contract. Co-authored-by: exe.dev user <exedev@discovery-lotus.exe.xyz> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Marks the changelog entry as released rather than Unreleased, so the tagged commit does not describe itself as pending. Records that the release was validated against a live Proton Bridge 3.25.0 rather than only the in-memory test server, and that Bridge advertises UIDPLUS, which resolves the open question from #15 and #17: the COPYUID no-match check runs at full strength on Bridge, and the capability guard costs nothing there while still protecting servers that withhold COPYUID. Co-authored-by: exe.dev user <exedev@discovery-lotus.exe.xyz> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
DeleteMessages,CopyMessages, andMoveMessagesininternal/imap/client.goreported success even when the target UIDs were notpresent in the selected mailbox. Removing a label an email didn't have (a COPY
to the wrong
Labels/*folder, or a STORE on an absent UID) printed e.g.Label removed from 1 message(s)while nothing actually changed.Root cause, as described in the issue:
error; the streamed FETCH responses (one per modified message) were never
counted.
servers (Proton Bridge included); the
CopyData(SourceUIDs/DestUIDs)was discarded.
Fix
DeleteMessages: drainstoreCmd.Next()and error when the affected countis zero.
CopyMessages: error whenCopyData.SourceUIDsandDestUIDsare bothempty.
MoveMessages: apply both checks before expunging the source.No extra IMAP round-trips — the affected count and COPYUID data are already in
the response streams.
SetFlagsMultipleis intentionally left unchanged (perthe issue): a server returns zero affected items both for an absent UID and for
a flag that is already set, so distinguishing them needs a different approach.
Tests
Added
internal/imap/client_affected_test.go, which drives a real in-memoryIMAP server (go-imap's
imapmemserver, no new module dependency) end-to-end,organized into all seven categories:
mailbox.
affected count adds no extra/blocking round-trip.
a false success.
silent success (the memserver rejects an empty COPY at the protocol level;
Proton Bridge returns an empty COPYUID set — both paths are handled).
integration path); explicit skip placeholder retained.
go build ./... && go vet ./... && go test ./...all green.Closes #11
🤖 Generated with Claude Code