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>
7 KiB
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):
pip install -e jobs/gmail-header-backfill/
Usage
# Dry run (default) — parse and count only, no DB writes:
gmail-header-backfill --dsn postgresql://kb:<pw>@localhost:5433/kb --limit 100
# Real run — apply the UPDATE for this slice:
gmail-header-backfill --dsn ... --limit 1000 --offset 0 --apply
# Next slice — offset is stable/deterministic (ORDER BY id), independent of
# how many rows in earlier slices were already backfilled:
gmail-header-backfill --dsn ... --limit 1000 --offset 1000 --apply
DSN can also come from the KB_DSN env var instead of --dsn.
--limit/--offset exist so the full 225 030-row backfill can be run in
verifiable partitions instead of one long unattended run (plan §5.3) — start
small (--limit 100), check the result in the DB, then widen.
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)
{
"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/ccare parsed to{name, address}viaemail.policy.default(RFC 2047 encoded-word decoding) +email.utils.getaddresseson the decoded text.to/ccare lists (comma-separated multi-address headers are split);fromis a single object ornull— if a message carries more than oneFrom:header (malformed but seen in the wild), the first is used and aheaders.multiple_fromwarning is logged.delivered_tois a list of raw, unparsed strings —Delivered-Tocan 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_rawis the literal originalDate:header text, taken from a separateemail.policy.compat32parse —policy.default's structuredDateHeaderreformats the value (corrects the weekday name, zero-pads the day) rather than preserving what was actually in the file, anddate_rawexists specifically for byte-for-byte comparison/debug againstenvelope.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.
Idempotency and resumability (plan §5.2)
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.
Tests
pip install -e jobs/gmail-header-backfill/
cd jobs/gmail-header-backfill && pytest
Pure unit tests, no DB or filesystem outside tmp_path/synthetic .eml
bytes — run() is tested by monkeypatching asyncpg.connect with an
in-memory fake connection. Covers: header parsing (multi-address To/Cc,
quoted display names with commas, multiple Delivered-To occurrences, RFC
2047 encoded-words including Polish diacritics, malformed encoded-words that
must not raise, missing/multiple From, date_raw preserving literal text
vs. Date header reformatting), idempotency (rows already carrying a
headers entity are skipped and never re-appended), batch flushing, and
--limit/--offset query shape.
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.