homelab-codex-ws/kb/services/job-mail-imap-sync.md
oskar 3eec5182a0 feat(mail-sync): scheduler PIHA, takt kb-ingest, runbook i dokumentacja
Domkniecie Kroku 7. Realizuje Decyzje (d) reconu (host schedulera + korekta
kadencji indeksowania) i doklada dokumentacje wg konwencji OKF.

Scheduler (NIEAKTYWOWANY — wlacza operator):
- jobs/mail-imap-sync/systemd/{service,timer,run.sh} — wzorzec 1:1 z kb-ingest,
  OnCalendar=hourly, Persistent=true, log do pliku (nigdy sam journal).
- hosts/piha/jobs.yaml — deklaracja jednostek host-level na PIHA. Nowy plik, bo
  services.yaml jest dla kontenerow (supervisor dopasowuje jego wpisy do world-state
  i wpis niekontenerowy dryfowalby wiecznie jako missing_service). Nic tego pliku
  nie czyta — istnieje po to, zeby "shadow-deploy family" z otwartego pytania 5
  reconu multiagentowego byla spisana, a nie tylko na nodzie.

Takt indeksowania (Decyzja (d), recon §3.3):
- kb-ingest.timer: 03:30 raz na dobe -> co 2 h. O 03:30 SOLARIA prawie na pewno spi
  (potwierdzone odczytem kb_ingest_embed_skipped 1 z 2026-08-06), a tick dostaje
  teraz etap mailowy: ~60 nowych chunkow na dobe pomijanych kazdej nocy sprawiloby,
  ze backlog rosnie monotonicznie i KbEmbedBacklogGrowing zapala sie NA STALE.
  Co 2 h zamiast stalej godziny — probe Ollamy sam wybiera okno, wiec ktorys tick
  w nie trafi niezaleznie od nawykow operatora.
- cyclic_ingest: etap mailowy (mail_body_ingest --only-unchunked), import miekki,
  wiec venv bez tego pakietu pomija etap zamiast wywracac wrapper. Predykat bledu
  JEST luzniejszy niz wlasne main() tamtego joba i to jedyne takie miejsce w tym
  wrapperze: pojedynczy trwale nieparsowalny mail nie moze zamrozic
  last_success_timestamp i zapalic KbIngestStale na zawsze. Bledy per-mail sa
  publikowane jako kb_ingest_mail_parse_errors, nie chowane.

Obserwowalnosc: KbMailSyncStale (6 h bez udanego ticku). Alert na cisze w skrzynce
ODRZUCONY (decyzja operatora, zgodna z reconem §3.4) — zero nowych maili to legalny
stan skrzynki, a alert zapalajacy sie na zdrowym systemie zostaje wyciszony
i przestaje dzialac wtedy, gdy jest potrzebny.

Dokumentacja:
- kb/services/job-mail-imap-sync.md (OKF), kb/runbooks/mail-sync-run.md — 9 krokow
  pierwszego uruchomienia, w tym checklista 4 punktow [do weryfikacji na zywo]
  z reconu (polityki dostawcow — do sprawdzenia, nie do zgadniecia) oraz pomiar
  STATUS (MESSAGES) na Fastmailu, na ktorym zapada ODLOZONA decyzja o historii.
- kb-mail-pillar.md: KOREKTA JMAP -> IMAP dla Fastmaila jako decyzja 2026-08-06;
  stary zapis zostaje jako historia z data. Zamkniete "unifikacja adaptera"
  i "sizing Gmaila"; otwarte zostaje "sizing Fastmaila" — celowo, bo rozstrzyga
  je pomiar, nie dyskusja.
- kb-m5-faza-mailowa.md: Krok 7 IN PROGRESS + tabela zakresu wdrozonego,
  kb-m5-faza3.md: korekta harmonogramu i sekwencji wrappera,
  pkg-kb-mail.md: rozpisany ze stubu, kb-postgres.md: lista migracji + 005.

Testy: 642 passed (calosc kb-mail, kb-retrieval i jobs). systemd-analyze verify
na timerze przechodzi, OnCalendar=0/2:00:00 normalizuje sie do co 2 h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:49:58 +02:00

13 KiB

okf type visibility status updated links
0.1 service private active 2026-08-06
../runbooks/mail-sync-run.md
../audits/mail-sync-2026-08-06.md
../phases/kb-m5-faza-mailowa.md

mail-imap-sync

Module 5, faza mailowa, Krok 7 (kb/phases/kb-m5-faza-mailowa.md §10), built to the recon kb/audits/mail-sync-2026-08-06.md after the operator approved decisions (a)-(g) on 2026-08-06. It is the first code in this repo that talks to a mail server.

Before it, the mail corpus was a photograph: 225 030 envelopes ending at 2026-06-19, imported once from a Gmail Takeout, with a gap that grew by roughly 37 messages a day. This job closes the gap and keeps it closed.

Where it runs

On PIHA, hourly, as a host-level systemd oneshot (jobs/mail-imap-sync/systemd/). The choice is not close (recon §3.1): PIHA is up 24/7, holds the canonical .eml archive, and runs kb-postgres locally. SOLARIA is powered off ~16 h a day by design, so a poller living there would read the mailbox only when the desktop happened to be on.

Install (from repo root, on PIHA):

pip install -e packages/kb-mail/
pip install -e jobs/mail-imap-sync/

What one tick does

Per account, per folder:

  1. EXAMINE the folder — read-only. Never SELECT, and bodies come via BODY.PEEK[], so the job never sets \Seen on the operator's own mail.
  2. Plan the tick from mail_sync_state and the server's UIDVALIDITY/UIDNEXT — first contact, ordinary increment, or a UIDVALIDITY reset (see below).
  3. UID SEARCH for the UIDs this tick owns.
  4. Per UID, ascending: FETCHsave_eml() (append-only; FileExistsError just means "already have it") → insert_envelope() with entities = [headers, attachment…].
  5. UPDATE mail_sync_state — but only over the contiguous prefix of messages that are fully durable in both the archive and the DB.

Folder scope (Decyzja (e))

Account Scope Why
gmail SPECIAL-USE \All one fetch per message despite many labels; the same population as the Takeout the existing 225 030 envelopes came from, so the corpus stays continuous
fastmail INBOX + Archive + Sent a classic IMAP layout has no \All equivalent

Spam, Trash and Drafts are excluded from both. Spam and Trash are defined noise and were not in the Takeout either; drafts are not correspondence, have no stable Message-ID, and mutate.

The gmail folder is resolved by SPECIAL-USE attribute, never by name. Gmail localizes the mailbox — with a Polish UI it is [Gmail]/Wszystkie — so a hardcoded [Gmail]/All Mail would sync nothing while reporting a clean run every hour.

Scope is per-account configuration (MAIL_<ACCOUNT>_FOLDERS / _SPECIAL_USE), so adding a folder later is a config change, not a code change; Message-ID dedup absorbs any overlap.

The sync cursor

mail_sync_state (migration 005), keyed (account, folder), holding uidvalidity, last_uid and last_sync_ts. A table rather than a file in /opt/homelab/state/ (Decyzja (f)) for one decisive reason: the cursor and the envelopes it describes must restore together or not at all. A state file surviving a DB restore would make the poller silently skip everything between the restored rows and the file's last_uid — a failure with no symptom, noticed months later as missing mail.

Deriving the cursor from max(envelope.ts) instead was rejected outright: ts is the sender's Date: header, not delivery time. 2 559 envelopes in the live corpus already sit at epoch 1970.

UIDVALIDITY

When the server changes UIDVALIDITY, every stored UID becomes meaningless. The job sweeps the folder (UID SEARCH ALL), leans on Message-ID dedup to make that cheap, and stores the new value. This is rare — and it is the failure that loses mail with no error anywhere if it is not handled.

Why the cursor only crosses a contiguous prefix

A message that fails is not stepped over. The cursor stops at the message before it, and the next tick refetches from there — free, because the archive write is append-only and the insert is ON CONFLICT DO NOTHING. Messages after the failure are still stored in the same tick; they simply do not move the cursor.

The consequence is deliberate: a message that fails permanently stalls its folder. It does so visibly — a non-zero error counter every tick, a cursor that stops moving, the offending UID in the log — which is strictly better than the alternative of dropping mail while reporting success. The runbook documents the manual UPDATE mail_sync_state escape hatch for that case.

The n:* trap

UID SEARCH UID n:* is a range, and when n exceeds the highest existing UID the server resolves it as highest:n and returns the last message anyway. Without the client-side filter, "new mails this tick" would never read zero on an idle mailbox, and every observability claim built on that counter would be a small permanent lie.

First contact with a folder

MAIL_<ACCOUNT>_INITIAL_MODE decides what the first-ever tick on a folder fetches. It has no effect once a mail_sync_state row exists.

Mode Behaviour When
new-only record UIDNEXT-1, fetch nothing start the corpus from now
since UID SEARCH SINCE <date> (server INTERNALDATE, not the Date: header) close a known gap
full UID SEARCH ALL pull a mailbox's whole history

gmail wants since. A new-only first tick would leave the 48-day gap the recon measured unfilled forever — and that gap is the entire reason this job exists. Point MAIL_GMAIL_INITIAL_SINCE a few days before the newest envelope in the DB; dedup makes the overlap free.

fastmail is undecided by design. The account is greenfield (zero rows, zero archive files), and whether to pull its history depends on how big it is — a number nobody has yet. Run mail-imap-sync --measure first (recon Decyzja (e) explicitly refuses to guess it).

Message-ID collisions across accounts

envelope.id is a bare Message-ID and therefore globally unique in the table, with no source prefix (unlike paperless, which uses paperless:N). A mail present in both mailboxes — a mailing list, a forward, a CC to both addresses — has the same Message-ID in both, so whoever inserts first sets source and the second is silently skipped by ON CONFLICT DO NOTHING. save_eml still writes both .eml copies, because the archive path contains the source; raw_ref points at one of them.

This is not corruption and it is the desired index behaviour: the content is indexed once. It has two measurable effects worth knowing about before someone starts wondering about the numbers — "new fastmail mails" reads systematically low by the shared part, and source filtering in retrieval attributes such a mail to whichever account won the race.

The recon's resolution (§2.4) is to keep the behaviour and make the phenomenon visible: envelopes_conflict_other_source, one extra query per suppressed insert. Re-keying to (source, message_id) was rejected — it breaks the frozen envelope contract, requires rewriting 225 030 ids and every raw_ref, and buys duplicates in the index.

What this job does NOT do

It does not chunk and does not embed. That split is the architecture, not an omission (recon §3.2): fetching is network-bound and belongs on the 24/7 node; chunking and embedding need Ollama on SOLARIA. Coupling them would mean mail only arrives when the desktop is on.

The handoff needs no queue of its own — an envelope with no document_chunk rows is the queue, drained by mail-body-ingest --only-unchunked from the kb-ingest timer. That is self-healing in a way a timestamp cursor is not: an interrupted run, a mail fetched while the GPU slept, a message inserted with a backdated header — each stays pending until it has chunks, with nothing to reconcile.

--since was explicitly rejected as the glue: it filters on envelope.ts, so a mail delivered today with a month-old header date would fall outside the window and never be chunked at all.

Headers at INSERT time

New envelopes carry entities[type=headers] immediately, via kb_mail.headers.parse_headers_resilient (typed parse, compat32 fallback). Historical envelopes only have headers because a separate backfill job walked all 225 030 of them later. Without this, mail_body_ingest.build_prefix would produce Temat: (brak tematu) | Od: ? | Data: … for every new mail — with no error, just permanently worse retrieval (recon §2.5 i).

Secrets

Environment only (Decyzja (c)). There is no --password or --user flag: --dsn <secret> lands in ps output and shell history. Values go into the existing /opt/homelab/kb/.env (root-owned 0600), which systemd reads as root before dropping to User=oskar — so the app passwords reach the process without being readable by oskar at rest. The repo ships only jobs/mail-imap-sync/env.example with placeholders.

Stats must balance

uids_seen = processed + vanished + errors
processed = archived + archive_exists
processed = envelopes_inserted + envelopes_skipped_dup + envelopes_conflict_other_source

headers_fallback, uidvalidity_resets, folder_errors and account_errors are labels and diagnostics, deliberately outside the equations — a folder that failed on EXAMINE never produced a UID to account for. An unbalanced run is a failure regardless of what else it reports.

Exit codes

Code Meaning
0 Balanced, zero errors at any level
1 Any message/folder/account error, or an unbalanced sum
2 Configuration error — missing/malformed env, no DSN, no archive directory

Failures are isolated per account: a Gmail app password that stopped working must not also stop Fastmail from syncing (the same stage isolation cyclic_ingest uses).

Modes

Invocation Effect
(default) Dry run: EXAMINE + SEARCH, report what a real tick would pull. No FETCH, no writes, no .prom.
--apply The real thing, and the only mode that publishes metrics.
--measure STATUS (MESSAGES UIDNEXT UIDVALIDITY) per folder, then exit. Read-only, no DB.
--limit N At most N messages per folder per tick — a brake for a first big sweep; the rest follows next tick.

Metrics

/opt/homelab/state/node-exporter/kb-mail-sync.prom, written atomically (tmp + rename) and scraped by node_exporter's textfile collector on PIHA.

Metric Notes
kb_mail_sync_last_run_timestamp every run
kb_mail_sync_last_success_timestamp carried forward across a failed run, so a blip does not reset the staleness clock
kb_mail_sync_last_exit_code
kb_mail_sync_envelopes_inserted{account}
kb_mail_sync_envelopes_skipped_dup{account}
kb_mail_sync_conflict_other_source{account} the cross-account overlap above
kb_mail_sync_uidvalidity_resets{account}
kb_mail_sync_errors{account} message + folder + account levels
kb_mail_sync_last_message_ts{account} max(envelope.ts); omitted for an account with no rows

One alert, KbMailSyncStale (services/fleet-prometheus/rules/kb-mail-sync.yml): 6 h without a successful tick. "No new mail for X days" was considered and rejected (recon §3.4) — zero new mail is a legal state of a mailbox, an alert that fires on a healthy system gets muted, and a muted alert is not there on the day it matters. A per-account warning on kb_mail_sync_last_message_ts may be worth adding after a month of observation, with a threshold taken from measured behaviour rather than a guess.

Definition of Done

Per CLAUDE.md: pytest passes (80 for this job, 111 for kb-mail including the adapter and the cursor planner) plus a CLI smoke run. The tests drive the real ImapClient over a fake imaplib connection, so what is covered is the adapter's actual protocol parsing: new messages, UIDVALIDITY invalidation, dedup, resumption after an interrupted run, cross-account collisions, account isolation, expunged UIDs, a dry run that touches nothing, and the metric carry-forward.

Nothing here has ever connected to a live mailbox. The first real sync is an operator step, supervised, per kb/runbooks/mail-sync-run.md.

Follow-ups

  • mail_imap_sync.prom and documents_ingest.cyclic_ingest's renderer are two copies of the same idea (this one supports labels, that one does not). Worth folding into one shared helper the next time either changes.
  • kb-mail-sync.timer joins the "shadow-deploy family" — units installed outside GitOps drift detection — listed in hosts/piha/jobs.yaml. That is open question 5 of kb/subsystems/recon-multiagent.md; the list is now one item longer, knowingly.