Skip to content

πŸͺ² BUG-#67: Use UID EXPUNGE in MailBox.move fallback and narrow except - #76

Merged
FernandoCelmer merged 3 commits into
developfrom
feature/67
May 17, 2026
Merged

πŸͺ² BUG-#67: Use UID EXPUNGE in MailBox.move fallback and narrow except#76
FernandoCelmer merged 3 commits into
developfrom
feature/67

Conversation

@FernandoCelmer

Copy link
Copy Markdown
Member

Summary

  • MailBox.move() no longer issues an unqualified EXPUNGE when the server lacks the MOVE extension β€” it uses UID EXPUNGE (RFC 4315) targeting only the moved UID
  • Narrowed the bare except Exception to imaplib.IMAP4.error, so network/auth/quota errors propagate to the caller instead of being swallowed by the COPY+DELETE fallback
  • When the server also lacks UIDPLUS, raise IMAPError so the caller can decide whether to expunge globally β€” never silently nuke other \\Deleted messages
  • Updated existing test_move_falls_back_to_copy_delete to assert the new behavior (UID EXPUNGE, no global expunge) and added 4 new tests covering MOVE-supported path, error propagation, and the UIDPLUS-missing path

Closes #67
Closes #30

Test plan

  • pytest tests/clients/imap/test_mailbox.py tests/clients/imap/test_write_ops.py β€” all passing

@FernandoCelmer FernandoCelmer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ” Code Review

Code issues found: 1

# Severity Comment
1 [Blocking] Narrowed except misses server NO/BAD responses

The UID EXPUNGE change is correct and the test coverage is good. One regression to address: the narrowed exception class no longer catches the IMAPError raised by Status.state when the server returns NO/BAD for an unsupported MOVE β€” which is the common case on servers without RFC 6851. See inline.


Status.state(self._client.select(_quote(self.name)))
uid = _uid_of(target)
try:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocking]

Problem β€” narrowing the except from Exception to imaplib.IMAP4.error skips the most common way servers signal "MOVE not supported": a tagged NO / BAD response. Status.state(...) translates those into email_profile.core.errors.IMAPError, which is not a subclass of imaplib.IMAP4.error. So when the server replies NO [CANNOT] MOVE not supported, IMAPError propagates out of move() and the COPY+STORE+UID EXPUNGE fallback never runs β€” a regression vs. the previous bare except Exception.

Failure scenario β€” Dovecot (and many others) advertise capabilities through CAPABILITY, not through raising imaplib protocol errors. Calling mailbox.move(uid, "Archive") on a server without RFC 6851 will:

  1. client.uid("MOVE", ...) returns ("NO", [b"MOVE not supported"])
  2. Status.state raises IMAPError("Command failed")
  3. except imaplib.IMAP4.error does not match β†’ caller sees IMAPError, message not moved.

The existing test test_falls_back_to_copy_uid_expunge masks this because it mocks side_effect=imaplib.IMAP4.error(...) β€” but Status.state would never raise that class in production.

Fix β€” catch both exception classes (or, better, check capabilities up-front and skip the try entirely when MOVE is unsupported):

from email_profile.core.errors import IMAPError

try:
    Status.state(self._client.uid("MOVE", uid, destination))
except (imaplib.IMAP4.error, IMAPError):
    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 (imaplib.IMAP4.error, IMAPError) as exc:
        raise IMAPError(
            "Server lacks MOVE and UIDPLUS; message was copied and "
            "flagged \Deleted but not expunged."
        ) from exc

Add a regression test that simulates ("NO", [b"MOVE not supported"]) returned from uid() (no exception raised) and asserts the COPY/STORE/UID-EXPUNGE path runs.

References β€” RFC 3501 Β§6.4 (untagged NO for unsupported commands), RFC 6851 Β§3 (MOVE capability advertised via CAPABILITY; servers without it return NO/BAD).

Status.state raises core.errors.IMAPError when the server returns a tagged
NO/BAD response β€” the most common way to signal an unsupported command.
The previous narrowed except only caught imaplib.IMAP4.error so MOVE
fallback never ran on servers without RFC 6851.

Catch (imaplib.IMAP4.error, IMAPError) in both try blocks. Added two
regression tests that simulate the NO response shape Dovecot and others
emit in production.
@FernandoCelmer
FernandoCelmer merged commit 75f5755 into develop May 17, 2026
11 checks passed
@FernandoCelmer
FernandoCelmer deleted the feature/67 branch May 17, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant