πͺ² BUG-#67: Use UID EXPUNGE in MailBox.move fallback and narrow except - #76
Conversation
FernandoCelmer
left a comment
There was a problem hiding this comment.
π 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: |
There was a problem hiding this comment.
[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:
client.uid("MOVE", ...)returns("NO", [b"MOVE not supported"])Status.stateraisesIMAPError("Command failed")except imaplib.IMAP4.errordoes not match β caller seesIMAPError, 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 excAdd 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.
Summary
MailBox.move()no longer issues an unqualifiedEXPUNGEwhen the server lacks the MOVE extension β it usesUID EXPUNGE(RFC 4315) targeting only the moved UIDexcept Exceptiontoimaplib.IMAP4.error, so network/auth/quota errors propagate to the caller instead of being swallowed by the COPY+DELETE fallbackIMAPErrorso the caller can decide whether to expunge globally β never silently nuke other\\Deletedmessagestest_move_falls_back_to_copy_deleteto assert the new behavior (UID EXPUNGE, no global expunge) and added 4 new tests covering MOVE-supported path, error propagation, and the UIDPLUS-missing pathCloses #67
Closes #30
Test plan
pytest tests/clients/imap/test_mailbox.py tests/clients/imap/test_write_ops.pyβ all passing