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 15:04:29 +02:00
|
|
|
|
---
|
|
|
|
|
|
okf: "0.1"
|
|
|
|
|
|
type: service
|
|
|
|
|
|
visibility: private
|
|
|
|
|
|
status: active
|
|
|
|
|
|
updated: 2026-07-14
|
|
|
|
|
|
links:
|
|
|
|
|
|
- ../runbooks/gmail-header-backfill-run.md
|
|
|
|
|
|
---
|
|
|
|
|
|
|
feat(gmail-header-backfill): one-shot job — backfill headers into envelope.entities
Module 5 phase 2 (docs/kb/modules/05-faza2-plan.md, §5). 225,030 existing
source='gmail' envelope rows carry only an attachment manifest — no
from/to/cc/delivered_to/subject anywhere in the DB, which blocks answering
"is this on me or my wife" / distinguishing aliases (plan §1.8). Separate
job from gmail-bulk-import per plan §2 decision 7: UPDATE semantics against
production data is a different risk profile than the historical INSERT job.
Parses headers only (no MIME-walk of attachments) from the archived .eml
files, appends {"type": "headers", ...} (plan §4.1) via the idempotent
UPDATE ... WHERE NOT EXISTS from §5.2, batched via executemany. --limit/
--offset (ORDER BY id) give stable, deterministic partitioning so the full
backfill can run and be verified in slices instead of one unattended pass.
Plain CLI (pip install -e), no Dockerfile — same convention as
gmail-bulk-import/documents-ingest, which run directly on PIHA for local
filesystem access to the .eml archive.
Verified against kb-postgres@PIHA (100-row dry-run + apply, re-run proved
idempotent no-op, 1000-row timed slice): 1096/225030 rows backfilled,
996/1000 succeeded on the timed slice (4 parse_errors — a 2014 spam message
with an RFC 2047 encoded-word decoding to an embedded newline in the From
display name, correctly caught and skipped rather than crashing the batch).
Extrapolated full-run time ~16 minutes. Full 225,030-row run is out of
scope for this change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 15:34:07 +02:00
|
|
|
|
# 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.
|
|
|
|
|
|
|
fix(gmail-header-backfill): compat32 fallback + missing_file stat — 8-bit Date header crashed json.dumps mid-slice, losing 4999 rows
Diagnosis of the 5008 header-less envelopes after the full 225 030-row run
(read-only, on PIHA, 2026-07-14):
- 4999 = one contiguous block at ORDER BY id positions 70001-74999: the
--offset 70000 slice died mid-run. Root cause reproduced: a Date: header
with raw 8-bit bytes makes compat32 .get() return email.header.Header
(not str), and json.dumps([headers]) — which sat OUTSIDE the per-row
try/except — raised TypeError and killed the process, silently losing
the rest of the slice.
- 9 = genuine typed-parse failures: 7x RFC 2047 encoded-word decoding to
CR/LF inside a display name (ValueError in headerregistry), 1x RFC 5322
group syntax in To: ("unlisted-recipients:;"), 1x CPython
_header_value_parser bug on a malformed display name (fixed upstream,
present on PIHA's 3.11).
- 0 missing .eml files.
Fixes:
- date_raw: str() + surrogate sanitization on the compat32 value — the
crash cause, now also covered by a regression test.
- json.dumps moved inside the per-row try: a non-serializable value counts
as that row's parse_error instead of crashing the slice.
- parse_headers_fallback(): on typed-parse failure retry with a pure
compat32 parse — getaddresses over raw header text, raw-string values,
same §4.1 entity shape. Counted separately as parsed_fallback (labeled
subset of updated), logged per row with the original typed error.
- missing_file counter + skip.missing_file info log (id, expected path);
run_complete now balances: scanned = updated + already_has_headers +
parse_errors + read_errors + missing_file. Non-zero missing_file also
fails the exit code.
Verified: 43/43 pytest locally (3.13) and on PIHA (3.11); read-only dry
runs on PIHA — all 9 parse failures recover via fallback, the lost slice
completes scanned=5000 updated=4999 already_has_headers=1 with zero errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:14:11 +02:00
|
|
|
|
## 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.
|
|
|
|
|
|
|
feat(gmail-header-backfill): one-shot job — backfill headers into envelope.entities
Module 5 phase 2 (docs/kb/modules/05-faza2-plan.md, §5). 225,030 existing
source='gmail' envelope rows carry only an attachment manifest — no
from/to/cc/delivered_to/subject anywhere in the DB, which blocks answering
"is this on me or my wife" / distinguishing aliases (plan §1.8). Separate
job from gmail-bulk-import per plan §2 decision 7: UPDATE semantics against
production data is a different risk profile than the historical INSERT job.
Parses headers only (no MIME-walk of attachments) from the archived .eml
files, appends {"type": "headers", ...} (plan §4.1) via the idempotent
UPDATE ... WHERE NOT EXISTS from §5.2, batched via executemany. --limit/
--offset (ORDER BY id) give stable, deterministic partitioning so the full
backfill can run and be verified in slices instead of one unattended pass.
Plain CLI (pip install -e), no Dockerfile — same convention as
gmail-bulk-import/documents-ingest, which run directly on PIHA for local
filesystem access to the .eml archive.
Verified against kb-postgres@PIHA (100-row dry-run + apply, re-run proved
idempotent no-op, 1000-row timed slice): 1096/225030 rows backfilled,
996/1000 succeeded on the timed slice (4 parse_errors — a 2014 spam message
with an RFC 2047 encoded-word decoding to an embedded newline in the From
display name, correctly caught and skipped rather than crashing the batch).
Extrapolated full-run time ~16 minutes. Full 225,030-row run is out of
scope for this change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 15:34:07 +02:00
|
|
|
|
## 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.
|