MoveMessages (internal/imap/client.go:610) implements a move as COPY, then STORE +FLAGS \Deleted, then EXPUNGE. Proton Bridge advertises MOVE (RFC 6851), confirmed against a live Bridge 3.25.0 while validating 0.2.6:
* OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT]
Proton Mail Bridge 03.25.00
Why switch
Atomicity. The current sequence has a partial-failure window: if the process dies or the connection drops after COPY but before EXPUNGE, the message exists in both mailboxes. RFC 6851 requires MOVE to be atomic, so that window closes.
Fewer round trips. Three commands become one.
The library already handles the fallback. imapclient.Client.Move() falls back to COPY + STORE(.SILENT) + EXPUNGE automatically when the server lacks MOVE, so there is no need to keep our own fallback path:
func (c *Client) Move(numSet imap.NumSet, mailbox string) *MoveCommand {
cmdName := "MOVE"
if !c.Caps().Has(imap.CapMove) { cmdName = "COPY" }
...
It uses UIDEXPUNGE where we do not. In its fallback the library issues UIDExpunge(uidSet) when UIDPLUS is available, rather than a blanket EXPUNGE. That difference is the subject of a separate and more serious bug — see the companion issue.
Scope
Testing
The in-memory imapmemserver fixtures in client_affected_test.go and client_copyuid_test.go already parameterize capabilities, so both the native-MOVE path (CapMove) and the fallback path (no CapMove) can be covered without a live Bridge.
Targeting the next minor release.
MoveMessages(internal/imap/client.go:610) implements a move as COPY, then STORE+FLAGS \Deleted, then EXPUNGE. Proton Bridge advertisesMOVE(RFC 6851), confirmed against a live Bridge 3.25.0 while validating 0.2.6:Why switch
Atomicity. The current sequence has a partial-failure window: if the process dies or the connection drops after COPY but before EXPUNGE, the message exists in both mailboxes. RFC 6851 requires MOVE to be atomic, so that window closes.
Fewer round trips. Three commands become one.
The library already handles the fallback.
imapclient.Client.Move()falls back to COPY + STORE(.SILENT) + EXPUNGE automatically when the server lacksMOVE, so there is no need to keep our own fallback path:It uses UIDEXPUNGE where we do not. In its fallback the library issues
UIDExpunge(uidSet)when UIDPLUS is available, rather than a blanketEXPUNGE. That difference is the subject of a separate and more serious bug — see the companion issue.Scope
MoveMessageswithc.client.Move(numSet, destMailbox).MoveDatacarriesSourceUIDs/DestUIDswith the same "requires UIDPLUS or IMAP4rev2" caveat, socopyMatchedNothingshould generalize rather than be duplicated — noteMoveDatausesimap.NumSet, whereasCopyDatausesimap.UIDSet, so the helper needs a small signature change.CopyMessagesstays as-is; COPY is already the right command there.mail archiveandmail batch'smove/archiveops both route throughMoveMessages, so they inherit the fix.Testing
The in-memory
imapmemserverfixtures inclient_affected_test.goandclient_copyuid_test.goalready parameterize capabilities, so both the native-MOVE path (CapMove) and the fallback path (noCapMove) can be covered without a live Bridge.Targeting the next minor release.