From 88191d17fb9e501769e27d910881db9dbed22826 Mon Sep 17 00:00:00 2001 From: Afonso Coutinho Date: Thu, 2 Jul 2026 10:40:19 +0100 Subject: [PATCH] fix: auto-spam move/delete targets the wrong message (seqnum vs UID) (#1874) --- routes/email_helpers.py | 9 ++++-- tests/test_imap_move_uid.py | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/test_imap_move_uid.py diff --git a/routes/email_helpers.py b/routes/email_helpers.py index 3796a6d85..b05f64681 100644 --- a/routes/email_helpers.py +++ b/routes/email_helpers.py @@ -1273,10 +1273,15 @@ def _imap_move(uid, dest, src="INBOX", account_id: str | None = None, owner: str try: c = _imap_connect(account_id, owner=owner) c.select(_q(src)) - status, _ = c.copy(uid, _q(dest)) + # Callers pass a real IMAP UID (from conn.uid("SEARCH", ...)). copy() + # and store() operate on message SEQUENCE NUMBERS, so addressing them + # with a UID moved/deleted the wrong message (or silently no-oped when + # the UID exceeded the message count). Use the UID commands, matching + # the move/delete path in email_routes.py. + status, _ = c.uid("COPY", uid, _q(dest)) if status != "OK": return False - c.store(uid, "+FLAGS", "\\Deleted") + c.uid("STORE", uid, "+FLAGS", "\\Deleted") c.expunge() return True except Exception as e: diff --git a/tests/test_imap_move_uid.py b/tests/test_imap_move_uid.py new file mode 100644 index 000000000..4d3da1936 --- /dev/null +++ b/tests/test_imap_move_uid.py @@ -0,0 +1,56 @@ +"""_imap_move must address messages by UID, not sequence number. + +The auto-spam poller passes a real IMAP UID (from conn.uid("SEARCH", ...)) +to _imap_move, but the function used conn.copy()/conn.store(), which operate +on message SEQUENCE NUMBERS. So a UID like 90521 was interpreted as sequence +number 90521 — moving/deleting the wrong message or silently no-oping. It +must use the UID commands. +""" +import sys +import types + +import pytest + + +@pytest.fixture +def email_helpers(monkeypatch, tmp_path): + # Keep _init_scheduled_db (run at import) off the real data dir. + monkeypatch.setenv("ODYSSEUS_DATA_DIR", str(tmp_path)) + import routes.email_helpers as eh + return eh + + +class _FakeIMAP: + def __init__(self): + self.calls = [] + + def select(self, mbox): + self.calls.append(("select", mbox)); return ("OK", [b""]) + + def copy(self, *a): + self.calls.append(("copy",) + a); return ("OK", [b""]) + + def store(self, *a): + self.calls.append(("store",) + a); return ("OK", [b""]) + + def uid(self, *a): + self.calls.append(("uid",) + a); return ("OK", [b""]) + + def expunge(self): + self.calls.append(("expunge",)); return ("OK", [b""]) + + def logout(self): + pass + + +def test_move_uses_uid_commands_not_seqnum(email_helpers, monkeypatch): + fake = _FakeIMAP() + monkeypatch.setattr(email_helpers, "_imap_connect", lambda *a, **k: fake) + ok = email_helpers._imap_move(b"90521", "Spam", src="INBOX") + assert ok is True + verbs = [c[0] for c in fake.calls] + uid_ops = [c[1] for c in fake.calls if c[0] == "uid"] + assert "COPY" in uid_ops and "STORE" in uid_ops + # the sequence-number commands must NOT be used to address a UID + assert "copy" not in verbs + assert "store" not in verbs