feat(mail-imap-sync): job przyrostowki + wpiecie w tor body-ingest

Nowy job jobs/mail-imap-sync — jedyny wlasciwy nowy kod przyrostowki
(recon kb/audits/mail-sync-2026-08-06.md §6 poz. 1). Jeden tick, per konto
i folder: EXAMINE -> plan -> UID SEARCH -> FETCH BODY.PEEK[] -> save_eml ->
insert_envelope(entities=[headers, attachment...]) -> UPDATE mail_sync_state.

Wlasciwosci, ktore latwo zgubic po cichu:
- Job NIE embeduje i NIE chunkuje (recon §3.2). Pobieranie jest sieciowe i chodzi
  na PIHA 24/7; chunk+embed potrzebuje Ollamy na SOLARII, wylaczanej ~16 h/dobe.
  Spoiwem jest kolejka wynikajaca z danych: koperta bez chunkow JEST elementem
  kolejki, ktora drenuje mail-body-ingest --only-unchunked.
- Koperta dostaje entities[type=headers] juz przy INSERCIE. Bez tego kazdy nowy
  mail mialby prefiks "Temat: (brak tematu) | Od: ?" — bez bledu, tylko z gorszym
  retrievalem (recon §2.5 i).
- Kolizja Message-ID miedzy kontami jest liczona (envelopes_conflict_other_source),
  nie ukryta w zwyklych duplikatach (recon §2.4).
- Kursor przesuwa sie tylko po nieprzerwanym ciagu w pelni trwalych wiadomosci.
  Bledna wiadomosc jest ponawiana (dedup czyni to darmowym), nie przeskakiwana;
  trwale zatrucie widac jako niezerowy licznik bledow i stojacy kursor.
- Poswiadczenia wylacznie ze srodowiska — brak flagi --password/--user (Decyzja (c)).
- Tryb --measure (STATUS MESSAGES/UIDNEXT/UIDVALIDITY): pomiar, na ktorym operator
  oprze decyzje o historii Fastmaila (Decyzja (e), celowo nieodgadywana).
- Metryki .prom per konto; last_success_timestamp przenoszony przez nieudany run.

Wpiecie w istniejacy tor (recon §6 poz. 4-6):
- mail_body_ingest.fetch_envelopes: --sources (domyslnie gmail,fastmail) +
  --only-unchunked.
- fetch_existing_chunk_keys zawezone do zbioru roboczego — bez tego kazdy tick
  czyta wszystkie 389 012 kluczy chunkow (~26 MB, ~1,0 s) na nodzie z 2,4 GB
  available (recon §2.5 iv).
- DEFAULT_SUMMARYLESS_SOURCES += "fastmail" (Decyzja (g)) w TYM SAMYM commicie,
  ktory wprowadza zrodlo: bez tego koperty fastmail zaindeksowalyby sie poprawnie
  i byly niewidoczne w /search, bez zadnego bledu.

Testy: 80 dla nowego joba (mock IMAP na poziomie imaplib, wiec testowane jest
prawdziwe parsowanie protokolu) — nowe wiadomosci, uniewaznienie UIDVALIDITY,
dedup, wznowienie po przerwaniu, izolacja kont, dry-run bez sieci, metryki;
+ 47 mail-body-ingest/kb-retrieval. Smoke: --help, blad konfiguracji -> exit 2.
Zero polaczen z zywymi kontami — pierwszy zywy sync robi operator wg runbooka.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
oskar 2026-08-06 14:37:32 +02:00
parent c65f0f2075
commit f056b08574
13 changed files with 1999 additions and 20 deletions

View file

@ -106,6 +106,9 @@ from kb_retrieval.embed import (
_log = structlog.get_logger(__name__)
DEFAULT_ARCHIVE_ROOT = Path("/home/oskar/kb/mail/archive")
# Both live mail accounts. `fastmail` joined when jobs/mail-imap-sync started writing that
# source (recon Decyzja (b)/(g)); it is inert until then, since the source has no rows.
DEFAULT_SOURCES = ("gmail", "fastmail")
DEFAULT_BATCH_SIZE = 64
DEFAULT_EMBED_RETRIES = 2
DEFAULT_EMBED_BACKOFF_S = 1.0
@ -366,14 +369,33 @@ def _has_threading(entities: list) -> bool:
async def fetch_envelopes(
conn: asyncpg.Connection, since: Optional[datetime], limit: Optional[int], offset: Optional[int]
conn: asyncpg.Connection,
since: Optional[datetime] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
sources: tuple[str, ...] = DEFAULT_SOURCES,
only_unchunked: bool = False,
) -> list:
"""`source='gmail'` envelopes, ordered by id for stable --limit/--offset slicing."""
query = "SELECT id, ts, raw_ref, entities FROM envelope WHERE source = 'gmail'"
params: list = []
"""Mail envelopes for `sources`, ordered by id for stable --limit/--offset slicing.
`only_unchunked` restricts the set to envelopes with no `document_chunk` rows at all
the queue the incremental poller feeds (recon §3.2). It is derived from the data rather
than kept as separate state, which makes it self-healing: an interrupted run, a mail
skipped while Ollama was down, a message inserted with a backdated header each stays in
the queue until it has chunks, with nothing to reconcile. `--since` deliberately does NOT
play that role: it filters on `envelope.ts`, the SENDER's clock, so a mail delivered today
with a month-old header date would fall outside the window and never be chunked.
"""
query = "SELECT id, ts, raw_ref, entities FROM envelope e WHERE source = ANY($1)"
params: list = [list(sources)]
if since is not None:
params.append(since)
query += f" AND ts >= ${len(params)}"
if only_unchunked:
query += (
" AND NOT EXISTS ("
"SELECT 1 FROM document_chunk c WHERE c.envelope_id = e.id)"
)
query += " ORDER BY id"
if limit is not None:
params.append(limit)
@ -384,9 +406,27 @@ async def fetch_envelopes(
return await conn.fetch(query, *params)
async def fetch_existing_chunk_keys(conn: asyncpg.Connection, model: str) -> set[tuple[str, int]]:
async def fetch_existing_chunk_keys(
conn: asyncpg.Connection, model: str, envelope_ids: Optional[list[str]] = None
) -> set[tuple[str, int]]:
"""(envelope_id, chunk_index) pairs already inserted with this model — idempotency + dry-run
preview. The query filters `WHERE model = $1`, so the pair need not redundantly carry model."""
preview. The query filters `WHERE model = $1`, so the pair need not redundantly carry model.
`envelope_ids` narrows the fetch to the current working set. Unbounded, this reads every
chunk key in the corpus: measured 2026-08-06 at 389 012 rows, ~1.0 s server-side, ~26 MB on
the wire and a 60-90 MB Python set irrelevant once for a 50k backfill slice, wasteful
every two hours on a node with 2.4 GB available, and growing with the corpus (recon
§2.5 iv). None keeps the old unbounded behaviour for the manual full-corpus runs.
"""
if envelope_ids is not None:
if not envelope_ids:
return set()
rows = await conn.fetch(
"SELECT envelope_id, chunk_index FROM document_chunk "
"WHERE model = $1 AND envelope_id = ANY($2)",
model, envelope_ids,
)
else:
rows = await conn.fetch(
"SELECT envelope_id, chunk_index FROM document_chunk WHERE model = $1", model
)
@ -458,14 +498,16 @@ async def run(
limit: Optional[int] = None,
offset: Optional[int] = None,
apply: bool = False,
sources: tuple[str, ...] = DEFAULT_SOURCES,
only_unchunked: bool = False,
batch_size: int = DEFAULT_BATCH_SIZE,
max_embed_failures: int = DEFAULT_MAX_EMBED_FAILURES,
embed_retries: int = DEFAULT_EMBED_RETRIES,
embed_backoff_s: float = DEFAULT_EMBED_BACKOFF_S,
embed_timeout_s: float = DEFAULT_EMBED_TIMEOUT_S,
) -> dict:
"""Process one --limit/--offset (optionally --since-filtered) slice of `source='gmail'`
envelopes. dry-run (apply=False): parse + quote-strip + classify + chunk + count, zero
"""Process one --limit/--offset (optionally --since-filtered) slice of mail envelopes for
`sources`. dry-run (apply=False): parse + quote-strip + classify + chunk + count, zero
Ollama calls, zero writes (including the threading UPDATE). Stats must always balance:
mails_scanned = missing_file + read_errors + parse_errors + body_empty + mails_chunked
@ -475,8 +517,12 @@ async def run(
stats = _new_stats()
conn = await asyncpg.connect(dsn)
try:
envelopes = await fetch_envelopes(conn, since, limit, offset)
existing = await fetch_existing_chunk_keys(conn, model)
envelopes = await fetch_envelopes(
conn, since, limit, offset, sources=sources, only_unchunked=only_unchunked
)
existing = await fetch_existing_chunk_keys(
conn, model, envelope_ids=[row["id"] for row in envelopes]
)
session: Optional[aiohttp.ClientSession] = None
if apply:
@ -692,6 +738,13 @@ def _parse_since(value: str) -> datetime:
return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc)
def _parse_sources(value: str) -> tuple[str, ...]:
sources = tuple(part.strip() for part in value.split(",") if part.strip())
if not sources:
raise SystemExit("--sources must name at least one envelope source")
return sources
def _env_num(name: str, default, cast):
"""Env override for a numeric CLI default. A malformed value is a loud startup failure, not a
silent fallback to the default a typo'd MAIL_INGEST_BATCH_SIZE must not quietly produce a
@ -707,7 +760,7 @@ def _env_num(name: str, default, cast):
def main() -> None:
parser = argparse.ArgumentParser(
description="Chunk + embed source='gmail' envelope body content into document_chunk "
description="Chunk + embed mail envelope body content into document_chunk "
"(module 5, faza mailowa — plan §5, Krok 2)."
)
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"),
@ -718,8 +771,17 @@ def main() -> None:
help=f"Ollama base URL (default: {DEFAULT_OLLAMA_URL}, or set OLLAMA_URL)")
parser.add_argument("--model", default=os.environ.get("OLLAMA_EMBED_MODEL", DEFAULT_MODEL),
help=f"Ollama embedding model (default: {DEFAULT_MODEL})")
parser.add_argument("--sources", type=_parse_sources, default=DEFAULT_SOURCES,
metavar="A,B",
help=f"Comma-separated envelope sources to process "
f"(default: {','.join(DEFAULT_SOURCES)})")
parser.add_argument("--only-unchunked", action="store_true",
help="Only envelopes that have no document_chunk rows at all — the "
"queue mail-imap-sync feeds. This is the cyclic mode; without it "
"the job re-scans the whole slice and relies on the chunk-key set.")
parser.add_argument("--since", type=_parse_since, default=None, metavar="YYYY-MM-DD",
help="Only envelopes with ts >= this date (for staged runs, plan Decyzja 9)")
help="Only envelopes with ts >= this date (for staged runs, plan Decyzja 9). "
"Never use it as an incremental cursor — ts is the sender's clock.")
parser.add_argument("--limit", type=int, default=None,
help="Max envelopes to process (default: all)")
parser.add_argument("--offset", type=int, default=0,
@ -772,6 +834,8 @@ def main() -> None:
limit=args.limit,
offset=args.offset,
apply=args.apply,
sources=args.sources,
only_unchunked=args.only_unchunked,
batch_size=args.batch_size,
max_embed_failures=args.max_embed_failures,
embed_retries=args.embed_retries,

View file

@ -304,19 +304,59 @@ class TestFetchHelpers:
assert "ORDER BY id" in query
assert "LIMIT" not in query and "OFFSET" not in query
async def test_fetch_envelopes_defaults_to_both_live_mail_sources(self):
# Decyzja (g): fastmail must be in the default set from the commit that creates the
# source, or its envelopes chunk correctly and stay invisible.
conn = _FakeConn(envelopes=[])
await fetch_envelopes(conn)
query, params = conn.queries[-1]
assert "source = ANY($1)" in query
assert params[0] == ["gmail", "fastmail"]
async def test_fetch_envelopes_with_since_limit_offset(self):
conn = _FakeConn(envelopes=[])
await fetch_envelopes(conn, since=datetime(2025, 7, 1, tzinfo=timezone.utc), limit=10, offset=5)
query, params = conn.queries[-1]
assert "ts >= $1" in query
assert "LIMIT $2" in query
assert "OFFSET $3" in query
assert params == (datetime(2025, 7, 1, tzinfo=timezone.utc), 10, 5)
assert "ts >= $2" in query
assert "LIMIT $3" in query
assert "OFFSET $4" in query
assert params == (["gmail", "fastmail"], datetime(2025, 7, 1, tzinfo=timezone.utc), 10, 5)
async def test_fetch_envelopes_explicit_sources_override_the_default(self):
conn = _FakeConn(envelopes=[])
await fetch_envelopes(conn, sources=("gmail",))
assert conn.queries[-1][1][0] == ["gmail"]
async def test_only_unchunked_adds_the_queue_predicate(self):
conn = _FakeConn(envelopes=[])
await fetch_envelopes(conn, only_unchunked=True)
query, _ = conn.queries[-1]
assert "NOT EXISTS" in query and "document_chunk c" in query
async def test_only_unchunked_is_off_by_default(self):
conn = _FakeConn(envelopes=[])
await fetch_envelopes(conn)
assert "NOT EXISTS" not in conn.queries[-1][0]
async def test_fetch_existing_chunk_keys(self):
conn = _FakeConn(existing_keys=[("m1@x", 0), ("m1@x", 1)])
keys = await fetch_existing_chunk_keys(conn, model="bge-m3")
assert keys == {("m1@x", 0), ("m1@x", 1)}
assert "envelope_id = ANY" not in conn.queries[-1][0]
async def test_fetch_existing_chunk_keys_narrows_to_the_working_set(self):
# Unbounded this reads all 389k chunk keys (~26 MB, ~1.0 s) every run — fine once for
# a backfill slice, wasteful every two hours on the cyclic path (recon §2.5 iv).
conn = _FakeConn(existing_keys=[("m1@x", 0)])
await fetch_existing_chunk_keys(conn, model="bge-m3", envelope_ids=["m1@x"])
query, params = conn.queries[-1]
assert "envelope_id = ANY($2)" in query
assert params == ("bge-m3", ["m1@x"])
async def test_fetch_existing_chunk_keys_short_circuits_on_an_empty_working_set(self):
conn = _FakeConn(existing_keys=[("m1@x", 0)])
assert await fetch_existing_chunk_keys(conn, model="bge-m3", envelope_ids=[]) == set()
assert conn.queries == []
def _env_row(envelope_id: str, subject: str, ts: datetime = datetime(2025, 8, 1, tzinfo=timezone.utc)):

View file

@ -0,0 +1,71 @@
# mail-imap-sync configuration — placeholders only. NEVER commit real values.
#
# On PIHA these keys are APPENDED to the existing /opt/homelab/kb/.env, which is already
# root-owned 0600 and already holds KB_DSN, PAPERLESS_API_TOKEN and ANTHROPIC_API_KEY
# (recon Decyzja (c)). systemd reads EnvironmentFile as root BEFORE dropping to
# User=oskar, so the secrets reach the process without being readable by `oskar` at rest —
# a better property than a 0600 file owned by the job's own user. Keep it that way.
#
# Rules, all learned the hard way in this repo:
# 1. Never on the command line. This job has no --user/--password flag on purpose;
# argv shows up in `ps` and in shell history.
# 2. Never echoed to a terminal. Two secrets have already had to be rotated after
# leaking into a session transcript (docs/sessions/2026-07-15.md,
# docs/sessions/2026-07-21.md). Install them with an editor, not `echo >>`.
# 3. App passwords, not account passwords — revocable one at a time, no access to
# account settings, no bypassing 2FA.
#
# Full setup, including how to mint the app passwords: kb/runbooks/mail-sync-run.md
# --- which mailboxes to sync -------------------------------------------------------
# Required. Comma-separated; each name is also the envelope.source value and the
# mail_sync_state.account key. Start with one account if the other is not set up yet.
MAIL_ACCOUNTS=gmail,fastmail
# --- gmail -------------------------------------------------------------------------
# App password requires 2FA on the account. Paste it WITHOUT the spaces Google displays.
MAIL_GMAIL_USER=you@gmail.com
MAIL_GMAIL_APP_PASSWORD=change-me-16-char-app-password
# Optional, defaults shown. The folder is chosen by SPECIAL-USE attribute, never by name:
# with a Polish UI the mailbox is called "[Gmail]/Wszystkie", and a hardcoded
# "[Gmail]/All Mail" would sync nothing while reporting success every hour.
# MAIL_GMAIL_HOST=imap.gmail.com
# MAIL_GMAIL_PORT=993
# MAIL_GMAIL_SPECIAL_USE=\All
# MAIL_GMAIL_FOLDERS=
#
# What the FIRST tick on a folder does — new-only | since | full. Ignored once a
# mail_sync_state row exists for that folder.
# new-only : start the corpus here, fetch nothing older.
# since : fetch everything the server RECEIVED on/after MAIL_*_INITIAL_SINCE.
# full : sweep the whole folder (dedup absorbs what is already in the DB).
# For gmail, 'since' is the point of the exercise: the corpus stops at 2026-06-19, so a
# 'new-only' first tick would leave that gap unfilled forever. Pick a date a few days
# BEFORE the last envelope in the DB — dedup makes the overlap free.
MAIL_GMAIL_INITIAL_MODE=since
MAIL_GMAIL_INITIAL_SINCE=2026-06-15
# --- fastmail ----------------------------------------------------------------------
# Fastmail app passwords can be scoped to IMAP only — use that, not a full-access one.
MAIL_FASTMAIL_USER=you@fastmail.com
MAIL_FASTMAIL_APP_PASSWORD=change-me-app-password
# Optional, defaults shown. Fastmail has no \All equivalent, so the scope is a literal
# list. Spam/Trash/Drafts are excluded on purpose (defined noise; drafts have no stable
# Message-ID and mutate).
# MAIL_FASTMAIL_HOST=imap.fastmail.com
# MAIL_FASTMAIL_PORT=993
# MAIL_FASTMAIL_FOLDERS=INBOX,Archive,Sent
#
# Fastmail is greenfield — zero rows, zero archive files. Whether to pull its history is
# a decision the recon deliberately leaves open until measured:
# mail-imap-sync --measure
# prints MESSAGES per folder. Decide 'new-only' vs 'full' from that number, then set it
# here BEFORE the first --apply run (after the first run the cursor exists and this key
# no longer has any effect).
MAIL_FASTMAIL_INITIAL_MODE=new-only
# --- shared ------------------------------------------------------------------------
# Already present in /opt/homelab/kb/.env on PIHA — listed here for completeness.
# KB_DSN=postgresql://kb:CHANGE-ME@localhost:5433/kb
# MAIL_ARCHIVE_ROOT=/home/oskar/kb/mail/archive
# KB_MAIL_SYNC_PROM_PATH=/opt/homelab/state/node-exporter/kb-mail-sync.prom

View file

@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "mail-imap-sync"
version = "0.1.0"
requires-python = ">=3.11"
# No IMAP dependency: imaplib is stdlib (PIHA runs 3.11.2). Recon Decyzja (a) — a synchronous
# stdlib client is the right size for ~37 messages a day, and adds nothing to keep patched.
dependencies = [
"asyncpg>=0.29",
"structlog>=24.1",
"kb-mail",
]
[project.optional-dependencies]
dev = [
"pytest>=8.1",
"pytest-asyncio>=0.23",
]
[project.scripts]
mail-imap-sync = "mail_imap_sync.sync:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View file

@ -0,0 +1,141 @@
"""Account configuration from the environment — and only from the environment.
Decyzja (c) of `kb/audits/mail-sync-2026-08-06.md`: credentials come from
`/opt/homelab/kb/.env` (root-owned `600`, read by systemd as root before dropping to
`User=oskar`), never from argv. This job therefore has no `--password` flag and no
`--user` flag at all `--dsn <secret>` lands in `ps` output and in shell history, and the
repo has already had to rotate two secrets that leaked into a session transcript.
Defaults encode the approved folder scope (Decyzja (e)) so a working config is short:
gmail -> SPECIAL-USE \\All (one fetch per message despite many labels;
same population as the Takeout the corpus rests on)
fastmail -> INBOX + Archive + Sent (no \\All equivalent in a classic layout)
Spam, Trash and Drafts are excluded from both: spam and trash are defined noise and were not
in the Takeout either, and drafts have no stable Message-ID they mutate.
"""
from __future__ import annotations
import os
from datetime import date, datetime
from pathlib import Path
from typing import Mapping, Optional
from kb_mail.imap import DEFAULT_PORT, INITIAL_MODES, ImapAccount
DEFAULT_ARCHIVE_ROOT = Path("/home/oskar/kb/mail/archive")
# Only a default. `MAIL_<ACCOUNT>_HOST` overrides it, and an account not listed here simply
# has to set its host — nothing in the code special-cases a provider.
KNOWN_HOSTS = {
"gmail": "imap.gmail.com",
"fastmail": "imap.fastmail.com",
}
DEFAULT_SCOPE = {
"gmail": {"special_use": ("\\All",), "folders": ()},
"fastmail": {"special_use": (), "folders": ("INBOX", "Archive", "Sent")},
}
class ConfigError(RuntimeError):
"""A missing or malformed setting. Always fatal at startup: an unattended hourly job
that silently syncs a subset of what the operator configured is worse than one that
refuses to start."""
def _csv(value: Optional[str]) -> Optional[tuple[str, ...]]:
"""Comma-separated list. An explicitly EMPTY value means "none" (a real override);
an unset value means "no opinion" and returns None so a default can win."""
if value is None:
return None
return tuple(part.strip() for part in value.split(",") if part.strip())
def _parse_date(value: str, key: str) -> date:
try:
return datetime.strptime(value.strip(), "%Y-%m-%d").date()
except ValueError:
raise ConfigError(f"{key}={value!r} is not a date (expected YYYY-MM-DD)")
def account_from_env(name: str, env: Mapping[str, str]) -> ImapAccount:
"""Build one account from `MAIL_<NAME>_*` keys. `name` doubles as `envelope.source`."""
prefix = f"MAIL_{name.upper().replace('-', '_')}_"
def get(key: str) -> Optional[str]:
return env.get(prefix + key)
user = get("USER")
password = get("APP_PASSWORD")
if not user:
raise ConfigError(f"{prefix}USER is not set")
if not password:
raise ConfigError(f"{prefix}APP_PASSWORD is not set")
host = get("HOST") or KNOWN_HOSTS.get(name)
if not host:
raise ConfigError(f"{prefix}HOST is not set and {name!r} has no known default")
port_raw = get("PORT")
try:
port = int(port_raw) if port_raw else DEFAULT_PORT
except ValueError:
raise ConfigError(f"{prefix}PORT={port_raw!r} is not an integer")
scope = DEFAULT_SCOPE.get(name, {"special_use": (), "folders": ()})
special_use = _csv(get("SPECIAL_USE"))
folders = _csv(get("FOLDERS"))
initial_mode = (get("INITIAL_MODE") or "new-only").strip()
if initial_mode not in INITIAL_MODES:
raise ConfigError(
f"{prefix}INITIAL_MODE={initial_mode!r} must be one of {list(INITIAL_MODES)}"
)
since_raw = get("INITIAL_SINCE")
initial_since = _parse_date(since_raw, prefix + "INITIAL_SINCE") if since_raw else None
try:
return ImapAccount(
name=name,
host=host,
port=port,
user=user,
password=password,
special_use=special_use if special_use is not None else scope["special_use"],
folders=folders if folders is not None else scope["folders"],
initial_mode=initial_mode,
initial_since=initial_since,
)
except ValueError as exc:
raise ConfigError(str(exc))
def accounts_from_env(env: Optional[Mapping[str, str]] = None) -> list[ImapAccount]:
"""Every account named in `MAIL_ACCOUNTS`, in the order listed.
`MAIL_ACCOUNTS` is required rather than defaulted to "gmail,fastmail": which mailboxes
are live is a fact about the deployment, and inferring it from which credentials happen
to be present would turn a typo'd variable name into a silently half-synced corpus.
"""
env = env if env is not None else os.environ
names = _csv(env.get("MAIL_ACCOUNTS"))
if not names:
raise ConfigError(
"MAIL_ACCOUNTS is not set — list the accounts to sync, e.g. 'gmail,fastmail'"
)
seen: set[str] = set()
accounts = []
for name in names:
if name in seen:
raise ConfigError(f"MAIL_ACCOUNTS lists {name!r} twice")
seen.add(name)
accounts.append(account_from_env(name, env))
return accounts
def archive_root_from_env(env: Optional[Mapping[str, str]] = None) -> Path:
env = env if env is not None else os.environ
return Path(env.get("MAIL_ARCHIVE_ROOT") or DEFAULT_ARCHIVE_ROOT)

View file

@ -0,0 +1,102 @@
"""Prometheus textfile-collector output for this job.
Same delivery path as `kb-ingest` (recon §3.4, no new moving parts): the job writes a `.prom`
file atomically into `/opt/homelab/state/node-exporter/`, node_exporter@PIHA picks it up
through its existing `/:/host:ro` mount, and brain-watchdog polls Prometheus' `/api/v1/alerts`
for Telegram delivery. No Alertmanager, deliberately.
`documents_ingest.cyclic_ingest` has an equivalent unlabelled renderer. It is not imported
here: this job must not depend on the documents-ingest package (aiohttp, anthropic, a
paperless client) to write eight numbers, and these metrics are per-account labelled, which
that renderer cannot express. Folding both into one shared helper is worth doing the next
time either changes noted in kb/services/job-mail-imap-sync.md.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
DEFAULT_PROM_PATH = Path("/opt/homelab/state/node-exporter/kb-mail-sync.prom")
@dataclass(frozen=True)
class Sample:
value: float
labels: dict = field(default_factory=dict)
@dataclass(frozen=True)
class MetricFamily:
name: str
type: str
help: str
samples: list = field(default_factory=list)
def _escape_label(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def _format_value(value: float) -> str:
"""Integers render without a trailing `.0` — Prometheus does not care, humans reading
`cat kb-mail-sync.prom` during a first run do."""
if isinstance(value, bool):
return str(int(value))
if isinstance(value, int) or (isinstance(value, float) and value.is_integer()):
return str(int(value))
return repr(float(value))
def render_prom(families: list[MetricFamily]) -> str:
lines: list[str] = []
for family in families:
if not family.samples:
# A family with no samples is omitted rather than zeroed: "no accounts reported"
# and "accounts reported zero" are different facts and must not look the same.
continue
lines.append(f"# HELP {family.name} {family.help}")
lines.append(f"# TYPE {family.name} {family.type}")
for sample in family.samples:
if sample.labels:
rendered = ",".join(
f'{k}="{_escape_label(str(v))}"' for k, v in sorted(sample.labels.items())
)
lines.append(f"{family.name}{{{rendered}}} {_format_value(sample.value)}")
else:
lines.append(f"{family.name} {_format_value(sample.value)}")
return "\n".join(lines) + "\n"
def read_prev_value(path: Path, name: str) -> Optional[float]:
"""Previous value of an unlabelled metric, or None.
Used to carry `kb_mail_sync_last_success_timestamp` forward across a failed run the
staleness signal must measure "time since the last good tick", not get reset to zero by
the first transient error.
"""
if not path.exists():
return None
try:
text = path.read_text()
except OSError:
return None
pattern = re.compile(rf"^{re.escape(name)}\s+([0-9.eE+-]+)\s*$")
for line in text.splitlines():
match = pattern.match(line)
if match:
try:
return float(match.group(1))
except ValueError:
return None
return None
def write_prom_atomic(path: Path, content: str) -> None:
"""tmp-write + rename, so the textfile collector never reads a half-written file."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.tmp{os.getpid()}")
tmp_path.write_text(content)
tmp_path.replace(path)

View file

@ -0,0 +1,593 @@
"""IMAP incremental sync — one job, two accounts (gmail + fastmail).
Krok 7 of the mail phase (`kb/phases/kb-m5-faza-mailowa.md` §10), built to the approved recon
`kb/audits/mail-sync-2026-08-06.md`. Until this job existed, the mail corpus was a photograph:
225 030 envelopes ending on 2026-06-19, with nothing in the repo that talks to a mail server.
What one tick does, per account and per folder:
EXAMINE folder (read-only) -> UIDVALIDITY, UIDNEXT
plan the tick -> first contact / incremental / UIDVALIDITY reset
UID SEARCH -> the UIDs this tick owns
per UID, in ascending order:
UID FETCH (BODY.PEEK[]) -> raw bytes, \\Seen untouched
save_eml() -> append-only; FileExistsError just means "already have it"
insert_envelope() -> entities = [headers, attachment...], ON CONFLICT DO NOTHING
UPDATE mail_sync_state -> only over the contiguous prefix of fully durable messages
**This job does not embed and does not chunk.** That split is deliberate (recon §3.2):
fetching is network-bound and runs hourly on PIHA, which is up 24/7; chunking and embedding
need Ollama on SOLARIA, which is powered off ~16 h a day by design. Coupling them would mean
mail only arrives when the desktop happens to be on. The handoff needs no queue of its own
an envelope with no `document_chunk` rows *is* the queue, which `mail-body-ingest
--only-unchunked` drains whenever the GPU is reachable. That is self-healing in a way a
timestamp cursor is not: an interrupted run, a mail inserted with a backdated header, a
message skipped while Ollama was down all of them simply stay in the queue.
Two things this job inserts that the bulk importer did not, both from recon §2.5:
* `entities[type=headers]` at INSERT time. Historical envelopes only have headers because a
separate backfill job walked all 225 030 of them later; without them
`mail_body_ingest.build_prefix` silently produces `Temat: (brak tematu) | Od: ?` no error,
just worse retrieval forever.
* A count of Message-ID collisions ACROSS accounts. `envelope.id` is a bare Message-ID and so
is globally unique: a mail present in both mailboxes is stored once, under whichever account
inserted it first. That is right for the index, but it makes "new fastmail mails" read low,
so the phenomenon is counted (`envelopes_conflict_other_source`) rather than left invisible.
Credentials come from the environment only (Decyzja (c)) there is no `--password` flag.
"""
from __future__ import annotations
import argparse
import asyncio
import email
import email.policy
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Optional
import asyncpg
import structlog
from kb_mail.archive import save_eml
from kb_mail.db import envelope_source, insert_envelope, rows_affected
from kb_mail.envelope import Envelope
from kb_mail.headers import parse_headers_resilient
from kb_mail.imap import ImapAccount, ImapClient, ImapError
from kb_mail.message import eml_ref, message_id, parse_attachments, parse_date
from kb_mail.sync_state import (
SEARCH_ALL,
SEARCH_FROM_UID,
SEARCH_NONE,
SEARCH_SINCE,
contiguous_last_uid,
get_folder_state,
plan_folder_sync,
upsert_folder_state,
)
from mail_imap_sync.config import DEFAULT_ARCHIVE_ROOT, ConfigError, accounts_from_env
from mail_imap_sync.prom import (
DEFAULT_PROM_PATH,
MetricFamily,
Sample,
read_prev_value,
render_prom,
write_prom_atomic,
)
_log = structlog.get_logger(__name__)
EXIT_CONFIG_ERROR = 2
_ACCOUNT_COUNTERS = (
# Message-level outcomes — these three groups must balance (see `balance_errors`).
"uids_seen",
"processed",
"vanished",
"errors",
"archived",
"archive_exists",
"envelopes_inserted",
"envelopes_skipped_dup",
"envelopes_conflict_other_source",
# Labels and diagnostics — deliberately outside the balance equations.
"headers_fallback",
"uidvalidity_resets",
"folder_errors",
"account_errors",
)
def _new_counters() -> dict:
return {name: 0 for name in _ACCOUNT_COUNTERS}
def _add(into: dict, other: dict) -> None:
for key, value in other.items():
into[key] = into.get(key, 0) + value
def total_errors(counters: dict) -> int:
"""Message-, folder- and account-level failures together. Kept separate in the counters
because only the message-level one participates in the balance equations a folder that
blew up on EXAMINE never produced a UID to account for."""
return counters["errors"] + counters["folder_errors"] + counters["account_errors"]
def balance_errors(counters: dict) -> list[str]:
"""The three identities an apply run must satisfy. Returned rather than logged so both the
summary line and the exit code can use them a run whose counters do not add up has not
told the truth about what it did, whatever else it reports."""
problems = []
if counters["uids_seen"] != counters["processed"] + counters["vanished"] + counters["errors"]:
problems.append("uids_seen != processed + vanished + errors")
if counters["processed"] != counters["archived"] + counters["archive_exists"]:
problems.append("processed != archived + archive_exists")
envelope_outcomes = (
counters["envelopes_inserted"]
+ counters["envelopes_skipped_dup"]
+ counters["envelopes_conflict_other_source"]
)
if counters["processed"] != envelope_outcomes:
problems.append("processed != inserted + skipped_dup + conflict_other_source")
return problems
def _parse_message(raw: bytes):
"""compat32, matching `gmail-bulk-import`'s mailbox parse exactly.
Not a stylistic choice: `message_id()` runs over this object, and the ids it produces must
be byte-identical to the 225 030 already in the table or dedup quietly stops working.
"""
return email.message_from_bytes(raw, policy=email.policy.compat32)
async def _sync_folder(
conn: asyncpg.Connection,
client: ImapClient,
account: ImapAccount,
folder: str,
archive_root: Path,
apply: bool,
limit: Optional[int],
) -> dict:
"""One folder, one tick. Returns a per-folder report; raises only on protocol failures
that make the folder's state meaningless (those are caught one level up, per account)."""
counters = _new_counters()
status = await asyncio.to_thread(client.examine, folder)
state = await get_folder_state(conn, account.name, folder)
plan = plan_folder_sync(account, status, state)
if plan.uidvalidity_reset:
counters["uidvalidity_resets"] += 1
if plan.search == SEARCH_NONE:
uids: list[int] = []
elif plan.search == SEARCH_ALL:
uids = await asyncio.to_thread(client.search_all)
elif plan.search == SEARCH_SINCE:
uids = await asyncio.to_thread(client.search_since, plan.since)
elif plan.search == SEARCH_FROM_UID:
uids = await asyncio.to_thread(client.search_from_uid, plan.start_uid)
else: # pragma: no cover — plan_folder_sync only emits the four above
raise ImapError(f"unknown search plan {plan.search!r}")
truncated = False
if limit is not None and len(uids) > limit:
# Take the LOWEST UIDs: the cursor then advances from the oldest end and the rest is
# picked up by the next tick. Taking the newest would strand the older ones behind a
# cursor that had already moved past them.
uids = uids[:limit]
truncated = True
report = {
"account": account.name,
"folder": folder,
"mode": plan.mode,
"uidvalidity": status.uidvalidity,
"uidnext": status.uidnext,
"state_last_uid": state.last_uid if state else None,
"candidates": len(uids),
"truncated": truncated,
}
if not apply:
# Dry run stops at the plan: EXAMINE + SEARCH answer "what would this tick do" without
# pulling a single body off the server or writing a byte. `candidates` is the whole
# answer, so no message-level counter moves and the balance check is skipped upstream.
report["counters"] = counters
report["new_last_uid"] = None
_log.info("folder.planned", **report)
return report
succeeded: set[int] = set()
for uid in uids:
counters["uids_seen"] += 1
try:
raw = await asyncio.to_thread(client.fetch_message, uid)
except Exception:
_log.warning("skip.fetch_error", account=account.name, folder=folder,
uid=uid, exc_info=True)
counters["errors"] += 1
continue
if raw is None:
# Expunged between SEARCH and FETCH. Ordinary on a live mailbox, and the UID is
# gone for good, so it counts as handled and does not block the cursor.
_log.info("skip.vanished", account=account.name, folder=folder, uid=uid)
counters["vanished"] += 1
succeeded.add(uid)
continue
try:
envelope_id, inserted_new = await _store_message(
conn, account, archive_root, raw, counters
)
except Exception:
_log.warning("skip.store_error", account=account.name, folder=folder,
uid=uid, exc_info=True)
counters["errors"] += 1
continue
succeeded.add(uid)
_log.debug("message.stored", account=account.name, folder=folder, uid=uid,
envelope_id=envelope_id, new=inserted_new)
if not uids:
# Nothing to fetch: adopt the plan's high-water mark (or keep the cursor for an
# incremental tick, where baseline_uid IS the stored cursor).
new_last_uid = plan.baseline_uid
else:
# A floor of 0 for any sweep-shaped plan, deliberately NOT plan.baseline_uid: on a
# full sweep that equals uidnext-1, and using it here would step the cursor straight
# over every message that failed.
floor = plan.start_uid - 1 if plan.search == SEARCH_FROM_UID else 0
new_last_uid = contiguous_last_uid(floor, uids, succeeded)
await upsert_folder_state(conn, account.name, folder, status.uidvalidity, new_last_uid)
report["counters"] = counters
report["new_last_uid"] = new_last_uid
report["stalled"] = bool(uids) and len(succeeded) != len(uids)
_log.info("folder.synced", **{k: v for k, v in report.items() if k != "counters"},
**counters)
return report
async def _store_message(
conn: asyncpg.Connection,
account: ImapAccount,
archive_root: Path,
raw: bytes,
counters: dict,
) -> tuple[str, bool]:
"""Archive then insert — in that order, and only then may the cursor move past this UID.
An exception anywhere here leaves the cursor where it was, so the next tick refetches this
message: the archive write is append-only and the insert is `ON CONFLICT DO NOTHING`, so
the repeat costs one `FileExistsError` and one no-op statement.
"""
msg = _parse_message(raw)
envelope_id = message_id(msg)
ts = parse_date(msg)
headers_entity, used_fallback = parse_headers_resilient(raw)
if used_fallback:
counters["headers_fallback"] += 1
entities = [headers_entity] + parse_attachments(msg)
try:
raw_ref = await save_eml(archive_root, envelope_id, account.name, ts, raw)
counters["archived"] += 1
except FileExistsError:
raw_ref = eml_ref(envelope_id, account.name, ts)
counters["archive_exists"] += 1
envelope = Envelope(id=envelope_id, source=account.name, ts=ts,
raw_ref=raw_ref, entities=entities)
tag = await insert_envelope(conn, envelope)
counters["processed"] += 1
if rows_affected(tag) == 1:
counters["envelopes_inserted"] += 1
return envelope_id, True
existing = await envelope_source(conn, envelope_id)
if existing is not None and existing != account.name:
_log.info("envelope.conflict_other_source", envelope_id=envelope_id,
account=account.name, existing_source=existing)
counters["envelopes_conflict_other_source"] += 1
else:
counters["envelopes_skipped_dup"] += 1
return envelope_id, False
async def _sync_account(
conn: asyncpg.Connection,
account: ImapAccount,
archive_root: Path,
apply: bool,
limit: Optional[int],
client_factory: Callable[[ImapAccount], ImapClient],
) -> dict:
"""All of one account's folders. Failures are contained here: a Gmail login that stopped
working must not also stop Fastmail from syncing (same isolation as `cyclic_ingest`)."""
counters = _new_counters()
folders_report: list[dict] = []
error: Optional[str] = None
client = client_factory(account)
try:
await asyncio.to_thread(client.connect)
folders = await asyncio.to_thread(client.resolve_folders)
for folder in folders:
try:
report = await _sync_folder(
conn, client, account, folder, archive_root, apply, limit
)
folders_report.append(report)
_add(counters, report["counters"])
except Exception as exc:
_log.error("folder_failed", account=account.name, folder=folder,
error=str(exc), exc_info=True)
counters["folder_errors"] += 1
folders_report.append({"account": account.name, "folder": folder,
"error": str(exc)})
except Exception as exc:
_log.error("account_failed", account=account.name, error=str(exc), exc_info=True)
error = str(exc)
counters["account_errors"] += 1
finally:
await asyncio.to_thread(client.logout)
return {
"account": account.name,
"counters": counters,
"folders": folders_report,
"error": error,
}
async def fetch_last_message_ts(
conn: asyncpg.Connection, accounts: list[str]
) -> dict[str, float]:
"""`max(envelope.ts)` per account — the freshness gauge from recon §3.4.
Purely observational, so a failure here degrades to "metric absent" rather than failing
the run: the DB being down is already loudly reported by every other counter.
"""
try:
rows = await conn.fetch(
"SELECT source, max(ts) AS newest FROM envelope "
"WHERE source = ANY($1) GROUP BY source",
accounts,
)
except Exception:
_log.warning("last_message_ts_failed", exc_info=True)
return {}
return {
r["source"]: r["newest"].timestamp()
for r in rows
if r["newest"] is not None
}
async def run(
dsn: str,
accounts: list[ImapAccount],
archive_root: Path = DEFAULT_ARCHIVE_ROOT,
apply: bool = False,
limit: Optional[int] = None,
client_factory: Optional[Callable[[ImapAccount], ImapClient]] = None,
) -> dict:
"""One tick over every configured account.
dry-run (`apply=False`) plans and counts: it connects, resolves folders, reads
UIDVALIDITY/UIDNEXT and runs the SEARCH, then stops no FETCH, no archive write, no DB
write, no state update. That is enough to answer "how much would the first real run pull",
which is exactly the question before a first sync.
"""
factory = client_factory or (lambda account: ImapClient(account))
totals = _new_counters()
per_account: dict[str, dict] = {}
reports: list[dict] = []
conn = await asyncpg.connect(dsn)
try:
for account in accounts:
report = await _sync_account(conn, account, archive_root, apply, limit, factory)
reports.append(report)
per_account[account.name] = report["counters"]
_add(totals, report["counters"])
last_message_ts = await fetch_last_message_ts(conn, [a.name for a in accounts])
finally:
await conn.close()
# Only an apply run has message-level outcomes to balance; a dry run never fetched.
problems = balance_errors(totals) if apply else []
if problems:
_log.error("stats_unbalanced", problems=problems, **totals)
failed = total_errors(totals) > 0 or bool(problems)
result = {
"totals": totals,
"accounts": per_account,
"reports": reports,
"last_message_ts": last_message_ts,
"balance_problems": problems,
"failed": failed,
}
_log.info("run_complete", apply=apply, failed=failed, **totals)
return result
async def measure(
accounts: list[ImapAccount],
client_factory: Optional[Callable[[ImapAccount], ImapClient]] = None,
) -> list[dict]:
"""`STATUS (MESSAGES UIDNEXT UIDVALIDITY)` for every configured folder. Read-only, no DB.
This is the measurement Decyzja (e) leaves open: whether to pull Fastmail's history is a
decision about a number nobody has yet, and the recon explicitly refuses to guess it.
Run this first, then choose `initial_mode` from what it prints.
"""
factory = client_factory or (lambda account: ImapClient(account))
rows: list[dict] = []
for account in accounts:
client = factory(account)
try:
await asyncio.to_thread(client.connect)
for folder in await asyncio.to_thread(client.resolve_folders):
status = await asyncio.to_thread(client.status, folder)
row = {
"account": account.name,
"folder": folder,
"messages": status.messages,
"uidnext": status.uidnext,
"uidvalidity": status.uidvalidity,
}
rows.append(row)
_log.info("folder.measured", **row)
except Exception as exc:
_log.error("measure_failed", account=account.name, error=str(exc), exc_info=True)
rows.append({"account": account.name, "error": str(exc)})
finally:
await asyncio.to_thread(client.logout)
return rows
def build_metrics(result: dict, now_ts: float, prom_path: Path) -> list[MetricFamily]:
"""`run()` result -> the metric families of recon §3.4.
`kb_mail_sync_last_success_timestamp` carries the previous file's value forward on a
failed run. Resetting it to 0 (or to now) would make the staleness alert either fire
forever after one blip or never fire at all.
"""
failed = result["failed"]
prev_success = read_prev_value(prom_path, "kb_mail_sync_last_success_timestamp")
last_success = now_ts if not failed else (prev_success if prev_success is not None else 0.0)
def per_account(counter: str) -> list[Sample]:
return [
Sample(value=counters[counter], labels={"account": name})
for name, counters in sorted(result["accounts"].items())
]
return [
MetricFamily("kb_mail_sync_last_run_timestamp", "gauge",
"Unix timestamp of the last kb-mail-sync run (success or failure).",
[Sample(now_ts)]),
MetricFamily("kb_mail_sync_last_success_timestamp", "gauge",
"Unix timestamp of the last kb-mail-sync run with no hard failure.",
[Sample(last_success)]),
MetricFamily("kb_mail_sync_last_exit_code", "gauge",
"Exit code of the last kb-mail-sync run.",
[Sample(1 if failed else 0)]),
MetricFamily("kb_mail_sync_envelopes_inserted", "gauge",
"New envelope rows inserted in the last run, per account.",
per_account("envelopes_inserted")),
MetricFamily("kb_mail_sync_envelopes_skipped_dup", "gauge",
"Messages already present under the same account (Message-ID dedup).",
per_account("envelopes_skipped_dup")),
MetricFamily("kb_mail_sync_conflict_other_source", "gauge",
"Messages whose Message-ID already belongs to a DIFFERENT account — "
"stored once, counted here so the overlap is visible.",
per_account("envelopes_conflict_other_source")),
MetricFamily("kb_mail_sync_uidvalidity_resets", "gauge",
"Folders whose UIDVALIDITY changed in the last run, forcing a full sweep.",
per_account("uidvalidity_resets")),
MetricFamily("kb_mail_sync_errors", "gauge",
"Messages, folders and accounts that failed in the last run.",
[Sample(value=total_errors(counters), labels={"account": name})
for name, counters in sorted(result["accounts"].items())]),
MetricFamily("kb_mail_sync_last_message_ts", "gauge",
"Timestamp of the newest envelope in the DB, per account.",
[Sample(value=ts, labels={"account": name})
for name, ts in sorted(result["last_message_ts"].items())]),
]
def main() -> None:
parser = argparse.ArgumentParser(
description="Incremental IMAP sync for the KB mail corpus (gmail + fastmail). "
"Credentials come from the environment only — see jobs/mail-imap-sync/env.example."
)
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"),
help="asyncpg DSN for kb-postgres (or set KB_DSN env var)")
parser.add_argument("--archive-root", type=Path,
default=Path(os.environ.get("MAIL_ARCHIVE_ROOT", str(DEFAULT_ARCHIVE_ROOT))),
help=f"Mail .eml archive root (default: {DEFAULT_ARCHIVE_ROOT})")
parser.add_argument("--prom-path", type=Path,
default=Path(os.environ.get("KB_MAIL_SYNC_PROM_PATH", str(DEFAULT_PROM_PATH))),
help=f"Textfile-collector output path (default: {DEFAULT_PROM_PATH})")
parser.add_argument("--limit", type=int, default=None, metavar="N",
help="Process at most N messages per folder per tick — a brake for "
"the first big sweep; the remainder is picked up next tick")
parser.add_argument("--measure", action="store_true",
help="Print STATUS (MESSAGES/UIDNEXT/UIDVALIDITY) per folder and exit. "
"Read-only, touches neither the DB nor the archive.")
parser.add_argument("--apply", action="store_true",
help="Actually fetch, archive, insert and advance the sync cursor. "
"Default is dry-run: plan + SEARCH counts only.")
args = parser.parse_args()
try:
accounts = accounts_from_env()
except ConfigError as exc:
_log.error("config_error", error=str(exc))
sys.exit(EXIT_CONFIG_ERROR)
if args.measure:
rows = asyncio.run(measure(accounts))
_log.info("measure_complete", folders=rows)
sys.exit(1 if any("error" in r for r in rows) else 0)
if not args.dsn:
_log.error("missing_dsn", hint="pass --dsn or set KB_DSN")
sys.exit(EXIT_CONFIG_ERROR)
if args.apply and not args.archive_root.is_dir():
_log.error("archive_root_not_found", path=str(args.archive_root))
sys.exit(EXIT_CONFIG_ERROR)
result = asyncio.run(
run(
dsn=args.dsn,
accounts=accounts,
archive_root=args.archive_root,
apply=args.apply,
limit=args.limit,
)
)
if args.apply:
# Only a real run publishes metrics: a dry-run must not touch the success clock the
# staleness alert reads.
now_ts = time.time()
write_prom_atomic(args.prom_path, render_prom(build_metrics(result, now_ts, args.prom_path)))
_log.info(
"summary",
mode="APPLY" if args.apply else "DRY-RUN",
accounts={name: counters for name, counters in result["accounts"].items()},
last_message=_readable_last_message(result["last_message_ts"]),
balance_problems=result["balance_problems"],
**result["totals"],
)
sys.exit(1 if result["failed"] else 0)
def _readable_last_message(last_message_ts: dict[str, float]) -> dict[str, str]:
return {
name: datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
for name, ts in sorted(last_message_ts.items())
}
if __name__ == "__main__":
main()

View file

@ -0,0 +1,199 @@
"""Fakes shared by the job's tests — no network, no DB, no live mailbox.
The IMAP fake sits at the `imaplib` level rather than at `ImapClient`, so every test runs the
adapter's REAL protocol parsing (LIST attributes, EXAMINE untagged responses, `n:*` range
trailers, `(prefix, literal)` FETCH tuples). The DB fake models the two tables the job touches
closely enough that dedup, cross-account collisions and cursor persistence are exercised as
behaviour rather than asserted as SQL strings.
"""
from __future__ import annotations
from datetime import datetime, timezone
class FakeIMAP:
"""Multi-folder `imaplib.IMAP4_SSL` stand-in.
`folders` maps mailbox name -> {uid: raw bytes}. `uidvalidity` may be a per-folder dict so
a test can flip one folder's value to simulate a server-side renumbering.
"""
def __init__(self, folders, *, uidvalidity=42, list_attrs=None, login_error=None,
fetch_errors=(), vanished=()):
self.folders = folders
self._uidvalidity = uidvalidity
self.list_attrs = list_attrs or {}
self.login_error = login_error
self.fetch_errors = set(fetch_errors)
self.vanished = set(vanished)
self.commands: list[tuple] = []
self.selected = None
self.logged_out = False
# -- helpers -----------------------------------------------------------------
def uidvalidity_of(self, folder: str) -> int:
if isinstance(self._uidvalidity, dict):
return self._uidvalidity.get(folder, 42)
return self._uidvalidity
def uidnext_of(self, folder: str) -> int:
uids = self.folders.get(folder) or {}
return (max(uids) + 1) if uids else 1
def fetch_commands(self):
return [c for c in self.commands if c[:2] == ("UID", "FETCH")]
# -- imaplib surface ---------------------------------------------------------
def login(self, user, password):
self.commands.append(("LOGIN", user))
if self.login_error:
raise RuntimeError(self.login_error)
return ("OK", [b"ok"])
def logout(self):
self.logged_out = True
return ("BYE", [b"bye"])
def list(self, directory='""', pattern="*"):
self.commands.append(("LIST",))
lines = []
for name in self.folders:
attrs = " ".join(self.list_attrs.get(name, ["\\HasNoChildren"]))
lines.append(f'({attrs}) "/" "{name}"'.encode())
return ("OK", lines)
def select(self, mailbox, readonly=False):
name = mailbox.strip('"')
self.commands.append(("SELECT", name, readonly))
if name not in self.folders:
return ("NO", [b"no such mailbox"])
self.selected = name
return ("OK", [str(len(self.folders[name])).encode()])
def status(self, mailbox, names):
name = mailbox.strip('"')
self.commands.append(("STATUS", name, names))
line = (
f'"{name}" (MESSAGES {len(self.folders.get(name) or {})} '
f"UIDNEXT {self.uidnext_of(name)} UIDVALIDITY {self.uidvalidity_of(name)})"
).encode()
return ("OK", [line])
def response(self, key):
if self.selected is None:
return (key, [None])
if key == "UIDVALIDITY":
return (key, [str(self.uidvalidity_of(self.selected)).encode()])
if key == "UIDNEXT":
return (key, [str(self.uidnext_of(self.selected)).encode()])
return (key, [None])
def uid(self, command, *args):
self.commands.append(("UID", command) + args)
uids = sorted(self.folders.get(self.selected) or {})
if command == "SEARCH":
criteria = args[1:]
if criteria and criteria[0] == "UID":
start = int(criteria[1].split(":")[0])
hits = [u for u in uids if u >= start]
if not hits and uids:
hits = [uids[-1]] # real servers do this; the client must filter it out
elif criteria and criteria[0] == "SINCE":
hits = [u for u in uids if u >= getattr(self, "since_uid_floor", 0)]
else:
hits = uids
return ("OK", [" ".join(str(u) for u in hits).encode()])
if command == "FETCH":
uid = int(args[0])
if uid in self.fetch_errors:
raise RuntimeError(f"fetch of {uid} exploded")
if uid in self.vanished:
return ("OK", [None])
raw = (self.folders.get(self.selected) or {}).get(uid)
if raw is None:
return ("OK", [None])
return ("OK", [(f"1 (UID {uid} BODY[] {{{len(raw)}}}".encode(), raw), b")"])
raise AssertionError(f"unexpected UID command {command}")
class FakeConn:
"""In-memory `envelope` + `mail_sync_state`, dispatched on SQL keywords."""
def __init__(self, envelopes=None, state=None, insert_errors=()):
self.envelopes = dict(envelopes or {}) # id -> {"source", "ts", "entities"}
self.state = dict(state or {}) # (account, folder) -> (uidvalidity, last_uid)
self.insert_errors = set(insert_errors) # envelope ids whose INSERT raises
self.queries: list[tuple] = []
self.closed = False
async def fetchrow(self, query, *params):
self.queries.append((query, params))
if "FROM mail_sync_state" in query:
row = self.state.get((params[0], params[1]))
if row is None:
return None
return {"account": params[0], "folder": params[1], "uidvalidity": row[0],
"last_uid": row[1], "last_sync_ts": None}
raise AssertionError(f"unexpected fetchrow: {query}")
async def fetchval(self, query, *params):
self.queries.append((query, params))
if "SELECT source FROM envelope" in query:
row = self.envelopes.get(params[0])
return row["source"] if row else None
raise AssertionError(f"unexpected fetchval: {query}")
async def fetch(self, query, *params):
self.queries.append((query, params))
if "max(ts)" in query:
newest: dict = {}
for row in self.envelopes.values():
if row["source"] in params[0]:
current = newest.get(row["source"])
if current is None or row["ts"] > current:
newest[row["source"]] = row["ts"]
return [{"source": s, "newest": ts} for s, ts in newest.items()]
if "FROM mail_sync_state" in query:
return [
{"account": a, "folder": f, "uidvalidity": v[0], "last_uid": v[1],
"last_sync_ts": None}
for (a, f), v in sorted(self.state.items())
]
raise AssertionError(f"unexpected fetch: {query}")
async def execute(self, query, *params):
self.queries.append((query, params))
if "INSERT INTO envelope" in query:
envelope_id = params[0]
if envelope_id in self.insert_errors:
raise RuntimeError(f"insert of {envelope_id} exploded")
if envelope_id in self.envelopes:
return "INSERT 0 0"
self.envelopes[envelope_id] = {"source": params[1], "ts": params[2],
"entities": params[5]}
return "INSERT 0 1"
if "INSERT INTO mail_sync_state" in query:
self.state[(params[0], params[1])] = (params[2], params[3])
return "INSERT 0 1"
raise AssertionError(f"unexpected execute: {query}")
async def close(self):
self.closed = True
def eml(message_id: str, *, subject="Temat", sender="Alice <a@example.com>",
date="Mon, 15 Jun 2026 14:00:00 +0200", body="tresc wiadomosci") -> bytes:
return (
f"Message-ID: <{message_id}>\r\n"
f"From: {sender}\r\n"
f"To: Oskar <o@example.com>\r\n"
f"Subject: {subject}\r\n"
f"Date: {date}\r\n"
f"Content-Type: text/plain; charset=utf-8\r\n"
f"\r\n{body}\r\n"
).encode("utf-8")
JUNE_15 = datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc)

View file

@ -0,0 +1,139 @@
"""Tests for env-only account configuration.
Two properties matter more than the parsing details: a malformed or missing setting is a
startup failure rather than a quiet half-configuration, and the approved folder scope
(Decyzja (e)) is the default so a working config is short.
"""
from __future__ import annotations
from datetime import date
import pytest
from mail_imap_sync.config import (
ConfigError,
account_from_env,
accounts_from_env,
archive_root_from_env,
)
BASE = {
"MAIL_ACCOUNTS": "gmail,fastmail",
"MAIL_GMAIL_USER": "u@gmail.com",
"MAIL_GMAIL_APP_PASSWORD": "pw1",
"MAIL_FASTMAIL_USER": "u@fastmail.com",
"MAIL_FASTMAIL_APP_PASSWORD": "pw2",
}
def env(**overrides) -> dict:
merged = dict(BASE)
merged.update(overrides)
return {k: v for k, v in merged.items() if v is not None}
class TestDefaults:
def test_gmail_defaults_to_the_special_use_all_folder(self):
account = account_from_env("gmail", env())
assert account.special_use == ("\\All",)
assert account.folders == ()
def test_fastmail_defaults_to_inbox_archive_sent(self):
account = account_from_env("fastmail", env())
assert account.folders == ("INBOX", "Archive", "Sent")
assert account.special_use == ()
def test_spam_trash_and_drafts_are_not_in_any_default_scope(self):
for name in ("gmail", "fastmail"):
scope = account_from_env(name, env()).folders
assert not {"Spam", "Trash", "Drafts"} & set(scope)
def test_known_hosts_are_defaults_not_requirements(self):
assert account_from_env("gmail", env()).host == "imap.gmail.com"
assert account_from_env("gmail", env(MAIL_GMAIL_HOST="mail.example.com")).host \
== "mail.example.com"
def test_first_tick_defaults_to_new_only(self):
assert account_from_env("fastmail", env()).initial_mode == "new-only"
class TestOverrides:
def test_folders_override_replaces_the_default_list(self):
account = account_from_env("fastmail", env(MAIL_FASTMAIL_FOLDERS="INBOX,Projects"))
assert account.folders == ("INBOX", "Projects")
def test_empty_override_means_none_not_fall_back_to_default(self):
account = account_from_env("gmail", env(MAIL_GMAIL_SPECIAL_USE="",
MAIL_GMAIL_FOLDERS="INBOX"))
assert account.special_use == ()
assert account.folders == ("INBOX",)
def test_since_mode_parses_the_date(self):
account = account_from_env("gmail", env(MAIL_GMAIL_INITIAL_MODE="since",
MAIL_GMAIL_INITIAL_SINCE="2026-06-15"))
assert account.initial_since == date(2026, 6, 15)
def test_port_override(self):
assert account_from_env("gmail", env(MAIL_GMAIL_PORT="1993")).port == 1993
def test_archive_root_override(self):
assert str(archive_root_from_env({"MAIL_ARCHIVE_ROOT": "/srv/mail"})) == "/srv/mail"
class TestFailsLoudly:
def test_missing_accounts_list(self):
with pytest.raises(ConfigError, match="MAIL_ACCOUNTS"):
accounts_from_env({})
def test_missing_password(self):
with pytest.raises(ConfigError, match="APP_PASSWORD"):
account_from_env("gmail", env(MAIL_GMAIL_APP_PASSWORD=None))
def test_missing_user(self):
with pytest.raises(ConfigError, match="USER"):
account_from_env("gmail", env(MAIL_GMAIL_USER=None))
def test_unknown_account_without_a_host(self):
with pytest.raises(ConfigError, match="HOST"):
account_from_env("mailbox-org", {"MAIL_MAILBOX_ORG_USER": "u",
"MAIL_MAILBOX_ORG_APP_PASSWORD": "p"})
def test_unknown_account_with_a_host_but_no_scope(self):
with pytest.raises(ConfigError, match="no scope"):
account_from_env("mailbox-org", {"MAIL_MAILBOX_ORG_USER": "u",
"MAIL_MAILBOX_ORG_APP_PASSWORD": "p",
"MAIL_MAILBOX_ORG_HOST": "imap.mailbox.org"})
def test_bad_port(self):
with pytest.raises(ConfigError, match="PORT"):
account_from_env("gmail", env(MAIL_GMAIL_PORT="993x"))
def test_bad_initial_mode(self):
with pytest.raises(ConfigError, match="INITIAL_MODE"):
account_from_env("gmail", env(MAIL_GMAIL_INITIAL_MODE="yesterday"))
def test_since_mode_without_a_date(self):
with pytest.raises(ConfigError, match="initial_since"):
account_from_env("gmail", env(MAIL_GMAIL_INITIAL_MODE="since"))
def test_bad_since_date(self):
with pytest.raises(ConfigError, match="not a date"):
account_from_env("gmail", env(MAIL_GMAIL_INITIAL_MODE="since",
MAIL_GMAIL_INITIAL_SINCE="15/06/2026"))
def test_duplicate_account_name(self):
with pytest.raises(ConfigError, match="twice"):
accounts_from_env(env(MAIL_ACCOUNTS="gmail,gmail"))
class TestAccountsFromEnv:
def test_returns_accounts_in_the_listed_order(self):
accounts = accounts_from_env(env(MAIL_ACCOUNTS="fastmail,gmail"))
assert [a.name for a in accounts] == ["fastmail", "gmail"]
def test_a_single_account_is_a_valid_config(self):
accounts = accounts_from_env(env(MAIL_ACCOUNTS="gmail"))
assert [a.name for a in accounts] == ["gmail"]
def test_account_name_is_the_envelope_source(self):
assert accounts_from_env(env())[0].name == "gmail"

View file

@ -0,0 +1,85 @@
"""Tests for the textfile-collector renderer."""
from __future__ import annotations
from pathlib import Path
from mail_imap_sync.prom import (
MetricFamily,
Sample,
read_prev_value,
render_prom,
write_prom_atomic,
)
def _family(samples, name="kb_mail_sync_test", mtype="gauge"):
return MetricFamily(name, mtype, "a help string", samples)
class TestRender:
def test_emits_help_and_type_before_samples(self):
text = render_prom([_family([Sample(3)])])
assert text.splitlines()[:3] == [
"# HELP kb_mail_sync_test a help string",
"# TYPE kb_mail_sync_test gauge",
"kb_mail_sync_test 3",
]
def test_labels_are_sorted_and_quoted(self):
text = render_prom([_family([Sample(1, {"folder": "INBOX", "account": "gmail"})])])
assert 'kb_mail_sync_test{account="gmail",folder="INBOX"} 1' in text
def test_label_values_are_escaped(self):
text = render_prom([_family([Sample(1, {"folder": '[Gmail]/"All"\\Mail'})])])
assert 'folder="[Gmail]/\\"All\\"\\\\Mail"' in text
def test_integers_render_without_a_trailing_zero(self):
assert "kb_mail_sync_test 3\n" in render_prom([_family([Sample(3.0)])])
def test_floats_keep_their_precision(self):
assert "kb_mail_sync_test 1.5" in render_prom([_family([Sample(1.5)])])
def test_family_with_no_samples_is_omitted_entirely(self):
# "no accounts reported" and "accounts reported zero" are different facts.
assert render_prom([_family([])]) == "\n"
def test_output_ends_with_a_newline(self):
assert render_prom([_family([Sample(1)])]).endswith("\n")
class TestReadPrevValue:
def test_absent_file_returns_none(self, tmp_path: Path):
assert read_prev_value(tmp_path / "nope.prom", "kb_mail_sync_x") is None
def test_absent_metric_returns_none(self, tmp_path: Path):
path = tmp_path / "x.prom"
path.write_text("kb_mail_sync_other 5\n")
assert read_prev_value(path, "kb_mail_sync_x") is None
def test_reads_a_scientific_notation_value(self, tmp_path: Path):
path = tmp_path / "x.prom"
path.write_text("kb_mail_sync_x 1.786e9\n")
assert read_prev_value(path, "kb_mail_sync_x") == 1.786e9
def test_help_lines_are_not_mistaken_for_samples(self, tmp_path: Path):
path = tmp_path / "x.prom"
path.write_text("# HELP kb_mail_sync_x 42 things\nkb_mail_sync_x 7\n")
assert read_prev_value(path, "kb_mail_sync_x") == 7.0
class TestWriteAtomic:
def test_creates_the_directory_and_the_file(self, tmp_path: Path):
path = tmp_path / "node-exporter" / "kb-mail-sync.prom"
write_prom_atomic(path, "kb_mail_sync_x 1\n")
assert path.read_text() == "kb_mail_sync_x 1\n"
def test_leaves_no_temp_file_behind(self, tmp_path: Path):
path = tmp_path / "kb-mail-sync.prom"
write_prom_atomic(path, "kb_mail_sync_x 1\n")
assert [p.name for p in tmp_path.iterdir()] == ["kb-mail-sync.prom"]
def test_overwrites_in_place(self, tmp_path: Path):
path = tmp_path / "kb-mail-sync.prom"
write_prom_atomic(path, "kb_mail_sync_x 1\n")
write_prom_atomic(path, "kb_mail_sync_x 2\n")
assert path.read_text() == "kb_mail_sync_x 2\n"

View file

@ -0,0 +1,509 @@
"""Unit tests for the IMAP sync job — no network, no DB, no live mailbox.
Every test drives the real `ImapClient` over a fake `imaplib` connection and the real
`run()` over an in-memory `envelope` / `mail_sync_state` pair, so what is being tested is the
job's actual behaviour: what it fetches, what it archives, where the cursor ends up. The
scenarios mirror the failure modes the recon named new mail, UIDVALIDITY invalidation,
dedup, resumption after an interrupted run plus the traps it warned would be silent.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import pytest
from conftest import FakeConn, FakeIMAP, eml
from kb_mail.imap import ImapAccount, ImapClient
from mail_imap_sync.prom import read_prev_value, render_prom
from mail_imap_sync.sync import (
balance_errors,
build_metrics,
measure,
run,
total_errors,
)
GMAIL_FOLDER = "[Gmail]/Wszystkie"
def gmail_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 fastmail_account(**kwargs) -> ImapAccount:
base = dict(name="fastmail", host="imap.example.net", user="u@example.net",
password="secret", special_use=(), folders=("INBOX",))
base.update(kwargs)
return ImapAccount(**base)
def gmail_server(messages, **kwargs) -> FakeIMAP:
return FakeIMAP({GMAIL_FOLDER: messages},
list_attrs={GMAIL_FOLDER: ["\\HasNoChildren", "\\All"]}, **kwargs)
def factory_for(servers: dict):
"""Client factory returning a real ImapClient bound to the named account's fake server."""
def _factory(account: ImapAccount) -> ImapClient:
return ImapClient(account, connection_factory=lambda acc, timeout: servers[acc.name])
return _factory
async def do_run(servers, accounts, conn, archive, *, apply=True, limit=None, monkeypatch=None):
async def _connect(_dsn):
return conn
import asyncpg
monkeypatch.setattr(asyncpg, "connect", _connect)
return await run(
dsn="postgresql://fake",
accounts=accounts,
archive_root=archive,
apply=apply,
limit=limit,
client_factory=factory_for(servers),
)
class TestNewMessages:
async def test_fetches_archives_and_inserts(self, tmp_path: Path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
conn = FakeConn()
result = await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert result["totals"]["envelopes_inserted"] == 2
assert set(conn.envelopes) == {"a@x", "b@x"}
assert sorted(p.name for p in tmp_path.rglob("*.eml")) == ["a@x.eml", "b@x.eml"]
assert not result["failed"]
async def test_archive_path_is_source_year_month(self, tmp_path: Path, monkeypatch):
server = gmail_server({1: eml("a@x")})
await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
FakeConn(), tmp_path, monkeypatch=monkeypatch)
assert (tmp_path / "gmail" / "2026" / "06" / "a@x.eml").exists()
async def test_cursor_advances_to_the_highest_processed_uid(self, tmp_path: Path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
conn = FakeConn()
await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert conn.state[("gmail", GMAIL_FOLDER)] == (42, 2)
async def test_envelope_carries_headers_at_insert_time(self, tmp_path: Path, monkeypatch):
# Recon §2.5 (i): without this, mail_body_ingest.build_prefix silently produces
# "Temat: (brak tematu) | Od: ?" for every new mail — no error, just worse retrieval.
server = gmail_server({1: eml("a@x", subject="Faktura 07/2026")})
conn = FakeConn()
await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
entities = json.loads(conn.envelopes["a@x"]["entities"])
headers = next(e for e in entities if e["type"] == "headers")
assert headers["subject"] == "Faktura 07/2026"
assert headers["from"]["address"] == "a@example.com"
async def test_attachment_manifest_is_appended_next_to_headers(self, tmp_path: Path, monkeypatch):
raw = (
b"Message-ID: <att@x>\r\nFrom: a@example.com\r\nSubject: s\r\n"
b"Date: Mon, 15 Jun 2026 14:00:00 +0200\r\n"
b'Content-Type: multipart/mixed; boundary="b"\r\n\r\n--b\r\n'
b"Content-Type: text/plain\r\n\r\nhello\r\n--b\r\n"
b"Content-Type: application/pdf\r\n"
b'Content-Disposition: attachment; filename="doc.pdf"\r\n\r\n%PDF\r\n--b--\r\n'
)
conn = FakeConn()
await do_run({"gmail": gmail_server({1: raw})}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
types = [e["type"] for e in json.loads(conn.envelopes["att@x"]["entities"])]
assert types == ["headers", "attachment"]
async def test_never_marks_mail_as_read(self, tmp_path: Path, monkeypatch):
# A plain SELECT + FETCH RFC822 would set \Seen on the operator's own inbox.
server = gmail_server({1: eml("a@x")})
await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
FakeConn(), tmp_path, monkeypatch=monkeypatch)
assert all(cmd[2] is True for cmd in server.commands if cmd[0] == "SELECT")
assert all(cmd[-1] == "(BODY.PEEK[])" for cmd in server.fetch_commands())
class TestIncremental:
async def test_second_run_fetches_only_what_arrived_since(self, tmp_path: Path, monkeypatch):
server = gmail_server({1: eml("a@x")})
conn = FakeConn()
account = gmail_account(initial_mode="full")
await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
server.folders[GMAIL_FOLDER][2] = eml("b@x")
server.commands.clear()
result = await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
assert [int(c[2]) for c in server.fetch_commands()] == [2]
assert result["totals"]["envelopes_inserted"] == 1
async def test_idle_mailbox_reports_zero_new(self, tmp_path: Path, monkeypatch):
# The `n:*` range trailer means an unfiltered implementation would report one "new"
# mail on every idle tick, forever.
server = gmail_server({1: eml("a@x")})
conn = FakeConn()
account = gmail_account(initial_mode="full")
await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
server.commands.clear()
result = await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
assert result["totals"]["uids_seen"] == 0
assert result["totals"]["envelopes_inserted"] == 0
assert server.fetch_commands() == []
async def test_rerun_of_the_same_uids_is_a_no_op_at_every_layer(self, tmp_path: Path, monkeypatch):
# Force a re-fetch by rewinding the cursor: archive says FileExistsError, DB says
# ON CONFLICT DO NOTHING, and nothing is double-counted as new.
server = gmail_server({1: eml("a@x")})
conn = FakeConn()
account = gmail_account(initial_mode="full")
await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
conn.state[("gmail", GMAIL_FOLDER)] = (42, 0)
result = await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
totals = result["totals"]
assert totals["envelopes_inserted"] == 0
assert totals["envelopes_skipped_dup"] == 1
assert totals["archive_exists"] == 1
assert totals["archived"] == 0
assert len(list(tmp_path.rglob("*.eml"))) == 1
class TestUidvalidityInvalidation:
async def test_changed_uidvalidity_triggers_a_full_sweep(self, tmp_path: Path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
conn = FakeConn()
account = gmail_account(initial_mode="full")
await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
server._uidvalidity = 99
server.commands.clear()
result = await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
assert result["totals"]["uidvalidity_resets"] == 1
assert sorted(int(c[2]) for c in server.fetch_commands()) == [1, 2]
async def test_resweep_inserts_nothing_new_thanks_to_message_id_dedup(self, tmp_path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
conn = FakeConn()
account = gmail_account(initial_mode="full")
await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
server._uidvalidity = 99
result = await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
assert result["totals"]["envelopes_inserted"] == 0
assert result["totals"]["envelopes_skipped_dup"] == 2
async def test_new_uidvalidity_is_stored_with_the_recomputed_cursor(self, tmp_path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
conn = FakeConn()
account = gmail_account(initial_mode="full")
await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
server._uidvalidity = 99
await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
assert conn.state[("gmail", GMAIL_FOLDER)] == (99, 2)
async def test_renumbered_mailbox_with_lower_uids_is_not_skipped(self, tmp_path, monkeypatch):
# The stored cursor (90) is meaningless under a new UIDVALIDITY; treating it as a
# floor would skip every renumbered message below it.
conn = FakeConn(state={("gmail", GMAIL_FOLDER): (42, 90)})
server = gmail_server({1: eml("a@x"), 2: eml("b@x")}, uidvalidity=99)
result = await do_run({"gmail": server}, [gmail_account()], conn, tmp_path,
monkeypatch=monkeypatch)
assert result["totals"]["envelopes_inserted"] == 2
assert conn.state[("gmail", GMAIL_FOLDER)] == (99, 2)
class TestResumeAfterInterruption:
async def test_cursor_stops_before_a_failed_message(self, tmp_path: Path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x"), 3: eml("c@x")},
fetch_errors={2})
conn = FakeConn()
result = await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert conn.state[("gmail", GMAIL_FOLDER)] == (42, 1)
assert result["totals"]["errors"] == 1
assert result["failed"] is True
async def test_messages_after_the_failure_are_still_stored(self, tmp_path: Path, monkeypatch):
# Not skipped, not lost: they land in the archive and DB now, and the re-fetch that
# the un-advanced cursor forces next tick is absorbed by dedup.
server = gmail_server({1: eml("a@x"), 2: eml("b@x"), 3: eml("c@x")}, fetch_errors={2})
conn = FakeConn()
await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert set(conn.envelopes) == {"a@x", "c@x"}
async def test_next_run_retries_from_the_stalled_uid(self, tmp_path: Path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x"), 3: eml("c@x")}, fetch_errors={2})
conn = FakeConn()
account = gmail_account(initial_mode="full")
await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
server.fetch_errors.clear()
server.commands.clear()
result = await do_run({"gmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
assert sorted(int(c[2]) for c in server.fetch_commands()) == [2, 3]
assert result["totals"]["envelopes_inserted"] == 1 # only b@x was actually missing
assert conn.state[("gmail", GMAIL_FOLDER)] == (42, 3)
async def test_a_failed_db_insert_leaves_the_cursor_behind(self, tmp_path: Path, monkeypatch):
# The archive write already happened; the cursor must not move until the row exists.
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
conn = FakeConn(insert_errors={"a@x"})
result = await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert conn.state[("gmail", GMAIL_FOLDER)] == (42, 0)
assert result["totals"]["errors"] == 1
async def test_limit_caps_a_tick_and_the_rest_follows_next_time(self, tmp_path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x"), 3: eml("c@x")})
conn = FakeConn()
account = gmail_account(initial_mode="full")
await do_run({"gmail": server}, [account], conn, tmp_path, limit=2, monkeypatch=monkeypatch)
assert conn.state[("gmail", GMAIL_FOLDER)] == (42, 2)
await do_run({"gmail": server}, [account], conn, tmp_path, limit=2, monkeypatch=monkeypatch)
assert set(conn.envelopes) == {"a@x", "b@x", "c@x"}
class TestFirstTickModes:
async def test_new_only_records_the_high_water_mark_without_fetching(self, tmp_path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
conn = FakeConn()
result = await do_run({"gmail": server}, [gmail_account(initial_mode="new-only")],
conn, tmp_path, monkeypatch=monkeypatch)
assert server.fetch_commands() == []
assert conn.envelopes == {}
assert conn.state[("gmail", GMAIL_FOLDER)] == (42, 2)
assert not result["failed"]
async def test_since_mode_issues_an_imap_date_search(self, tmp_path, monkeypatch):
from datetime import date
server = gmail_server({1: eml("a@x")})
await do_run({"gmail": server},
[gmail_account(initial_mode="since", initial_since=date(2026, 6, 15))],
FakeConn(), tmp_path, monkeypatch=monkeypatch)
search = [c for c in server.commands if c[:2] == ("UID", "SEARCH")][0]
assert search[-2:] == ("SINCE", "15-Jun-2026")
async def test_initial_mode_no_longer_applies_once_a_cursor_exists(self, tmp_path, monkeypatch):
conn = FakeConn(state={("gmail", GMAIL_FOLDER): (42, 1)})
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert [int(c[2]) for c in server.fetch_commands()] == [2]
async def test_empty_mailbox_first_tick_stores_a_zero_cursor(self, tmp_path, monkeypatch):
server = gmail_server({})
conn = FakeConn()
await do_run({"gmail": server}, [gmail_account(initial_mode="new-only")],
conn, tmp_path, monkeypatch=monkeypatch)
assert conn.state[("gmail", GMAIL_FOLDER)] == (42, 0)
class TestCrossAccount:
async def test_shared_message_id_counts_as_a_cross_source_conflict(self, tmp_path, monkeypatch):
# Recon §2.4: one row, whichever account got there first — but the overlap must be
# visible, or "new fastmail mails" reads low for no discoverable reason.
servers = {
"gmail": gmail_server({1: eml("shared@list")}),
"fastmail": FakeIMAP({"INBOX": {1: eml("shared@list")}}),
}
conn = FakeConn()
result = await do_run(servers,
[gmail_account(initial_mode="full"),
fastmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert result["accounts"]["gmail"]["envelopes_inserted"] == 1
assert result["accounts"]["fastmail"]["envelopes_inserted"] == 0
assert result["accounts"]["fastmail"]["envelopes_conflict_other_source"] == 1
assert conn.envelopes["shared@list"]["source"] == "gmail"
async def test_both_accounts_keep_their_own_archive_copy(self, tmp_path, monkeypatch):
servers = {
"gmail": gmail_server({1: eml("shared@list")}),
"fastmail": FakeIMAP({"INBOX": {1: eml("shared@list")}}),
}
await do_run(servers,
[gmail_account(initial_mode="full"), fastmail_account(initial_mode="full")],
FakeConn(), tmp_path, monkeypatch=monkeypatch)
assert (tmp_path / "gmail" / "2026" / "06" / "shared@list.eml").exists()
assert (tmp_path / "fastmail" / "2026" / "06" / "shared@list.eml").exists()
async def test_a_broken_account_does_not_stop_the_other(self, tmp_path, monkeypatch):
servers = {
"gmail": gmail_server({1: eml("a@x")}, login_error="AUTHENTICATIONFAILED"),
"fastmail": FakeIMAP({"INBOX": {1: eml("b@x")}}),
}
conn = FakeConn()
result = await do_run(servers,
[gmail_account(initial_mode="full"),
fastmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert set(conn.envelopes) == {"b@x"}
assert result["accounts"]["gmail"]["account_errors"] == 1
assert result["failed"] is True
async def test_fastmail_syncs_its_three_configured_folders(self, tmp_path, monkeypatch):
server = FakeIMAP({"INBOX": {1: eml("i@x")}, "Archive": {1: eml("ar@x")},
"Sent": {1: eml("s@x")}})
conn = FakeConn()
account = fastmail_account(folders=("INBOX", "Archive", "Sent"), initial_mode="full")
await do_run({"fastmail": server}, [account], conn, tmp_path, monkeypatch=monkeypatch)
assert set(conn.envelopes) == {"i@x", "ar@x", "s@x"}
assert len(conn.state) == 3
class TestVanishedAndErrors:
async def test_expunged_uid_is_not_an_error_and_does_not_block_the_cursor(self, tmp_path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")}, vanished={1})
conn = FakeConn()
result = await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, monkeypatch=monkeypatch)
assert result["totals"]["vanished"] == 1
assert result["totals"]["errors"] == 0
assert conn.state[("gmail", GMAIL_FOLDER)] == (42, 2)
assert not result["failed"]
async def test_missing_special_use_folder_fails_the_account_loudly(self, tmp_path, monkeypatch):
# Rather than "synced 0 mails, all good" every hour.
server = FakeIMAP({"INBOX": {}})
result = await do_run({"gmail": server}, [gmail_account()], FakeConn(), tmp_path,
monkeypatch=monkeypatch)
assert result["failed"] is True
assert result["accounts"]["gmail"]["account_errors"] == 1
class TestBalance:
async def test_counters_balance_on_a_mixed_run(self, tmp_path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x"), 3: eml("c@x"), 4: eml("d@x")},
fetch_errors={3}, vanished={2})
result = await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
FakeConn(), tmp_path, monkeypatch=monkeypatch)
assert balance_errors(result["totals"]) == []
assert result["balance_problems"] == []
def test_balance_errors_names_each_broken_identity(self):
broken = {"uids_seen": 5, "processed": 1, "vanished": 0, "errors": 0,
"archived": 0, "archive_exists": 0, "envelopes_inserted": 0,
"envelopes_skipped_dup": 0, "envelopes_conflict_other_source": 0}
assert len(balance_errors(broken)) == 3
def test_total_errors_sums_all_three_levels(self):
assert total_errors({"errors": 1, "folder_errors": 2, "account_errors": 3}) == 6
class TestDryRun:
async def test_dry_run_fetches_nothing_and_writes_nothing(self, tmp_path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
conn = FakeConn()
result = await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
conn, tmp_path, apply=False, monkeypatch=monkeypatch)
assert server.fetch_commands() == []
assert conn.envelopes == {}
assert conn.state == {}
assert list(tmp_path.rglob("*.eml")) == []
assert not result["failed"]
async def test_dry_run_reports_how_much_the_real_run_would_pull(self, tmp_path, monkeypatch):
server = gmail_server({1: eml("a@x"), 2: eml("b@x")})
result = await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
FakeConn(), tmp_path, apply=False, monkeypatch=monkeypatch)
folder = result["reports"][0]["folders"][0]
assert folder["candidates"] == 2
assert folder["mode"] == "initial-full"
async def test_dry_run_is_not_flagged_unbalanced(self, tmp_path, monkeypatch):
# It never fetched, so there are no message-level outcomes to balance.
server = gmail_server({1: eml("a@x")})
result = await do_run({"gmail": server}, [gmail_account(initial_mode="full")],
FakeConn(), tmp_path, apply=False, monkeypatch=monkeypatch)
assert result["balance_problems"] == []
class TestMeasure:
async def test_reports_message_counts_per_folder(self):
server = FakeIMAP({"INBOX": {1: eml("a@x"), 2: eml("b@x")}, "Archive": {1: eml("c@x")}})
rows = await measure([fastmail_account(folders=("INBOX", "Archive"))],
client_factory=factory_for({"fastmail": server}))
assert [(r["folder"], r["messages"]) for r in rows] == [("INBOX", 2), ("Archive", 1)]
async def test_touches_neither_archive_nor_mail_flags(self):
server = FakeIMAP({"INBOX": {1: eml("a@x")}})
await measure([fastmail_account()], client_factory=factory_for({"fastmail": server}))
assert server.fetch_commands() == []
async def test_a_failing_account_is_reported_not_raised(self):
server = FakeIMAP({"INBOX": {}}, login_error="nope")
rows = await measure([fastmail_account()], client_factory=factory_for({"fastmail": server}))
assert "error" in rows[0]
class TestMetrics:
def _result(self, *, failed=False, inserted=3):
return {
"totals": {},
"accounts": {
"gmail": {"envelopes_inserted": inserted, "envelopes_skipped_dup": 1,
"envelopes_conflict_other_source": 0, "uidvalidity_resets": 0,
"errors": 0, "folder_errors": 0, "account_errors": 0},
"fastmail": {"envelopes_inserted": 0, "envelopes_skipped_dup": 0,
"envelopes_conflict_other_source": 2, "uidvalidity_resets": 1,
"errors": 0, "folder_errors": 0, "account_errors": 0},
},
"last_message_ts": {"gmail": 1786000000.0},
"failed": failed,
}
def test_counters_are_labelled_per_account(self, tmp_path: Path):
text = render_prom(build_metrics(self._result(), 1786000123.0,
tmp_path / "kb-mail-sync.prom"))
assert 'kb_mail_sync_envelopes_inserted{account="gmail"} 3' in text
assert 'kb_mail_sync_conflict_other_source{account="fastmail"} 2' in text
assert 'kb_mail_sync_uidvalidity_resets{account="fastmail"} 1' in text
def test_success_timestamp_is_set_on_a_clean_run(self, tmp_path: Path):
text = render_prom(build_metrics(self._result(), 1786000123.0, tmp_path / "x.prom"))
assert "kb_mail_sync_last_success_timestamp 1786000123" in text
assert "kb_mail_sync_last_exit_code 0" in text
def test_success_timestamp_carries_forward_across_a_failure(self, tmp_path: Path):
path = tmp_path / "kb-mail-sync.prom"
path.write_text(render_prom(build_metrics(self._result(), 1786000000.0, path)))
text = render_prom(build_metrics(self._result(failed=True), 1786009999.0, path))
# The staleness alert measures time since the last GOOD tick — a transient failure
# must not reset its clock, and must not pretend this run succeeded either.
assert "kb_mail_sync_last_success_timestamp 1786000000" in text
assert "kb_mail_sync_last_run_timestamp 1786009999" in text
assert "kb_mail_sync_last_exit_code 1" in text
def test_first_ever_run_failing_leaves_the_clock_at_zero(self, tmp_path: Path):
text = render_prom(build_metrics(self._result(failed=True), 1786000123.0,
tmp_path / "absent.prom"))
assert "kb_mail_sync_last_success_timestamp 0" in text
def test_last_message_ts_is_omitted_for_an_account_with_no_rows(self, tmp_path: Path):
text = render_prom(build_metrics(self._result(), 1786000123.0, tmp_path / "x.prom"))
assert 'kb_mail_sync_last_message_ts{account="gmail"}' in text
assert 'kb_mail_sync_last_message_ts{account="fastmail"}' not in text
def test_read_prev_value_ignores_labelled_series(self, tmp_path: Path):
path = tmp_path / "x.prom"
path.write_text('kb_mail_sync_envelopes_inserted{account="gmail"} 3\n'
"kb_mail_sync_last_success_timestamp 1786000000\n")
assert read_prev_value(path, "kb_mail_sync_last_success_timestamp") == 1786000000.0
assert read_prev_value(path, "kb_mail_sync_envelopes_inserted") is None

View file

@ -40,7 +40,12 @@ DEFAULT_SUMMARY_MODEL = "claude-haiku-4-5" # plan §2 D3 resolution 2026-07-17:
DEFAULT_EMBED_MODEL = "bge-m3"
DEFAULT_N = 10 # plan §6.1 start value
DEFAULT_K = 5 # plan §6.1 start value
DEFAULT_SUMMARYLESS_SOURCES = ("gmail",) # faza mailowa plan §6 Krok 3: sources with no summaries
# faza mailowa plan §6 Krok 3: sources with no summaries. `fastmail` added 2026-08-06 in the
# same commit that introduced the source (recon Decyzja (g)) — mail reaches /search results
# ONLY through the hybrid path's summaryless leg, which filters `WHERE e.source = ANY($2)`.
# Left out, fastmail envelopes would archive, chunk and embed correctly and then be invisible
# to every query, with nothing anywhere reporting an error.
DEFAULT_SUMMARYLESS_SOURCES = ("gmail", "fastmail")
async def flat_retrieve(conn: asyncpg.Connection, query_vector: str, k: int = DEFAULT_K) -> list[dict]: