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>
168 lines
6.8 KiB
Python
168 lines
6.8 KiB
Python
"""Per-folder IMAP sync cursor: the `mail_sync_state` table (migration 005) plus the pure
|
|
logic that decides what a tick fetches and how far the cursor is allowed to move.
|
|
|
|
The rules encoded here are the whole correctness story of the poller, so they live as pure
|
|
functions with their own tests rather than inline in the job's loop:
|
|
|
|
* **UIDVALIDITY governs everything.** When the server changes it, every stored UID becomes
|
|
meaningless. The only safe response is to sweep the folder and lean on Message-ID dedup —
|
|
a poller that trusts `last_uid` across a UIDVALIDITY change loses mail with no error
|
|
anywhere (recon `kb/audits/mail-sync-2026-08-06.md` §2.2).
|
|
* **The cursor moves only over messages that are fully durable**, archive file and envelope
|
|
row both. It moves over a CONTIGUOUS prefix of successes, so a message that failed is
|
|
re-fetched next tick instead of being stepped over silently.
|
|
* **A first tick is a policy decision, not a default.** `new-only` starts the corpus from
|
|
now; `since` closes a known gap (gmail's corpus stops at 2026-06-19); `full` pulls a
|
|
mailbox's whole history. Which one is right depends on a measurement the runbook takes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime
|
|
from typing import Iterable, Optional
|
|
|
|
import asyncpg
|
|
import structlog
|
|
|
|
from .imap import FolderStatus, ImapAccount
|
|
|
|
_log = structlog.get_logger(__name__)
|
|
|
|
# What a plan tells the caller to run against the server.
|
|
SEARCH_NONE = "none" # fetch nothing this tick; just record the high-water mark
|
|
SEARCH_ALL = "all" # UID SEARCH ALL — full sweep, dedup does the rest
|
|
SEARCH_SINCE = "since" # UID SEARCH SINCE <date>
|
|
SEARCH_FROM_UID = "from-uid" # UID SEARCH UID <start>:*
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FolderSyncState:
|
|
"""One row of `mail_sync_state`."""
|
|
|
|
account: str
|
|
folder: str
|
|
uidvalidity: int
|
|
last_uid: int
|
|
last_sync_ts: Optional[datetime] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SyncPlan:
|
|
"""What this tick will do to one folder.
|
|
|
|
`baseline_uid` is the cursor to persist when the plan turns up NO messages — never a
|
|
floor to combine with partial results, because on a full sweep it equals `uidnext - 1`
|
|
and combining it with a partially-failed batch would step over everything that failed.
|
|
"""
|
|
|
|
mode: str
|
|
search: str
|
|
baseline_uid: int
|
|
start_uid: Optional[int] = None
|
|
since: Optional[date] = None
|
|
uidvalidity_reset: bool = False
|
|
|
|
|
|
def plan_folder_sync(
|
|
account: ImapAccount,
|
|
status: FolderStatus,
|
|
state: Optional[FolderSyncState],
|
|
) -> SyncPlan:
|
|
"""Decide this tick's work for one folder from the stored cursor and the server's state."""
|
|
high_water = max(status.uidnext - 1, 0)
|
|
|
|
if state is None:
|
|
if account.initial_mode == "new-only":
|
|
# Start the corpus here: record the high-water mark, fetch nothing. Everything
|
|
# already in the mailbox stays out of KB unless the operator re-runs with a
|
|
# different initial_mode after clearing the row.
|
|
return SyncPlan(mode="initial-new-only", search=SEARCH_NONE, baseline_uid=high_water)
|
|
if account.initial_mode == "since":
|
|
return SyncPlan(mode="initial-since", search=SEARCH_SINCE,
|
|
baseline_uid=high_water, since=account.initial_since)
|
|
return SyncPlan(mode="initial-full", search=SEARCH_ALL, baseline_uid=high_water)
|
|
|
|
if state.uidvalidity != status.uidvalidity:
|
|
_log.warning("imap.uidvalidity_reset", account=account.name, folder=status.name,
|
|
stored=state.uidvalidity, server=status.uidvalidity)
|
|
return SyncPlan(mode="uidvalidity-reset", search=SEARCH_ALL,
|
|
baseline_uid=high_water, uidvalidity_reset=True)
|
|
|
|
return SyncPlan(mode="incremental", search=SEARCH_FROM_UID,
|
|
baseline_uid=state.last_uid, start_uid=state.last_uid + 1)
|
|
|
|
|
|
def contiguous_last_uid(floor: int, attempted: Iterable[int], succeeded: set[int]) -> int:
|
|
"""How far the cursor may move: the last UID of the unbroken run of successes.
|
|
|
|
A failure stops the cursor at the message before it. The consequences are deliberate and
|
|
worth naming: nothing is ever lost, the re-fetch costs nothing (dedup), and a message
|
|
that fails *permanently* stalls its folder — visibly, as a non-zero error counter every
|
|
tick and a cursor that stops moving, with the offending UID in the log. That is strictly
|
|
better than the alternative of stepping over it, which drops mail while reporting success.
|
|
The runbook documents the manual `UPDATE mail_sync_state` escape hatch for that case.
|
|
"""
|
|
last = floor
|
|
for uid in sorted(attempted):
|
|
if uid not in succeeded:
|
|
break
|
|
last = max(last, uid)
|
|
return last
|
|
|
|
|
|
async def get_folder_state(
|
|
conn: asyncpg.Connection, account: str, folder: str
|
|
) -> Optional[FolderSyncState]:
|
|
row = await conn.fetchrow(
|
|
"SELECT account, folder, uidvalidity, last_uid, last_sync_ts "
|
|
"FROM mail_sync_state WHERE account = $1 AND folder = $2",
|
|
account, folder,
|
|
)
|
|
if row is None:
|
|
return None
|
|
return FolderSyncState(
|
|
account=row["account"],
|
|
folder=row["folder"],
|
|
uidvalidity=int(row["uidvalidity"]),
|
|
last_uid=int(row["last_uid"]),
|
|
last_sync_ts=row["last_sync_ts"],
|
|
)
|
|
|
|
|
|
async def upsert_folder_state(
|
|
conn: asyncpg.Connection, account: str, folder: str, uidvalidity: int, last_uid: int
|
|
) -> None:
|
|
"""Persist the cursor and stamp `last_sync_ts`.
|
|
|
|
Called on every tick including empty ones: `last_sync_ts` is how one answers "is this
|
|
folder being polled at all", which stays true when nothing has arrived for a week.
|
|
"""
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO mail_sync_state (account, folder, uidvalidity, last_uid, last_sync_ts)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
ON CONFLICT (account, folder) DO UPDATE
|
|
SET uidvalidity = EXCLUDED.uidvalidity,
|
|
last_uid = EXCLUDED.last_uid,
|
|
last_sync_ts = EXCLUDED.last_sync_ts
|
|
""",
|
|
account, folder, uidvalidity, last_uid,
|
|
)
|
|
_log.info("sync_state.saved", account=account, folder=folder,
|
|
uidvalidity=uidvalidity, last_uid=last_uid)
|
|
|
|
|
|
async def fetch_all_state(conn: asyncpg.Connection) -> list[FolderSyncState]:
|
|
"""Every cursor, for the runbook's inspection queries and the job's summary line."""
|
|
rows = await conn.fetch(
|
|
"SELECT account, folder, uidvalidity, last_uid, last_sync_ts "
|
|
"FROM mail_sync_state ORDER BY account, folder"
|
|
)
|
|
return [
|
|
FolderSyncState(
|
|
account=r["account"], folder=r["folder"], uidvalidity=int(r["uidvalidity"]),
|
|
last_uid=int(r["last_uid"]), last_sync_ts=r["last_sync_ts"],
|
|
)
|
|
for r in rows
|
|
]
|