Skip to content

Unqualified EXPUNGE in MoveMessages can permanently delete unrelated messages #29

Description

@bscott

Found while investigating #28. This is a data-loss path, not a style issue.

The interaction

DeleteMessages with permanent=false marks messages \Deleted and deliberately does not expunge (internal/imap/client.go:548):

// Expunge if permanent
if permanent {
    if err := c.client.Expunge().Close(); err != nil {

MoveMessages then issues an unqualified EXPUNGE (internal/imap/client.go:651):

if err := c.client.Expunge().Close(); err != nil {

Per RFC 3501, EXPUNGE permanently removes every message in the selected mailbox carrying \Deleted — not only the ones this move just marked.

Consequence

pm-cli mail delete 123          # soft: marks \Deleted, no expunge
pm-cli mail move 456 -d Archive # expunges 123 as a side effect, permanently

Message 123 is destroyed by a command that never named it. The same applies to any message left \Deleted by another client sharing the mailbox.

mail archive and mail batch's move/archive operations all route through MoveMessages, so they carry the same behavior.

Fix

Adopting native MOVE (#28) removes the explicit EXPUNGE entirely and resolves this. If the COPY fallback is kept for any reason, it must use UIDExpunge(uidSet) when UIDPLUS is available — which is exactly what imapclient's own fallback does, and what our hand-rolled version omits.

Bridge advertises UIDPLUS, so UIDEXPUNGE is available in practice.

Verification status

Identified by code inspection and RFC semantics; not reproduced against a live server. The Bridge account used for 0.2.6 validation was removed afterward, so I could not confirm how Bridge maps \Deleted — it may translate soft deletes to a Trash move rather than leaving the flag set, which would narrow real-world exposure. That should be checked before deciding severity, but the unqualified EXPUNGE is wrong regardless of how Bridge behaves, and pm-cli should not depend on a server-specific mapping to be safe.

Reproducible against imapmemserver without a live account: mark one message \Deleted, move a different one, assert the first still exists.

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