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>
13 KiB
| okf | type | visibility | status | updated | links | |||
|---|---|---|---|---|---|---|---|---|
| 0.1 | service | private | active | 2026-08-06 |
|
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:
- EXAMINE the folder — read-only. Never
SELECT, and bodies come viaBODY.PEEK[], so the job never sets\Seenon the operator's own mail. - Plan the tick from
mail_sync_stateand the server's UIDVALIDITY/UIDNEXT — first contact, ordinary increment, or a UIDVALIDITY reset (see below). - UID SEARCH for the UIDs this tick owns.
- Per UID, ascending: FETCH →
save_eml()(append-only;FileExistsErrorjust means "already have it") →insert_envelope()withentities = [headers, attachment…]. - 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.promanddocuments_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.timerjoins the "shadow-deploy family" — units installed outside GitOps drift detection — listed inhosts/piha/jobs.yaml. That is open question 5 ofkb/subsystems/recon-multiagent.md; the list is now one item longer, knowingly.