homelab-codex-ws/kb/services/pkg-kb-mail.md
oskar ae16deb8d3 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 15:30:57 +02:00

61 lines
3.2 KiB
Markdown

---
okf: "0.1"
type: service
visibility: private
status: active
updated: 2026-08-06
links:
- job-mail-imap-sync.md
- job-mail-body-ingest.md
- job-gmail-bulk-import.md
---
# kb-mail
Shared library for the mail pillar. Everything that more than one mail job needs lives here,
by rule rather than by taste: the corpus rests on 225 030 envelopes whose ids, archive paths
and header entities were produced by one implementation, and a second copy of any of those
derivations would silently stop agreeing with the first.
**Kod:** `packages/kb-mail/` (`src/kb_mail/`, `pyproject.toml`, `tests/`)
Dependencies: `asyncpg`, `structlog`. Nothing else — the IMAP transport is stdlib `imaplib`.
## Modules
| Module | What it holds | Used by |
|---|---|---|
| `envelope` | The frozen cross-source `Envelope` dataclass (mirrors `envelope` in kb-postgres). Additive contract. | everything |
| `db` | asyncpg helpers: `insert_envelope` (returns the command tag), `get_envelope`, `rows_affected`, `envelope_source` | all jobs |
| `archive` | `save_eml` — append-only `.eml` writer, `{root}/{source}/{YYYY}/{MM}/{id}.eml`. `FileExistsError` is the "already have it" signal, never masked. | bulk-import, imap-sync |
| `text` | `sanitize_surrogates`, `strip_nul` — both mandatory before anything reaches `text`/`jsonb` | all parsing paths |
| `chunking` | `chunk_text` (2400/600 chars), extracted in Krok 0 | mail-body-ingest, documents-ingest |
| `headers` | `parse_headers`, `parse_headers_fallback`, `parse_headers_resilient` — the `entities[type=headers]` shape, typed parse with a compat32 fallback | header-backfill, imap-sync |
| `message` | `message_id`, `parse_date`, `parse_attachments`, `eml_ref`, `EPOCH` — envelope id, timestamp, attachment manifest, archive path | bulk-import, imap-sync |
| `imap` | `ImapAccount`, `ImapClient`, `FolderStatus` — read-only IMAP over stdlib `imaplib` | imap-sync |
| `sync_state` | `mail_sync_state` accessors + `plan_folder_sync` / `contiguous_last_uid` | imap-sync |
`headers` and `message` were extracted from `gmail-header-backfill` and `gmail-bulk-import`
respectively when `mail-imap-sync` needed the same derivations (2026-08-06). Both jobs
re-export them under their original names, so their CLIs and test suites are unchanged. This
is the same move as `chunking` in Krok 0 — extract, do not copy.
## Why the IMAP transport lives here and not in the job
The adapter is protocol code, and the sync-cursor semantics (UIDVALIDITY invalidation, how far
a cursor may advance) are protocol semantics — both are worth testing in isolation, and both
would otherwise be buried in a job's loop where a wrong branch is invisible. `kb_mail.imap` and
`kb_mail.sync_state` therefore carry the rules; `jobs/mail-imap-sync` carries the orchestration
and the policy. See `kb/services/job-mail-imap-sync.md` for the behaviour they add up to.
## Tests
```bash
pip install -e "packages/kb-mail[dev]"
cd packages/kb-mail && pytest
```
111 unit tests, no DB or network. `tests/test_db.py` is additionally marked `integration` and
needs a live kb-postgres (`KB_TEST_DSN`, default `localhost:5433`) — it is the only file that
does, and it errors rather than skips when the DB is absent.