Krok 7 fazy mailowej, warstwa wspoldzielona. Realizuje decyzje (a), (b), (f)
reconu kb/audits/mail-sync-2026-08-06.md (zatwierdzone przez operatora
2026-08-06): jeden adapter IMAP na oba konta, stan synca jako tabela w bazie.
Nowe moduly w packages/kb-mail:
- imap.py — ImapAccount/ImapClient nad stdlib imaplib (zero nowych zaleznosci).
Foldery otwierane READ-ONLY (EXAMINE) i pobierane przez BODY.PEEK[],
zeby job nie ustawial \Seen na skrzynce operatora. Wybor folderu po
atrybucie SPECIAL-USE, nigdy po nazwie — Gmail lokalizuje
"[Gmail]/All Mail". search_from_uid filtruje zakres po stronie
klienta, bo n:* zwraca ostatnia wiadomosc takze gdy przedzial pusty.
- sync_state.py — tabela mail_sync_state + czyste funkcje: plan_folder_sync
(pierwszy tick / przyrost / uniewaznienie UIDVALIDITY) i
contiguous_last_uid (kursor przesuwa sie tylko po nieprzerwanym
ciagu sukcesow — bledna wiadomosc jest ponawiana, nie przeskakiwana).
- headers.py / message.py — parse_headers(+fallback) z gmail-header-backfill oraz
message_id/parse_date/parse_attachments/eml_ref z gmail-bulk-import,
przeniesione zamiast skopiowane. Klucz dedup musi pochodzic z jednej
implementacji: kazdy insert przyrostowki trafia na 225 030 istniejacych
id. Stare joby re-eksportuja te nazwy — ich CLI i testy bez zmian.
kb_mail.db.insert_envelope zwraca teraz command tag (+ rows_affected,
envelope_source) — bez tego nie da sie odroznic zwyklego duplikatu od kolizji
Message-ID miedzy kontami (recon §2.4).
Migracja 005_mail_sync_state.sql: addytywna, klucz (account, folder).
Testy: 285 passed (111 kb-mail w tym 37 adaptera IMAP na fake serwerze i 26
planera kursora; 174 istniejace suity jobow bez zmian po ekstrakcji).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
331 lines
13 KiB
Python
331 lines
13 KiB
Python
"""Unit tests for the IMAP adapter — no network, no real imaplib connection.
|
|
|
|
`ImapClient` is exercised against `_FakeIMAP`, a stand-in for `imaplib.IMAP4_SSL` that speaks
|
|
real IMAP response shapes (bytes lines, `(prefix, literal)` FETCH tuples, untagged responses).
|
|
The point is to test this module's OWN parsing rather than a mock of it — every response shape
|
|
here is one imaplib actually hands back.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from kb_mail.imap import (
|
|
DEFAULT_PORT,
|
|
ImapAccount,
|
|
ImapClient,
|
|
ImapError,
|
|
imap_date,
|
|
parse_list_line,
|
|
quote_mailbox,
|
|
)
|
|
|
|
|
|
class _FakeIMAP:
|
|
"""Minimal IMAP4_SSL stand-in. Records commands so tests can assert on the wire traffic —
|
|
notably that folders are opened READ-ONLY and bodies fetched with BODY.PEEK[]."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
list_data=None,
|
|
messages=None,
|
|
uidvalidity=42,
|
|
uidnext=101,
|
|
status_line=None,
|
|
search_ok=True,
|
|
select_ok=True,
|
|
untagged=True,
|
|
):
|
|
self.list_data = list_data if list_data is not None else [
|
|
b'(\\HasNoChildren) "/" "INBOX"',
|
|
b'(\\HasNoChildren \\All) "/" "[Gmail]/Wszystkie"',
|
|
b'(\\HasNoChildren \\Sent) "/" "[Gmail]/Wys&AWI-ane"',
|
|
]
|
|
self.messages = messages or {}
|
|
self.uidvalidity = uidvalidity
|
|
self.uidnext = uidnext
|
|
self.status_line = status_line
|
|
self.search_ok = search_ok
|
|
self.select_ok = select_ok
|
|
self.untagged = untagged
|
|
self.commands: list[tuple] = []
|
|
self.logged_in = False
|
|
self.logged_out = False
|
|
|
|
def login(self, user, password):
|
|
self.commands.append(("LOGIN", user))
|
|
self.logged_in = True
|
|
return ("OK", [b"LOGIN completed"])
|
|
|
|
def logout(self):
|
|
self.logged_out = True
|
|
return ("BYE", [b"logging out"])
|
|
|
|
def list(self, directory='""', pattern="*"):
|
|
self.commands.append(("LIST",))
|
|
return ("OK", self.list_data)
|
|
|
|
def select(self, mailbox, readonly=False):
|
|
self.commands.append(("SELECT", mailbox, readonly))
|
|
if not self.select_ok:
|
|
return ("NO", [b"no such mailbox"])
|
|
return ("OK", [str(len(self.messages)).encode()])
|
|
|
|
def status(self, mailbox, names):
|
|
self.commands.append(("STATUS", mailbox, names))
|
|
line = self.status_line or (
|
|
f'"{mailbox}" (MESSAGES {len(self.messages)} '
|
|
f"UIDNEXT {self.uidnext} UIDVALIDITY {self.uidvalidity})"
|
|
).encode()
|
|
return ("OK", [line])
|
|
|
|
def response(self, key):
|
|
if not self.untagged:
|
|
return (key, [None])
|
|
if key == "UIDVALIDITY":
|
|
return (key, [str(self.uidvalidity).encode()])
|
|
if key == "UIDNEXT":
|
|
return (key, [str(self.uidnext).encode()])
|
|
return (key, [None])
|
|
|
|
def uid(self, command, *args):
|
|
self.commands.append(("UID", command) + args)
|
|
if command == "SEARCH":
|
|
if not self.search_ok:
|
|
return ("NO", [b"search failed"])
|
|
criteria = args[1:]
|
|
return ("OK", [self._search(criteria)])
|
|
if command == "FETCH":
|
|
uid = int(args[0])
|
|
raw = self.messages.get(uid)
|
|
if raw is None:
|
|
return ("OK", [None])
|
|
prefix = f"1 (UID {uid} BODY[] {{{len(raw)}}}".encode()
|
|
return ("OK", [(prefix, raw), b")"])
|
|
raise AssertionError(f"unexpected UID command {command}")
|
|
|
|
def _search(self, criteria):
|
|
uids = sorted(self.messages)
|
|
if criteria and criteria[0] == "UID":
|
|
start = int(criteria[1].split(":")[0])
|
|
hits = [u for u in uids if u >= start]
|
|
# Real servers resolve `n:*` as a range and return the highest message even when
|
|
# n is beyond it. Reproduced deliberately — the client-side filter exists for this.
|
|
if not hits and uids:
|
|
hits = [uids[-1]]
|
|
return " ".join(str(u) for u in hits).encode()
|
|
return " ".join(str(u) for u in uids).encode()
|
|
|
|
|
|
def _account(**kwargs) -> ImapAccount:
|
|
base = dict(name="gmail", host="imap.example.com", user="u@example.com",
|
|
password="secret", special_use=("\\All",))
|
|
base.update(kwargs)
|
|
return ImapAccount(**base)
|
|
|
|
|
|
def _client(fake: _FakeIMAP, account=None) -> ImapClient:
|
|
account = account or _account()
|
|
client = ImapClient(account, connection_factory=lambda acc, timeout: fake)
|
|
client.connect()
|
|
return client
|
|
|
|
|
|
class TestImapAccount:
|
|
def test_defaults_to_implicit_tls_port(self):
|
|
assert _account().port == DEFAULT_PORT
|
|
|
|
def test_password_is_not_in_repr(self):
|
|
# The repo has two documented secret leaks to session transcripts (recon Decyzja (c)).
|
|
assert "secret" not in repr(_account())
|
|
|
|
def test_rejects_account_with_no_scope(self):
|
|
with pytest.raises(ValueError, match="no scope"):
|
|
ImapAccount(name="x", host="h", user="u", password="p")
|
|
|
|
def test_rejects_unknown_initial_mode(self):
|
|
with pytest.raises(ValueError, match="initial_mode"):
|
|
_account(initial_mode="yesterday")
|
|
|
|
def test_since_mode_requires_a_date(self):
|
|
with pytest.raises(ValueError, match="initial_since"):
|
|
_account(initial_mode="since")
|
|
|
|
def test_since_mode_with_date_is_valid(self):
|
|
assert _account(initial_mode="since", initial_since=date(2026, 6, 15)).initial_since
|
|
|
|
|
|
class TestHelpers:
|
|
def test_imap_date_uses_english_month_abbreviations(self):
|
|
assert imap_date(date(2026, 6, 15)) == "15-Jun-2026"
|
|
|
|
def test_imap_date_zero_pads_the_day(self):
|
|
assert imap_date(date(2026, 12, 5)) == "05-Dec-2026"
|
|
|
|
def test_quote_mailbox_wraps_names_with_spaces(self):
|
|
assert quote_mailbox("[Gmail]/All Mail") == '"[Gmail]/All Mail"'
|
|
|
|
def test_quote_mailbox_escapes_quotes_and_backslashes(self):
|
|
assert quote_mailbox('we"ird\\name') == '"we\\"ird\\\\name"'
|
|
|
|
|
|
class TestParseListLine:
|
|
def test_quoted_name_with_attributes(self):
|
|
attrs, name = parse_list_line(b'(\\HasNoChildren \\All) "/" "[Gmail]/All Mail"')
|
|
assert name == "[Gmail]/All Mail"
|
|
assert "\\all" in attrs
|
|
|
|
def test_unquoted_atom_name(self):
|
|
attrs, name = parse_list_line(b'(\\HasNoChildren) "/" INBOX')
|
|
assert name == "INBOX"
|
|
|
|
def test_nil_delimiter(self):
|
|
attrs, name = parse_list_line(b'(\\Noselect) NIL "Archive"')
|
|
assert name == "Archive"
|
|
|
|
def test_literal_name_arrives_as_a_tuple(self):
|
|
attrs, name = parse_list_line((b'(\\HasNoChildren \\Archive) "/" {7}', b"Archiwa"))
|
|
assert name == "Archiwa"
|
|
assert "\\archive" in attrs
|
|
|
|
def test_unparseable_line_returns_none(self):
|
|
assert parse_list_line(b"* SOMETHING ELSE") is None
|
|
|
|
|
|
class TestResolveFolders:
|
|
def test_special_use_is_matched_by_attribute_not_name(self):
|
|
# The Polish-UI name is what makes hardcoding '[Gmail]/All Mail' a silent zero-mail sync.
|
|
fake = _FakeIMAP()
|
|
assert _client(fake).resolve_folders() == ["[Gmail]/Wszystkie"]
|
|
|
|
def test_literal_folders_are_used_as_configured(self):
|
|
fake = _FakeIMAP()
|
|
account = _account(name="fastmail", special_use=(),
|
|
folders=("INBOX", "Archive", "Sent"))
|
|
assert _client(fake, account).resolve_folders() == ["INBOX", "Archive", "Sent"]
|
|
|
|
def test_missing_special_use_raises_instead_of_syncing_nothing(self):
|
|
fake = _FakeIMAP(list_data=[b'(\\HasNoChildren) "/" "INBOX"'])
|
|
with pytest.raises(ImapError, match="SPECIAL-USE"):
|
|
_client(fake).resolve_folders()
|
|
|
|
def test_duplicates_between_attribute_and_literal_are_collapsed(self):
|
|
fake = _FakeIMAP()
|
|
account = _account(special_use=("\\All",), folders=("[Gmail]/Wszystkie", "INBOX"))
|
|
assert _client(fake, account).resolve_folders() == ["[Gmail]/Wszystkie", "INBOX"]
|
|
|
|
def test_no_list_call_when_only_literal_folders_are_configured(self):
|
|
fake = _FakeIMAP()
|
|
account = _account(special_use=(), folders=("INBOX",))
|
|
_client(fake, account).resolve_folders()
|
|
assert not any(c[0] == "LIST" for c in fake.commands)
|
|
|
|
|
|
class TestExamine:
|
|
def test_opens_folder_read_only(self):
|
|
# A plain SELECT + FETCH RFC822 would set \Seen on the operator's mail.
|
|
fake = _FakeIMAP(messages={1: b"raw"})
|
|
_client(fake).examine("INBOX")
|
|
select_cmds = [c for c in fake.commands if c[0] == "SELECT"]
|
|
assert select_cmds and select_cmds[0][2] is True
|
|
|
|
def test_returns_uidvalidity_and_uidnext_from_untagged_responses(self):
|
|
fake = _FakeIMAP(uidvalidity=7, uidnext=55)
|
|
status = _client(fake).examine("INBOX")
|
|
assert (status.uidvalidity, status.uidnext) == (7, 55)
|
|
assert not any(c[0] == "STATUS" for c in fake.commands)
|
|
|
|
def test_falls_back_to_status_when_server_omits_untagged_values(self):
|
|
fake = _FakeIMAP(uidvalidity=9, uidnext=30, untagged=False)
|
|
status = _client(fake).examine("INBOX")
|
|
assert (status.uidvalidity, status.uidnext) == (9, 30)
|
|
assert any(c[0] == "STATUS" for c in fake.commands)
|
|
# ... and re-opens the mailbox, since STATUS can deselect it.
|
|
assert len([c for c in fake.commands if c[0] == "SELECT"]) == 2
|
|
|
|
def test_non_ok_select_raises(self):
|
|
fake = _FakeIMAP(select_ok=False)
|
|
with pytest.raises(ImapError, match="EXAMINE"):
|
|
_client(fake).examine("Nope")
|
|
|
|
|
|
class TestStatus:
|
|
def test_parses_message_count_for_the_sizing_measurement(self):
|
|
fake = _FakeIMAP(messages={1: b"a", 2: b"b"}, uidnext=3, uidvalidity=11)
|
|
status = _client(fake).status("INBOX")
|
|
assert (status.messages, status.uidnext, status.uidvalidity) == (2, 3, 11)
|
|
|
|
def test_missing_field_raises_rather_than_defaulting(self):
|
|
fake = _FakeIMAP(status_line=b'"INBOX" (MESSAGES 2)')
|
|
with pytest.raises(ImapError, match="has no UIDVALIDITY"):
|
|
_client(fake).status("INBOX")
|
|
|
|
|
|
class TestSearch:
|
|
def test_search_all_returns_sorted_uids(self):
|
|
fake = _FakeIMAP(messages={3: b"c", 1: b"a", 2: b"b"})
|
|
assert _client(fake).search_all() == [1, 2, 3]
|
|
|
|
def test_search_since_sends_an_imap_date(self):
|
|
fake = _FakeIMAP(messages={1: b"a"})
|
|
_client(fake).search_since(date(2026, 6, 15))
|
|
search = [c for c in fake.commands if c[:2] == ("UID", "SEARCH")][0]
|
|
assert search[-2:] == ("SINCE", "15-Jun-2026")
|
|
|
|
def test_search_from_uid_filters_the_range_trailer(self):
|
|
# `n:*` past the end returns the LAST message on real servers; without the
|
|
# client-side filter every idle tick would report one "new" mail forever.
|
|
fake = _FakeIMAP(messages={1: b"a", 2: b"b"})
|
|
assert _client(fake).search_from_uid(3) == []
|
|
|
|
def test_search_from_uid_returns_only_new_uids(self):
|
|
fake = _FakeIMAP(messages={1: b"a", 2: b"b", 3: b"c"})
|
|
assert _client(fake).search_from_uid(2) == [2, 3]
|
|
|
|
def test_non_ok_search_raises(self):
|
|
fake = _FakeIMAP(messages={1: b"a"}, search_ok=False)
|
|
with pytest.raises(ImapError, match="UID SEARCH"):
|
|
_client(fake).search_all()
|
|
|
|
|
|
class TestFetch:
|
|
def test_returns_raw_bytes_and_uses_body_peek(self):
|
|
fake = _FakeIMAP(messages={5: b"From: a@b\r\n\r\nhello"})
|
|
assert _client(fake).fetch_message(5) == b"From: a@b\r\n\r\nhello"
|
|
fetch = [c for c in fake.commands if c[:2] == ("UID", "FETCH")][0]
|
|
assert fetch[-1] == "(BODY.PEEK[])"
|
|
|
|
def test_missing_uid_returns_none_not_an_error(self):
|
|
# Expunged between SEARCH and FETCH — an ordinary race on a live mailbox.
|
|
fake = _FakeIMAP(messages={5: b"x"})
|
|
assert _client(fake).fetch_message(6) is None
|
|
|
|
def test_mismatched_uid_in_response_raises(self):
|
|
fake = _FakeIMAP(messages={5: b"x"})
|
|
client = _client(fake)
|
|
fake.uid = lambda command, *args: ("OK", [(b"1 (UID 9 BODY[] {1}", b"x"), b")"])
|
|
with pytest.raises(ImapError, match="returned UID 9"):
|
|
client.fetch_message(5)
|
|
|
|
|
|
class TestLifecycle:
|
|
def test_context_manager_logs_in_and_out(self):
|
|
fake = _FakeIMAP()
|
|
with ImapClient(_account(), connection_factory=lambda acc, t: fake):
|
|
assert fake.logged_in
|
|
assert fake.logged_out
|
|
|
|
def test_logout_runs_even_when_the_body_raises(self):
|
|
fake = _FakeIMAP()
|
|
with pytest.raises(RuntimeError):
|
|
with ImapClient(_account(), connection_factory=lambda acc, t: fake):
|
|
raise RuntimeError("boom")
|
|
assert fake.logged_out
|
|
|
|
def test_commands_before_connect_raise(self):
|
|
client = ImapClient(_account(), connection_factory=lambda acc, t: _FakeIMAP())
|
|
with pytest.raises(ImapError, match="not connected"):
|
|
client.search_all()
|