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

257 lines
13 KiB
Markdown

---
okf: "0.1"
type: service
visibility: private
status: active
updated: 2026-08-06
links:
- ../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):
```bash
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: **FETCH**`save_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.