Skip to content

MailBox.move fallback expunges every deleted message, not just the moved UID #67

Description

@FernandoCelmer

Description

When the server does not advertise MOVE (RFC 6851), MailBox.move falls back to COPY + +FLAGS \Deleted + EXPUNGE. The unqualified EXPUNGE removes every message in the mailbox currently flagged \Deleted, including messages the caller marked for deletion earlier and was not yet ready to commit. This is irreversible data loss.

Location

email_profile/clients/imap/mailbox.py line ~190

Current Behavior

def move(self, target: UIDLike, destination: str) -> None:
    Status.state(self._client.select(_quote(self.name)))
    uid = _uid_of(target)
    try:
        Status.state(self._client.uid("MOVE", uid, destination))
    except Exception:
        Status.state(self._client.uid("COPY", uid, destination))
        Status.state(self._client.uid("STORE", uid, "+FLAGS", "\\Deleted"))
        Status.state(self._client.expunge())

self._client.expunge() is an unqualified EXPUNGE — it removes ALL \Deleted messages in the mailbox, not just uid.

Expected Behavior

Only the moved UID should be permanently removed. Other messages already flagged \Deleted should remain until the user calls expunge() explicitly.

Suggested Fix

Use UID EXPUNGE (RFC 4315 / UIDPLUS) which targets a specific UID set:

except Exception:
    Status.state(self._client.uid("COPY", uid, destination))
    Status.state(self._client.uid("STORE", uid, "+FLAGS", "\\Deleted"))
    try:
        Status.state(self._client.uid("EXPUNGE", uid))
    except Exception:
        # server lacks UIDPLUS — leave the \Deleted flag in place and
        # raise so the caller can decide whether to expunge globally.
        raise IMAPError(
            "Server lacks MOVE and UIDPLUS; message was copied and "
            "flagged \\Deleted but not expunged."
        )

Document the behavior change in the README.

Impact

Any code path that uses move() on a server without MOVE extension may silently delete unrelated messages that the user previously flagged for deletion. Once expunged, recovery is impossible.

Priority

High

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghighHigh priority — incorrect behavior, performance, API breakage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions