homelab-codex-ws/kb/runbooks/mail-sync-run.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

353 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
okf: "0.1"
type: runbook
visibility: private
status: active
updated: 2026-08-06
links:
- ../services/job-mail-imap-sync.md
- ../audits/mail-sync-2026-08-06.md
---
# mail-imap-sync — pierwsze uruchomienie i eksploatacja
Wszystko poniżej wykonuje **operator na PIHA**. Kod nie łączył się nigdy z żadnym żywym
kontem — pierwszy sync jest nadzorowany i idzie krokami 1-9. Timer aktywujesz dopiero
w kroku 9, po tym jak ręczny run przejdzie czysto.
---
## 0. Punkty [do weryfikacji na żywo]
Recon (`kb/audits/mail-sync-2026-08-06.md`) oznaczył cztery twierdzenia o zewnętrznych
dostawcach jako **niesprawdzone** — stan wiedzy modelu sięga maja 2026 i **nie jest
wiarygodnym źródłem dla polityki dostawcy z sierpnia 2026**. Sprawdź je przy okazji kroków
1-3 i dopisz wynik tutaj. Nie zgaduj.
| # | Do sprawdzenia | Gdzie | Wynik |
|---|---|---|---|
| 1 | Czy Google nadal wydaje hasła aplikacji (i czy wymagają 2FA na koncie)? Gdyby metoda odpadła, przymusem staje się OAuth2 — reszta architektury się nie zmienia, wymianie podlega **wyłącznie** sposób uwierzytelnienia w `kb_mail.imap.ImapClient.connect`. | Krok 1 | *(wpisz)* |
| 2 | Czy Fastmail pozwala wydać hasło aplikacji **o zakresie tylko-IMAP** (bez dostępu do panelu)? Jeśli tak, jest to uprawnienie ściślejsze niż token JMAP — i to domyka argument za unifikacją adaptera. | Krok 2 | *(wpisz)* |
| 3 | Czy konto Fastmail wystawia jakiś folder wirtualny obejmujący całość (odpowiednik gmailowego `\All`)? Jeśli tak, zakres `INBOX+Archive+Sent` można uprościć. | Krok 4 (`--measure` wypisze wszystkie foldery po `LIST`) | *(wpisz)* |
| 4 | Dobowy limit transferu IMAP Gmaila (rzędu kilku GB). Przy ~1 800 zaległych maili (~225 MB) i ~4,6 MB/dobę bieżąco nie ma znaczenia — ma, gdyby ktoś kiedyś ciągnął IMAP-em pełną historię. | Krok 6, jeśli pierwszy sweep się urwie | *(wpisz)* |
---
## 1. Hasło aplikacji — Gmail
Wymaga włączonego 2FA na koncie (bez tego Google nie pokaże opcji haseł aplikacji).
Wygeneruj hasło aplikacji dla „Mail", zapisz je **od razu do menedżera haseł** — Google
pokazuje je jeden raz.
> **Nie wypisuj hasła na ekran i nie wklejaj go do żadnej sesji.** W tym repo są dwa
> udokumentowane przypadki rotacji sekretu po wycieku do transkryptu sesji
> (`docs/sessions/2026-07-15.md` §Krok 3, `docs/sessions/2026-07-21.md` incydent 3). To jedyna
> droga, którą sekrety w tym homelabie dotąd wyciekały.
Google wyświetla hasło w czterech grupach po cztery znaki — **wpisuj bez spacji**.
## 2. Hasło aplikacji — Fastmail
Wydaj hasło aplikacji o zakresie **IMAP** (nie „full access"), jeśli konto na to pozwala —
to punkt (2) z tabeli wyżej. Zapisz tak samo jak wyżej.
## 3. Wypełnienie `.env`
Sekrety dopisujesz do **istniejącego** `/opt/homelab/kb/.env` (już `root:root 0600`, już
trzyma `KB_DSN`, `PAPERLESS_API_TOKEN`, `ANTHROPIC_API_KEY`). Edytorem, nie `echo >>`
`echo` zostawia hasło w historii powłoki.
```bash
sudo -e /opt/homelab/kb/.env
```
Wzorzec kluczy i komentarze: `jobs/mail-imap-sync/env.example`. Minimum:
```
MAIL_ACCOUNTS=gmail,fastmail
MAIL_GMAIL_USER=...
MAIL_GMAIL_APP_PASSWORD=...
MAIL_GMAIL_INITIAL_MODE=since
MAIL_GMAIL_INITIAL_SINCE=2026-06-15
MAIL_FASTMAIL_USER=...
MAIL_FASTMAIL_APP_PASSWORD=...
MAIL_FASTMAIL_INITIAL_MODE=new-only
```
Sprawdź uprawnienia po edycji — muszą zostać `600 root:root`:
```bash
sudo ls -l /opt/homelab/kb/.env
```
Dlaczego akurat tak: systemd czyta `EnvironmentFile` **jako root, przed zrzuceniem uprawnień
do `User=oskar`**. Sekret trafia do procesu, ale nie jest czytelny dla `oskar` w spoczynku.
To lepsza własność niż plik `600 oskar:oskar` — nie „napraw" jej.
### Skąd data dla `MAIL_GMAIL_INITIAL_SINCE`
Kilka dni **przed** najnowszą kopertą w bazie — nakładka jest darmowa (dedup po Message-ID),
a luka nie jest:
```bash
docker exec kb-postgres psql -U kb -d kb -c \
"SELECT source, count(*), max(ts) FROM envelope GROUP BY source ORDER BY source;"
```
Stan na 2026-08-06: gmail 225 030 kopert, `max(ts) = 2026-06-19 19:13:39+02`. Stąd
`2026-06-15` w przykładzie wyżej. `SINCE` filtruje po INTERNALDATE serwera (kiedy wiadomość
przyszła), nie po nagłówku `Date:` — czyli po właściwej stronie tego rozróżnienia.
## 4. Instalacja i migracja
```bash
cd ~/homelab-codex-ws && git pull
source /opt/homelab/kb/venv/bin/activate
pip install -e packages/kb-mail/ -e jobs/mail-imap-sync/
# mail-body-ingest jest potrzebny etapowi mailowemu w kb-ingest (patrz krok 8):
pip install -e packages/kb-retrieval/ -e jobs/mail-body-ingest/
```
Migracja `005``initdb` odpala skrypty z `init/` **tylko na świeżym wolumenie**, więc na
istniejącej bazie aplikujesz ją ręcznie:
```bash
docker exec -i kb-postgres psql -U kb -d kb \
< ~/homelab-codex-ws/services/kb-postgres/init/005_mail_sync_state.sql
docker exec kb-postgres psql -U kb -d kb -c "\d mail_sync_state"
```
Oczekiwane: kolumny `account, folder, uidvalidity, last_uid, last_sync_ts` i
`PRIMARY KEY (account, folder)`. DDL jest `IF NOT EXISTS` — powtórzenie jest bezpieczne.
## 5. Pomiar — ile waży Fastmail
**To jest moment, w którym zapada decyzja o historii Fastmaila.** Recon celowo jej nie
podjął: zależy od liczby, której nikt jeszcze nie znał (Decyzja (e), „najpierw pomiar, potem
decyzja"). Odłożona **do tego kroku**.
```bash
set -a; . /opt/homelab/kb/.env; set +a
mail-imap-sync --measure
```
Tryb jest read-only: `STATUS (MESSAGES UIDNEXT UIDVALIDITY)` per folder, zero zapisów, zero
dotknięcia flag wiadomości. Weryfikuje przy okazji, że oba hasła aplikacji działają.
Odczytaj sumę `messages` dla `INBOX + Archive + Sent` i wybierz:
| Rozmiar | `MAIL_FASTMAIL_INITIAL_MODE` | Uwagi |
|---|---|---|
| kilka-kilkanaście tysięcy | `full` | Rzędu godzin transferu; mechanizm ten sam co przyrost, więc nie kosztuje osobnego kodu. To prawdopodobnie ta „sensowna poczta", o którą chodziło bardziej niż o gmailowe newslettery. |
| dziesiątki tysięcy+ | `full` **z `--limit`** | Pierwszy sweep w kawałkach, np. `--limit 2000` na tick; kursor idzie od najstarszych, reszta dochodzi kolejnymi tickami. |
| nieinteresująca | `new-only` | Historia zostaje poza KB. |
Wpisz wybór do `.env` **zanim** puścisz pierwszy `--apply` — po pierwszym runie istnieje
wiersz w `mail_sync_state` i `INITIAL_MODE` przestaje mieć jakikolwiek wpływ.
Przy okazji: `--measure` po drodze robi `LIST`, więc log pokaże wszystkie foldery konta —
stąd odpowiedź na punkt (3) z tabeli w §0.
## 6. Dry run, potem pierwszy sync
Dry run nie pobiera ani jednej wiadomości — robi EXAMINE + SEARCH i mówi, ile by ściągnął:
```bash
mail-imap-sync
```
Czytaj `candidates` per folder. Dla gmaila przy `since=2026-06-15` spodziewaj się rzędu
**~1 800** (48 dni × ~37/dobę). Rząd wielkości zupełnie inny niż oczekiwany = zatrzymaj się
i sprawdź konfigurację, nie puszczaj `--apply`.
Pierwszy prawdziwy run — z hamulcem, do pliku, na niskim priorytecie:
```bash
mkdir -p /opt/homelab/logs/kb-mail-sync
nice -n 10 ionice -c2 -n7 mail-imap-sync --apply --limit 200 \
> /opt/homelab/logs/kb-mail-sync/first-run.log 2>&1
echo "exit=$?"
```
Sprawdź w logu linię `summary`:
- `envelopes_inserted` > 0, `errors` = 0,
- `archived` + `archive_exists` = `processed`,
- `balance_problems` puste,
- `headers_fallback` — pojedyncze sztuki są normalne (8-bitowe nagłówki), dziesiątki nie.
Potem puszczaj bez `--limit`, aż `candidates` w dry runie spadnie do zera:
```bash
mail-imap-sync --apply >> /opt/homelab/logs/kb-mail-sync/first-run.log 2>&1
```
Kontrola w bazie:
```bash
docker exec kb-postgres psql -U kb -d kb -c \
"SELECT account, folder, uidvalidity, last_uid, last_sync_ts FROM mail_sync_state ORDER BY 1,2;"
docker exec kb-postgres psql -U kb -d kb -c \
"SELECT source, count(*), max(ts) FROM envelope GROUP BY source ORDER BY 1;"
```
`max(ts)` dla gmaila powinien być z ostatnich dni, a `source='fastmail'` pojawić się po raz
pierwszy w historii tej bazy.
## 7. Indeksowanie i weryfikacja end-to-end
Sam sync **nie chunkuje i nie embeduje** — celowo (recon §3.2). Nowe koperty czekają
w kolejce, którą jest brak chunków. Zdrenuj ją ręcznie raz, przy włączonej SOLARII:
```bash
mail-body-ingest --only-unchunked --apply \
>> /opt/homelab/logs/kb-mail-sync/first-ingest.log 2>&1
```
Pełny test od końca do końca — wyślij sobie maila z rozpoznawalną frazą, potem:
```bash
# 1. sync go pobiera
mail-imap-sync --apply
# 2. koperta jest w bazie
docker exec kb-postgres psql -U kb -d kb -c \
"SELECT id, source, ts FROM envelope ORDER BY ts DESC LIMIT 5;"
# 3. ingest robi chunki i embeddingi (SOLARIA musi być włączona)
mail-body-ingest --only-unchunked --apply
# 4. widać go w wyszukiwarce, trybem domyślnym (hybrid)
curl -s "http://localhost:8230/search?q=<fraza+z+maila>" | jq '.results[] | {envelope_id, dist}'
```
Krok 4 jest tym, co weryfikuje Decyzję (g): `fastmail` musi być w
`DEFAULT_SUMMARYLESS_SOURCES`, inaczej koperty zaindeksują się poprawnie i **nie pojawią się
w żadnym wyniku** — bez błędu gdziekolwiek. Jeśli mail z Fastmaila nie wychodzi w `/search`,
a z gmaila wychodzi, to jest pierwsze miejsce do sprawdzenia.
## 8. Instalacja jednostek systemd (jeszcze bez włączenia)
```bash
sudo install -m 0755 ~/homelab-codex-ws/jobs/mail-imap-sync/systemd/kb-mail-sync-run.sh \
/opt/homelab/kb/kb-mail-sync-run.sh
sudo cp ~/homelab-codex-ws/jobs/mail-imap-sync/systemd/kb-mail-sync.{service,timer} \
/etc/systemd/system/
sudo systemctl daemon-reload
```
**Równocześnie: zmiana taktu `kb-ingest`** (Decyzja (d), recon §3.3). Timer przechodzi
z 03:30 raz na dobę na **co 2 h**, a `kb-ingest` dostaje etap mailowy:
```bash
sudo cp ~/homelab-codex-ws/jobs/documents-ingest/systemd/kb-ingest.timer /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl restart kb-ingest.timer
systemctl list-timers kb-ingest.timer
```
Dlaczego to musi iść razem z uruchomieniem przyrostówki: o 03:30 SOLARIA prawie na pewno śpi
(potwierdzone 2026-08-06 odczytem `kb_ingest_embed_skipped 1`). Dopięcie ~60 nowych chunków
na dobę do ticku, który każdej nocy pomija embedowanie, sprawiłoby, że backlog rośnie
monotonicznie i `KbEmbedBacklogGrowing` (próg `> 0` przez 72 h) **zapala się na stałe** — nie
sygnalizując awarii, tylko rozjazd harmonogramu z dobowym cyklem SOLARII. Alert, który świeci
zawsze, przestaje być alertem.
Reguła alertowa dla samego synca (`services/fleet-prometheus/rules/kb-mail-sync.yml`) wchodzi
przy najbliższym deployu fleet-prometheus. Dopóki timer nie chodzi, metryka nie istnieje,
`time() - <brak serii>` nie daje wyniku i reguła jest bezczynna — kolejność nie ma znaczenia.
## 9. Aktywacja timera
Dopiero teraz, i tylko jeśli kroki 6-7 przeszły czysto:
```bash
sudo systemctl enable --now kb-mail-sync.timer
systemctl list-timers kb-mail-sync.timer
```
Po pierwszym automatycznym ticku:
```bash
journalctl -u kb-mail-sync.service -n 50
cat /opt/homelab/state/node-exporter/kb-mail-sync.prom
```
W `.prom` szukaj `kb_mail_sync_last_success_timestamp` (niezerowy) i
`kb_mail_sync_envelopes_inserted{account="..."}`.
---
## Eksploatacja
### Codzienne sprawdzenie
```bash
tail -n 20 /opt/homelab/logs/kb-mail-sync/run-$(date +%Y%m%d).log
docker exec kb-postgres psql -U kb -d kb -c \
"SELECT account, folder, last_uid, last_sync_ts FROM mail_sync_state ORDER BY 1,2;"
```
`last_sync_ts` ma się przesuwać co godzinę **nawet gdy nic nie przyszło** — to jest odpowiedź
na pytanie „czy poller w ogóle działa", niezależna od tego, czy ktoś do nas napisał.
### Folder stanął — jedna wiadomość blokuje kursor
Objaw: `errors` niezerowe co tick, `last_uid` w `mail_sync_state` stoi, w logu ten sam UID
przy `skip.fetch_error` / `skip.store_error`.
To jest zaprojektowane zachowanie: kursor nie przeskakuje wiadomości, której nie udało się
zapisać (przeskakiwanie = ciche gubienie poczty). Wiadomości *po* niej i tak lądują
w archiwum i w bazie — blokada dotyczy wyłącznie kursora.
Jeśli wiadomość jest trwale nie do pobrania, przestaw kursor ręcznie o jeden:
```bash
docker exec kb-postgres psql -U kb -d kb -c \
"UPDATE mail_sync_state SET last_uid = <zly_uid> WHERE account='gmail' AND folder='<folder>';"
```
Zanotuj w `kb/incidents/`, którego maila świadomie pominięto.
### Serwer unieważnił UIDVALIDITY
W logu `imap.uidvalidity_reset`, w metrykach `kb_mail_sync_uidvalidity_resets{account} = 1`.
Job sam robi pełne przemiecenie folderu i opiera się na dedupie po Message-ID — nie ma tu nic
do zrobienia ręcznie. Tick będzie dłuższy i `envelopes_skipped_dup` skoczy do rozmiaru
folderu; to jest poprawny przebieg, nie awaria.
### Rotacja hasła aplikacji
Odwołaj stare u dostawcy, wydaj nowe, podmień w `/opt/homelab/kb/.env`, `systemctl start
kb-mail-sync.service`. Restart timera nie jest potrzebny — `EnvironmentFile` czytany jest przy
każdym starcie serwisu. Nic w stanie synca nie zależy od poświadczeń.
### Dołożenie folderu
Dopisz go do `MAIL_<KONTO>_FOLDERS` w `.env`. Nowy folder nie ma wiersza w `mail_sync_state`,
więc obowiązuje go `INITIAL_MODE` tego konta — jeśli chcesz jego historię, przestaw na `full`
**na jeden run**, potem wróć do poprzedniej wartości (istniejące foldery mają już kursory
i zmiana ich nie dotyczy). Zawartość pokrywająca się z innym folderem niczego nie zduplikuje.
## Testy
```bash
pip install -e "jobs/mail-imap-sync[dev]"
cd jobs/mail-imap-sync && pytest
```
80 testów, bez sieci i bez bazy: `ImapClient` jest napędzany podstawionym połączeniem
`imaplib` (więc testowane jest prawdziwe parsowanie protokołu, nie mock adaptera), a
`envelope`/`mail_sync_state` są w pamięci. Pokrycie: nowe wiadomości, uniewaznienie
UIDVALIDITY, dedup na trzech warstwach, wznowienie po przerwaniu, kolizja Message-ID między
kontami, izolacja kont, wygasłe UID-y, dry-run bez sieci, przenoszenie
`last_success_timestamp` przez nieudany run.
W worktree bez gotowego venva:
```bash
python3 -m venv /tmp/kbvenv && /tmp/kbvenv/bin/pip install -q pytest pytest-asyncio \
-e packages/kb-mail/ -e jobs/mail-imap-sync/
/tmp/kbvenv/bin/python -m pytest jobs/mail-imap-sync/tests packages/kb-mail/tests -q
```