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>
183 lines
7.5 KiB
Python
183 lines
7.5 KiB
Python
"""Unit tests for the sync cursor: the plan a tick makes, and how far the cursor may move.
|
|
|
|
These two pure functions carry the whole correctness argument of the poller — a wrong branch
|
|
here loses mail with no error anywhere — so they are tested directly, without a DB. The
|
|
asyncpg helpers are tested against an in-memory fake connection, the same style as
|
|
`jobs/gmail-header-backfill/tests`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from kb_mail.imap import FolderStatus, ImapAccount
|
|
from kb_mail.sync_state import (
|
|
SEARCH_ALL,
|
|
SEARCH_FROM_UID,
|
|
SEARCH_NONE,
|
|
SEARCH_SINCE,
|
|
FolderSyncState,
|
|
contiguous_last_uid,
|
|
fetch_all_state,
|
|
get_folder_state,
|
|
plan_folder_sync,
|
|
upsert_folder_state,
|
|
)
|
|
|
|
|
|
def _account(**kwargs) -> ImapAccount:
|
|
base = dict(name="gmail", host="h", user="u", password="p", special_use=("\\All",))
|
|
base.update(kwargs)
|
|
return ImapAccount(**base)
|
|
|
|
|
|
def _status(uidvalidity=42, uidnext=101) -> FolderStatus:
|
|
return FolderStatus(name="[Gmail]/All Mail", uidvalidity=uidvalidity, uidnext=uidnext)
|
|
|
|
|
|
def _state(uidvalidity=42, last_uid=90) -> FolderSyncState:
|
|
return FolderSyncState(account="gmail", folder="[Gmail]/All Mail",
|
|
uidvalidity=uidvalidity, last_uid=last_uid)
|
|
|
|
|
|
class TestPlanFirstTick:
|
|
def test_new_only_fetches_nothing_and_records_the_high_water_mark(self):
|
|
plan = plan_folder_sync(_account(initial_mode="new-only"), _status(uidnext=101), None)
|
|
assert plan.search == SEARCH_NONE
|
|
assert plan.baseline_uid == 100
|
|
assert plan.mode == "initial-new-only"
|
|
|
|
def test_new_only_on_an_empty_mailbox_records_zero_not_minus_one(self):
|
|
plan = plan_folder_sync(_account(initial_mode="new-only"), _status(uidnext=1), None)
|
|
assert plan.baseline_uid == 0
|
|
|
|
def test_since_mode_carries_the_configured_date(self):
|
|
account = _account(initial_mode="since", initial_since=date(2026, 6, 15))
|
|
plan = plan_folder_sync(account, _status(), None)
|
|
assert plan.search == SEARCH_SINCE
|
|
assert plan.since == date(2026, 6, 15)
|
|
|
|
def test_full_mode_sweeps_everything(self):
|
|
plan = plan_folder_sync(_account(initial_mode="full"), _status(), None)
|
|
assert plan.search == SEARCH_ALL
|
|
assert plan.mode == "initial-full"
|
|
|
|
def test_first_tick_is_never_flagged_as_a_uidvalidity_reset(self):
|
|
plan = plan_folder_sync(_account(initial_mode="full"), _status(), None)
|
|
assert plan.uidvalidity_reset is False
|
|
|
|
|
|
class TestPlanIncremental:
|
|
def test_resumes_from_the_uid_after_the_cursor(self):
|
|
plan = plan_folder_sync(_account(), _status(uidvalidity=42), _state(last_uid=90))
|
|
assert plan.search == SEARCH_FROM_UID
|
|
assert plan.start_uid == 91
|
|
assert plan.mode == "incremental"
|
|
|
|
def test_baseline_is_the_stored_cursor_so_an_empty_tick_changes_nothing(self):
|
|
plan = plan_folder_sync(_account(), _status(), _state(last_uid=90))
|
|
assert plan.baseline_uid == 90
|
|
|
|
def test_initial_mode_is_ignored_once_a_cursor_exists(self):
|
|
# Otherwise flipping initial_mode to 'full' in .env would re-sweep every folder
|
|
# on the next tick instead of only affecting folders with no state.
|
|
account = _account(initial_mode="since", initial_since=date(2026, 1, 1))
|
|
plan = plan_folder_sync(account, _status(), _state())
|
|
assert plan.search == SEARCH_FROM_UID
|
|
|
|
|
|
class TestPlanUidvalidityReset:
|
|
def test_changed_uidvalidity_forces_a_full_sweep(self):
|
|
plan = plan_folder_sync(_account(), _status(uidvalidity=99), _state(uidvalidity=42))
|
|
assert plan.search == SEARCH_ALL
|
|
assert plan.uidvalidity_reset is True
|
|
assert plan.mode == "uidvalidity-reset"
|
|
|
|
def test_stored_cursor_is_discarded_not_reused_as_a_floor(self):
|
|
# The stored last_uid means nothing under a new UIDVALIDITY; keeping it as a floor
|
|
# would skip every message whose new UID happens to fall below it.
|
|
plan = plan_folder_sync(_account(), _status(uidvalidity=99, uidnext=51),
|
|
_state(uidvalidity=42, last_uid=9000))
|
|
assert plan.start_uid is None
|
|
assert plan.baseline_uid == 50
|
|
|
|
|
|
class TestContiguousLastUid:
|
|
def test_all_succeeded_advances_to_the_last_uid(self):
|
|
assert contiguous_last_uid(90, [91, 92, 93], {91, 92, 93}) == 93
|
|
|
|
def test_stops_before_the_first_failure(self):
|
|
assert contiguous_last_uid(90, [91, 92, 93], {91, 93}) == 91
|
|
|
|
def test_failure_on_the_first_uid_leaves_the_cursor_where_it_was(self):
|
|
assert contiguous_last_uid(90, [91, 92], {92}) == 90
|
|
|
|
def test_nothing_attempted_returns_the_floor(self):
|
|
assert contiguous_last_uid(90, [], set()) == 90
|
|
|
|
def test_sparse_uid_sets_advance_over_gaps(self):
|
|
# A SINCE search returns non-contiguous UIDs; skipped-by-design UIDs are not failures.
|
|
assert contiguous_last_uid(0, [95, 97, 100], {95, 97, 100}) == 100
|
|
|
|
def test_unsorted_input_is_ordered_before_walking(self):
|
|
assert contiguous_last_uid(0, [93, 91, 92], {91, 92}) == 92
|
|
|
|
def test_full_sweep_floor_of_zero_with_an_early_failure_keeps_the_cursor_at_zero(self):
|
|
# This is why baseline_uid (uidnext-1) must NOT be used as a floor for partial
|
|
# results: it would step straight over everything that failed.
|
|
assert contiguous_last_uid(0, [1, 2, 3], {2, 3}) == 0
|
|
|
|
|
|
class _FakeConn:
|
|
def __init__(self, row=None, rows=None):
|
|
self.row = row
|
|
self.rows = rows or []
|
|
self.executed: list[tuple] = []
|
|
self.queries: list[tuple] = []
|
|
|
|
async def fetchrow(self, query, *params):
|
|
self.queries.append((query, params))
|
|
return self.row
|
|
|
|
async def fetch(self, query, *params):
|
|
self.queries.append((query, params))
|
|
return self.rows
|
|
|
|
async def execute(self, query, *params):
|
|
self.executed.append((query, params))
|
|
return "INSERT 0 1"
|
|
|
|
|
|
class TestStateHelpers:
|
|
async def test_get_folder_state_returns_none_when_absent(self):
|
|
assert await get_folder_state(_FakeConn(), "gmail", "INBOX") is None
|
|
|
|
async def test_get_folder_state_decodes_a_row(self):
|
|
ts = datetime(2026, 8, 6, tzinfo=timezone.utc)
|
|
conn = _FakeConn(row={"account": "gmail", "folder": "INBOX", "uidvalidity": 42,
|
|
"last_uid": 90, "last_sync_ts": ts})
|
|
state = await get_folder_state(conn, "gmail", "INBOX")
|
|
assert (state.uidvalidity, state.last_uid, state.last_sync_ts) == (42, 90, ts)
|
|
|
|
async def test_upsert_is_an_on_conflict_update(self):
|
|
conn = _FakeConn()
|
|
await upsert_folder_state(conn, "gmail", "INBOX", 42, 91)
|
|
query, params = conn.executed[-1]
|
|
assert "ON CONFLICT (account, folder) DO UPDATE" in query
|
|
assert params == ("gmail", "INBOX", 42, 91)
|
|
|
|
async def test_upsert_stamps_last_sync_ts_server_side(self):
|
|
# last_sync_ts answers "is this folder being polled at all" — it must advance on
|
|
# empty ticks too, so it is set by the statement rather than passed in.
|
|
conn = _FakeConn()
|
|
await upsert_folder_state(conn, "gmail", "INBOX", 42, 91)
|
|
assert "now()" in conn.executed[-1][0]
|
|
|
|
async def test_fetch_all_state_is_ordered(self):
|
|
conn = _FakeConn(rows=[{"account": "fastmail", "folder": "INBOX", "uidvalidity": 1,
|
|
"last_uid": 2, "last_sync_ts": None}])
|
|
states = await fetch_all_state(conn)
|
|
assert "ORDER BY account, folder" in conn.queries[-1][0]
|
|
assert states[0].account == "fastmail"
|