Skip to content

Use native IMAP MOVE instead of hand-rolled COPY + STORE + EXPUNGE #28

Description

@bscott

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions