homelab-codex-ws/kb/services/job-gmail-header-backfill.md
oskar 6b85c7ef68 feat(kb): SPLIT service+runbook — 10 serwisow -> 20 dokumentow
Wzorzec mechaniczny: sekcje deploy/verify/install/testy wycinane do
kb/runbooks/<serwis>-*.md, reszta zostaje dokumentem type: service.
Wzajemne `links` w obie strony. Tresc sekcji nietknieta — przenoszone
doslownie, dodany wylacznie naglowek H1 nowego runbooka.

kb-query, paperless-worker, planner-agent, ha-diag-agent, ollama-piha,
narty27, home-assistant, ha-mcp, job-gmail-header-backfill, job-mail-body-ingest.

Weryfikacja: dla kazdego pliku multizbior niepustych linii
(main + runbook) == oryginal z HEAD. Zero zgubionych, zero dodanych.

Recon szacowal 13 splitow service+runbook; faktycznie 2-typowych jest 10,
pozostale 5 (paperless, nextcloud, gokapi, fleet-prometheus, deploy-runner)
sa 3-typowe i ida osobno jako splity wielotypowe.

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

172 lines
7.7 KiB
Markdown
Raw 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: service
visibility: private
status: active
updated: 2026-07-14
links:
- ../runbooks/gmail-header-backfill-run.md
---
# gmail-header-backfill
One-shot job, module 5 phase 2 backfill (`docs/kb/modules/05-faza2-plan.md`, §5).
Backfills `{"type": "headers", ...}` onto the 225 030 existing `source='gmail'`
envelope rows in kb-postgres, which today carry only an attachment manifest
(`{"type": "attachment", ...}`) — no `from`/`to`/`cc`/`delivered_to`/`subject`
anywhere in the DB (plan §1.8). This job does **not** touch `document_chunk` or
any non-`entities` data, and never rewrites existing `entities` elements — it
only appends one new element per envelope.
## Why a separate job, not an extension of gmail-bulk-import
`gmail-bulk-import` has INSERT semantics (new envelopes from mbox/Takeout),
already run once, historical. This job has UPDATE semantics against the
225 030 rows already in production — different risk profile, different
lifecycle. See plan §2 decision 7.
## Where it runs
**Locally on PIHA**, as a plain CLI (not a container) — same reasoning as
`documents-ingest`: it needs simultaneous local filesystem access to the mail
archive (`/home/oskar/kb/mail/archive`) and a DB connection to kb-postgres.
Install (from repo root, on PIHA):
```bash
pip install -e jobs/gmail-header-backfill/
```
## Source of headers: the archived `.eml`, not the original mbox
Same access pattern as `documents-ingest`: `archive_root / row["raw_ref"]` ->
`read_bytes()` -> `email.message_from_bytes(raw, policy=email.policy.default)`.
The `.eml` archive is the immutable raw layer (kb-00 rule #1) and carries the
full original headers — no need to go back to the source Takeout mbox.
Only headers are parsed — the job does **not** MIME-walk into attachment
parts (plan §5.1); that keeps per-message cost low relative to
`gmail-bulk-import`'s full import (which does walk attachments).
## `entities[type=headers]` shape (plan §4.1)
```json
{
"type": "headers",
"from": {"name": "WARTA", "address": "no-reply@warta.pl"},
"to": [{"name": "...", "address": "oskar@gmail.com"}],
"cc": [],
"delivered_to": ["oskar+alias@gmail.com", "oskar@gmail.com"],
"subject": "Twoja polisa OC/AC",
"date_raw": "Mon, 9 Jun 2026 12:34:56 +0200"
}
```
- `from`/`to`/`cc` are parsed to `{name, address}` via `email.policy.default`
(RFC 2047 encoded-word decoding) + `email.utils.getaddresses` on the
decoded text. `to`/`cc` are lists (comma-separated multi-address headers
are split); `from` is a single object or `null` — if a message carries more
than one `From:` header (malformed but seen in the wild), the first is used
and a `headers.multiple_from` warning is logged.
- `delivered_to` is a list of **raw, unparsed strings**`Delivered-To` can
repeat per hop, and every occurrence is kept (this is what identifies which
of Oskar's aliases received the message; motivation in plan §1.8).
- `date_raw` is the literal original `Date:` header text, taken from a
separate `email.policy.compat32` parse — `policy.default`'s structured
`DateHeader` reformats the value (corrects the weekday name, zero-pads the
day) rather than preserving what was actually in the file, and `date_raw`
exists specifically for byte-for-byte comparison/debug against `envelope.ts`
(already parsed at import time — this is not a duplicate source of truth).
- Malformed/undecodable headers never raise — they degrade to best-effort
text or are skipped, logged, and counted; the row is left for a future run
rather than half-updated.
## Fallback parse (`parse_headers_fallback`)
The full 225 030-row run (2026-07) left 9 envelopes whose headers the typed
`policy.default` parse rejects outright (diagnosis 2026-07-14):
- 7× `ValueError: address parts cannot contain CR or LF` — an RFC 2047
encoded-word decoding to text with a newline (`=0A`) inside a display
name, e.g. `Rekrutacja z =?utf-8?Q?ExampleCorp=0A?= <mailing@example.pl>`
- 1× `AttributeError: 'Group' object has no attribute 'local_part'` — RFC
5322 group syntax: `To: unlisted-recipients:; (no To-header on input)`
- 1× `AttributeError: 'str' object has no attribute 'token_type'` — a
malformed display name (CPython parser bug, fixed in newer versions but
present on PIHA's 3.11)
When the typed parse raises, the job retries with `parse_headers_fallback`:
a pure `policy.compat32` parse where address fields are split into
`{name, address}` by `email.utils.getaddresses` over the **raw** header text
(no RFC 2047 decoding — names may keep literal `=?...?=` encoded-words), and
subject/`delivered_to`/`date_raw` stay raw strings. The written entity has
exactly the same §4.1 shape — only parse **quality** degrades, never the
schema. String values are sanitized so no lone surrogates (which postgres
jsonb rejects) can reach the DB.
Fallback successes are counted separately as `parsed_fallback` (a labeled
subset of `updated`) and logged per row (`headers.parsed_fallback` with the
original typed error) — never silently mixed into ordinary successes. Only
when the fallback **also** fails does the row count as `parse_errors`.
## Stats must balance — no silent skips
Every scanned row lands in exactly one bucket, and `run_complete` /
`summary` must satisfy:
```
scanned = updated + already_has_headers + parse_errors + read_errors
+ missing_file
```
A missing `.eml` is its own counter (`missing_file`) with a per-row
`skip.missing_file` info log carrying the envelope id and the expected
path — a full run can no longer lose rows without a trace. Any non-zero
`parse_errors`/`read_errors`/`missing_file` makes the CLI exit 1.
## Idempotency and resumability (plan §5.2)
```sql
UPDATE envelope
SET entities = entities || $2::jsonb
WHERE id = $1
AND NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(entities) e WHERE e->>'type' = 'headers'
);
```
Rows that already carry a `headers` entity are skipped (checked client-side
before building the batch, and enforced again at the SQL level as
defense-in-depth). Re-running any slice — including after a crash mid-batch —
is always safe: already-backfilled rows are no-ops, not double-appended.
Writes are batched: rows are queued in memory and flushed via
`conn.executemany` every 500 rows (one round-trip per batch, not one
transaction per row) — mirrors `gmail-bulk-import`'s `_insert_batch` pattern.
`--limit`/`--offset` partition by `ORDER BY id`, not by "rows still missing
headers" — this keeps a given `--offset` naming the same slice of the table
across repeated runs, so progress is easy to reason about (e.g. "slices 0,
1000, 2000, ... cover the whole table") independent of how much of it is
already done.
## Performance estimate (not measured — job didn't exist before this change)
Per plan §5.3: ~225 030 files, ~124 KB average. Each row costs an `open` +
`read` + header-only parse (no MIME-walk of attachments) + a batched UPDATE.
The full `gmail-bulk-import` run (parsing the whole mbox, including
attachment MIME-walk and inserts) took ~29 minutes. This job does less
per-message work but pays for opening 225k small files individually instead
of streaming one mbox — the plan's estimate is "same order of magnitude,
likely tens of minutes." Not measured directly in this change — see the
DoD note below.
## Definition of Done
Per `CLAUDE.md`: this job's smoke run is `gmail-header-backfill --dsn ...
--limit 100` (dry-run first, then `--apply` against a small slice) — run
against kb-postgres@PIHA over SSH, **not executed as part of this change**
without operator confirmation (UPDATE against production data). `pytest`
passes locally (33/33) before this commit; full 225 030-row backfill is out
of scope for this change — see the plan for the rollout sequence.