Server-side deletions now propagate to the DB. Previously poll() detected new messages and CONDSTORE flag deltas but never expunges (the old TODO), so mail deleted in another client lingered as phantoms outside the small Drafts reconcile. - provider: enable QRESYNC when advertised (_maybe_enable_qresync + qresync_enabled property). poll() harvests VANISHED (expunged UIDs) and changed FLAGS in one pass via a raw `SELECT <mbox> (QRESYNC (uidvalidity modseq))` — issued as a low-level SELECT because aioimaplib only surfaces untagged VANISHED lines on a sync command, not on an async UID FETCH. CONDSTORE-only servers (e.g. Gmail) keep the CHANGEDSINCE flag delta and fall back to the engine reconcile. - parsing: parse_vanished() for `VANISHED [(EARLIER)] <uid-set>` with range expansion. - engine: apply_delta now drops expunged rows from the search index, un-links any local draft mirroring a vanished UID, and publishes message.deleted for the UI. - tests: parse_vanished unit cases + apply_delta expunge (removal, draft un-link, modseq advance). Verified live: the worker issues `SELECT "INBOX" (QRESYNC (uidvalidity modseq))` against QRESYNC-capable accounts. ruff + mypy --strict clean; 296 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
134 lines
4.6 KiB
Python
134 lines
4.6 KiB
Python
"""IMAP response parsing (SPEC §5.1 provider), using real home.pl wire samples."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from app.providers.imap_smtp.parsing import (
|
|
parse_fetch_lines,
|
|
parse_internaldate,
|
|
parse_list_line,
|
|
parse_search_uids,
|
|
parse_select_untagged,
|
|
parse_vanished,
|
|
)
|
|
|
|
|
|
def test_parse_list_special_use() -> None:
|
|
assert parse_list_line(rb'(\HasNoChildren \UnMarked \Sent) "/" SENT') is not None
|
|
cases = {
|
|
rb'(\HasNoChildren \UnMarked \Sent) "/" SENT': ("SENT", "sent"),
|
|
rb'(\HasNoChildren \UnMarked \Drafts) "/" DRAFTS': ("DRAFTS", "drafts"),
|
|
rb'(\HasNoChildren \UnMarked \Trash) "/" TRASH': ("TRASH", "trash"),
|
|
rb'(\HasNoChildren \UnMarked \Junk) "/" SPAM': ("SPAM", "junk"),
|
|
rb'(\HasNoChildren) "/" INBOX': ("INBOX", "inbox"),
|
|
}
|
|
for line, (path, special) in cases.items():
|
|
parsed = parse_list_line(line)
|
|
assert parsed is not None
|
|
assert parsed.path == path
|
|
assert parsed.special_use == special
|
|
|
|
|
|
def test_parse_list_plain_folder_has_no_special_use() -> None:
|
|
parsed = parse_list_line(rb'(\HasNoChildren) "/" "Work Stuff"')
|
|
assert parsed is not None
|
|
assert parsed.path == "Work Stuff"
|
|
assert parsed.special_use is None
|
|
|
|
|
|
def test_parse_list_utf7_name() -> None:
|
|
# Modified UTF-7: "&AOk-" decodes to "é" -> folder "Réponses"
|
|
parsed = parse_list_line(rb'(\HasNoChildren) "/" "R&AOk-ponses"')
|
|
assert parsed is not None
|
|
assert parsed.path == "Réponses"
|
|
|
|
|
|
def test_parse_internaldate() -> None:
|
|
dt = parse_internaldate("27-May-2026 19:08:09 +0200")
|
|
assert dt == datetime.fromisoformat("2026-05-27T19:08:09+02:00")
|
|
# single-digit day (space padded)
|
|
assert parse_internaldate(" 1-Jan-2026 05:06:07 +0000") is not None
|
|
assert parse_internaldate("garbage") is None
|
|
|
|
|
|
def test_parse_search_uids() -> None:
|
|
assert parse_search_uids([b"1 2 3 4 5 8", b"Search completed."]) == [1, 2, 3, 4, 5, 8]
|
|
assert parse_search_uids([b""]) == []
|
|
assert parse_search_uids([]) == []
|
|
|
|
|
|
def test_parse_vanished() -> None:
|
|
# aioimaplib strips the leading "* " before buffering the line.
|
|
assert parse_vanished([b"VANISHED (EARLIER) 300:305,405"]) == [300, 301, 302, 303, 304, 305, 405] # noqa: E501
|
|
# Live-expunge form (no EARLIER) and a descending range are both handled.
|
|
assert parse_vanished([b"VANISHED 41", b"VANISHED (EARLIER) 5:3"]) == [41, 3, 4, 5]
|
|
# Non-VANISHED lines are ignored; non-bytes entries are skipped.
|
|
assert parse_vanished([b"12 FETCH (UID 12 FLAGS (\\Seen))", b"OK done"]) == []
|
|
assert parse_vanished([]) == []
|
|
|
|
|
|
def test_parse_select_untagged() -> None:
|
|
lines = [
|
|
b"6 EXISTS",
|
|
b"0 RECENT",
|
|
b"OK [UIDVALIDITY 1772887273] UIDs valid",
|
|
b"OK [UIDNEXT 9] Predicted next UID",
|
|
b"OK [HIGHESTMODSEQ 31] Highest",
|
|
]
|
|
info = parse_select_untagged(lines)
|
|
assert info["uidvalidity"] == 1772887273
|
|
assert info["uidnext"] == 9
|
|
assert info["highestmodseq"] == 31
|
|
assert info["exists"] == 6
|
|
|
|
|
|
def test_parse_fetch_body() -> None:
|
|
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\nBody"
|
|
header = (
|
|
b"6 FETCH (UID 8 FLAGS (\\Answered \\Seen $label1) "
|
|
b'INTERNALDATE "27-May-2026 19:08:09 +0200" RFC822.SIZE 34 BODY[] {'
|
|
+ str(len(raw)).encode()
|
|
+ b"}"
|
|
)
|
|
lines: list[bytes | bytearray] = [header, bytearray(raw), b")", b"Fetch completed."]
|
|
parsed = parse_fetch_lines(lines)
|
|
assert len(parsed) == 1
|
|
pf = parsed[0]
|
|
assert pf.uid == 8
|
|
assert pf.raw == raw
|
|
assert "\\Seen" in pf.flags
|
|
assert "\\Answered" in pf.flags
|
|
assert pf.internal_date is not None
|
|
|
|
|
|
def test_parse_fetch_flags_only() -> None:
|
|
lines: list[bytes | bytearray] = [
|
|
b"1 FETCH (UID 5 FLAGS (\\Seen))",
|
|
b"2 FETCH (UID 6 FLAGS (\\Flagged \\Seen))",
|
|
b"Fetch completed.",
|
|
]
|
|
parsed = parse_fetch_lines(lines)
|
|
assert [p.uid for p in parsed] == [5, 6]
|
|
assert parsed[0].raw is None
|
|
assert parsed[1].flags == frozenset({"\\Flagged", "\\Seen"})
|
|
|
|
|
|
def test_parse_fetch_multiple_bodies() -> None:
|
|
raw1 = b"msg-one"
|
|
raw2 = b"message-two-longer"
|
|
lines: list[bytes | bytearray] = [
|
|
b'1 FETCH (UID 1 FLAGS () INTERNALDATE "1-Jan-2026 00:00:00 +0000" BODY[] {%d}' % len(raw1),
|
|
bytearray(raw1),
|
|
b")",
|
|
b'2 FETCH (UID 2 FLAGS (\\Seen) INTERNALDATE "2-Jan-2026 00:00:00 +0000" BODY[] {%d}'
|
|
% len(raw2),
|
|
bytearray(raw2),
|
|
b")",
|
|
b"Fetch completed.",
|
|
]
|
|
parsed = parse_fetch_lines(lines)
|
|
assert [p.uid for p in parsed] == [1, 2]
|
|
assert parsed[0].raw == raw1
|
|
assert parsed[1].raw == raw2
|