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 (docs/kb/modules/05-faza2-plan.md, §5).
|
|
|
|
|
|
|
|
|
|
Backfills `{"type": "headers", ...}` (§4.1) onto the 225 030 existing `source='gmail'`
|
|
|
|
|
envelope rows in kb-postgres, which today carry only an attachment manifest. This is an
|
|
|
|
|
UPDATE against production data (not an INSERT like gmail-bulk-import) — a deliberately
|
|
|
|
|
separate job from gmail-bulk-import, see plan §2 decision 7.
|
|
|
|
|
|
|
|
|
|
Runs on PIHA (needs local access to the .eml archive):
|
|
|
|
|
|
|
|
|
|
Install (from repo root):
|
|
|
|
|
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 (stable, deterministic partitioning by id order):
|
|
|
|
|
gmail-header-backfill --dsn ... --limit 1000 --offset 1000 --apply
|
|
|
|
|
|
|
|
|
|
DSN can also come from the KB_DSN env var instead of --dsn.
|
|
|
|
|
|
|
|
|
|
Idempotency: the UPDATE only touches rows that don't already carry a `headers` entity
|
|
|
|
|
(§5.2 `WHERE NOT EXISTS`), so re-running any slice (including after a crash) is safe —
|
|
|
|
|
already-backfilled rows are skipped, not double-appended.
|
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
|
|
|
|
|
|
|
|
Messages the typed (policy.default) parse rejects fall back to a degraded
|
|
|
|
|
compat32/raw-string parse (parse_headers_fallback) — same §4.1 entity shape, counted
|
|
|
|
|
separately as `parsed_fallback`, never silently mixed into ordinary successes.
|
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
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import asyncio
|
|
|
|
|
import email
|
|
|
|
|
import email.policy
|
|
|
|
|
import email.utils
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
import asyncpg
|
|
|
|
|
import structlog
|
|
|
|
|
|
fix(gmail-bulk-import): harden re-import against 8-bit headers + poison batches
Four audit findings (2026-07-14), each reproduced on crafted mboxes; the
Takeout corpus has proven 8-bit header bytes, so all are real re-import risks.
1. Whole-run crash on 8-bit Message-ID. compat32 .get() returns an
email.header.Header (not str) for raw 8-bit bytes; the old
Header.strip() raised AttributeError. _message_id ran BEFORE the
per-message try, so one bad header killed the entire import.
Fix: str() + sanitize_surrogates() before strip; and move
_message_id/_parse_date/_parse_attachments INSIDE the per-message try —
a broken message is now errors += 1, never run death.
2. Poison batch. pending.clear() ran only AFTER a successful insert, so a
failed flush (DB down / bad row) left pending intact and every later
message re-flushed the doomed batch; the final flush sat in try/finally
with no except and propagated out, losing all stats. Fix: _flush always
clears pending and counts a failed insert as db_insert_failed; the run
always reaches import_complete.
3. Stats didn't reconcile with the DB. imported counts archive writes, not
DB rows, so a partial-insert drift was invisible. Fix: separate
db_inserted/db_insert_failed counters; main() exits non-zero on any
error, DB drift, or a processed = imported + skipped + errors imbalance.
4. 8-bit Date → needless epoch_fallback. parsedate_to_datetime(Header)
raised even when str(header) parses fine. Fix: str() before the epoch
fallback.
Shared helper: _sanitize moved from gmail-header-backfill into
packages/kb-mail (kb_mail.text.sanitize_surrogates) and used by both jobs;
gmail-header-backfill now depends on kb-mail.
Tests: regression coverage for all four findings in gmail-bulk-import
(8-bit id/date, per-message guard, failed-insert non-poisoning, stats
balance) plus kb_mail.text unit tests. Full suites green:
kb-mail 27, gmail-bulk-import 33, gmail-header-backfill 43.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:51:35 +02:00
|
|
|
from kb_mail.text import sanitize_surrogates as _sanitize
|
|
|
|
|
|
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
|
|
|
_log = structlog.get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
DEFAULT_ARCHIVE_ROOT = Path("/home/oskar/kb/mail/archive")
|
|
|
|
|
DEFAULT_LIMIT = 1000
|
|
|
|
|
UPDATE_BATCH_SIZE = 500
|
|
|
|
|
|
|
|
|
|
_UPDATE_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'
|
|
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_headers(raw: bytes) -> dict:
|
|
|
|
|
"""Parse Gmail .eml headers into the `{"type": "headers", ...}` shape (plan §4.1).
|
|
|
|
|
|
|
|
|
|
`from`/`to`/`cc` are decoded via `email.policy.default` (resolves RFC 2047
|
|
|
|
|
encoded-words to real Unicode) and then split into {name, address} pairs with
|
|
|
|
|
`email.utils.getaddresses`. `delivered_to` is kept as a list of raw (decoded but
|
|
|
|
|
unparsed) strings — `Delivered-To` may repeat per hop, and every occurrence is
|
|
|
|
|
kept (see plan §1.8/§4.1). `date_raw` comes from a separate compat32 parse: the
|
|
|
|
|
default policy's DateHeader reformats the header (e.g. corrects the weekday name,
|
|
|
|
|
zero-pads the day) instead of preserving the literal original text.
|
|
|
|
|
"""
|
|
|
|
|
msg = email.message_from_bytes(raw, policy=email.policy.default)
|
|
|
|
|
msg_compat = email.message_from_bytes(raw, policy=email.policy.compat32)
|
|
|
|
|
|
|
|
|
|
from_headers = msg.get_all("From") or []
|
|
|
|
|
if len(from_headers) > 1:
|
|
|
|
|
_log.warning("headers.multiple_from", count=len(from_headers))
|
|
|
|
|
|
|
|
|
|
from_obj: Optional[dict] = None
|
|
|
|
|
if from_headers:
|
|
|
|
|
from_addrs = email.utils.getaddresses([str(from_headers[0])])
|
|
|
|
|
if from_addrs and from_addrs[0][1]:
|
|
|
|
|
name, address = from_addrs[0]
|
|
|
|
|
from_obj = {"name": name or None, "address": address}
|
|
|
|
|
|
|
|
|
|
to_list = [
|
|
|
|
|
{"name": name or None, "address": address}
|
|
|
|
|
for name, address in email.utils.getaddresses(
|
|
|
|
|
[str(h) for h in (msg.get_all("To") or [])]
|
|
|
|
|
)
|
|
|
|
|
if address
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
cc_list = [
|
|
|
|
|
{"name": name or None, "address": address}
|
|
|
|
|
for name, address in email.utils.getaddresses(
|
|
|
|
|
[str(h) for h in (msg.get_all("Cc") or [])]
|
|
|
|
|
)
|
|
|
|
|
if address
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
delivered_to = [str(h) for h in (msg.get_all("Delivered-To") or [])]
|
|
|
|
|
|
|
|
|
|
subject_header = msg.get("Subject")
|
|
|
|
|
subject = str(subject_header) if subject_header is not None else None
|
|
|
|
|
|
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
|
|
|
# compat32 .get() returns an email.header.Header (not str) when the raw
|
|
|
|
|
# value contains 8-bit bytes — str() + _sanitize keeps it JSON/jsonb-safe.
|
|
|
|
|
# An unguarded Header here crashed the original full run mid-slice
|
|
|
|
|
# (TypeError at json.dumps), silently losing the rest of the slice.
|
|
|
|
|
date_header = msg_compat.get("Date")
|
|
|
|
|
date_raw = _sanitize(str(date_header)) if date_header is not None else None
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"type": "headers",
|
|
|
|
|
"from": from_obj,
|
|
|
|
|
"to": to_list,
|
|
|
|
|
"cc": cc_list,
|
|
|
|
|
"delivered_to": delivered_to,
|
|
|
|
|
"subject": subject,
|
|
|
|
|
"date_raw": date_raw,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_headers_fallback(raw: bytes) -> dict:
|
|
|
|
|
"""Degraded parse for messages the typed (policy.default) path rejects.
|
|
|
|
|
|
|
|
|
|
Real-world triggers found in the archive (diagnosis 2026-07-14, 9 of
|
|
|
|
|
225 030): RFC 2047 encoded-words that decode to text with CR/LF in a
|
|
|
|
|
display name (ValueError in headerregistry), group syntax in To:
|
|
|
|
|
("unlisted-recipients:;"), and a _header_value_parser bug on malformed
|
|
|
|
|
display names (fixed in newer CPython, present on PIHA's 3.11).
|
|
|
|
|
|
|
|
|
|
Everything comes from a compat32 parse: address fields are split into
|
|
|
|
|
{name, address} by email.utils.getaddresses over the RAW header text — no
|
|
|
|
|
RFC 2047 decoding, so names may keep literal =?...?= encoded-words.
|
|
|
|
|
Subject and delivered_to stay raw strings too. The returned shape is
|
|
|
|
|
exactly parse_headers()'s §4.1 shape — only parse QUALITY degrades, never
|
|
|
|
|
the schema. Callers count these separately (stats["parsed_fallback"]).
|
|
|
|
|
"""
|
|
|
|
|
msg = email.message_from_bytes(raw, policy=email.policy.compat32)
|
|
|
|
|
|
|
|
|
|
from_headers = [str(h) for h in (msg.get_all("From") or [])]
|
|
|
|
|
if len(from_headers) > 1:
|
|
|
|
|
_log.warning("headers.multiple_from", count=len(from_headers))
|
|
|
|
|
|
|
|
|
|
from_obj: Optional[dict] = None
|
|
|
|
|
if from_headers:
|
|
|
|
|
from_addrs = email.utils.getaddresses([from_headers[0]])
|
|
|
|
|
if from_addrs and from_addrs[0][1]:
|
|
|
|
|
name, address = from_addrs[0]
|
|
|
|
|
from_obj = {"name": _sanitize(name) or None, "address": _sanitize(address)}
|
|
|
|
|
|
|
|
|
|
to_list = [
|
|
|
|
|
{"name": _sanitize(name) or None, "address": _sanitize(address)}
|
|
|
|
|
for name, address in email.utils.getaddresses(
|
|
|
|
|
[str(h) for h in (msg.get_all("To") or [])]
|
|
|
|
|
)
|
|
|
|
|
if address
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
cc_list = [
|
|
|
|
|
{"name": _sanitize(name) or None, "address": _sanitize(address)}
|
|
|
|
|
for name, address in email.utils.getaddresses(
|
|
|
|
|
[str(h) for h in (msg.get_all("Cc") or [])]
|
|
|
|
|
)
|
|
|
|
|
if address
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
delivered_to = [_sanitize(str(h)) for h in (msg.get_all("Delivered-To") or [])]
|
|
|
|
|
|
|
|
|
|
subject_header = msg.get("Subject")
|
|
|
|
|
subject = _sanitize(str(subject_header)) if subject_header is not None else None
|
|
|
|
|
|
|
|
|
|
date_raw = _sanitize(msg.get("Date"))
|
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
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"type": "headers",
|
|
|
|
|
"from": from_obj,
|
|
|
|
|
"to": to_list,
|
|
|
|
|
"cc": cc_list,
|
|
|
|
|
"delivered_to": delivered_to,
|
|
|
|
|
"subject": subject,
|
|
|
|
|
"date_raw": date_raw,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _decode_jsonb(value: object) -> object:
|
|
|
|
|
"""asyncpg may return jsonb as a str or an already-decoded object."""
|
|
|
|
|
if value is None:
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return json.loads(value)
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _has_headers(entities: list) -> bool:
|
|
|
|
|
return any(isinstance(e, dict) and e.get("type") == "headers" for e in entities)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_batch(conn: asyncpg.Connection, limit: int, offset: int) -> list:
|
|
|
|
|
"""One deterministic slice of `source='gmail'` envelopes, ordered by id.
|
|
|
|
|
|
|
|
|
|
Ordering by id (not filtering out already-backfilled rows here) keeps --offset
|
|
|
|
|
stable across runs — the same --limit/--offset always names the same slice of the
|
|
|
|
|
225 030-row table, so partial runs can be resumed or re-verified predictably.
|
|
|
|
|
Idempotency itself is enforced by the UPDATE's WHERE NOT EXISTS (see _UPDATE_SQL).
|
|
|
|
|
"""
|
|
|
|
|
return await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT id, raw_ref, entities
|
|
|
|
|
FROM envelope
|
|
|
|
|
WHERE source = 'gmail'
|
|
|
|
|
ORDER BY id
|
|
|
|
|
LIMIT $1 OFFSET $2
|
|
|
|
|
""",
|
|
|
|
|
limit,
|
|
|
|
|
offset,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _update_batch(conn: asyncpg.Connection, rows: list[tuple[str, str]]) -> None:
|
|
|
|
|
"""Multi-row UPDATE for one batch — a single executemany round, not one transaction per row."""
|
|
|
|
|
if not rows:
|
|
|
|
|
return
|
|
|
|
|
await conn.executemany(_UPDATE_SQL, rows)
|
|
|
|
|
_log.debug("update_batch_flushed", count=len(rows))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def run(
|
|
|
|
|
dsn: str,
|
|
|
|
|
archive_root: Path = DEFAULT_ARCHIVE_ROOT,
|
|
|
|
|
limit: int = DEFAULT_LIMIT,
|
|
|
|
|
offset: int = 0,
|
|
|
|
|
apply: bool = False,
|
|
|
|
|
) -> dict[str, int]:
|
|
|
|
|
"""Backfill headers for one --limit/--offset slice of `source='gmail'` envelopes.
|
|
|
|
|
|
|
|
|
|
dry-run (apply=False) parses and counts everything without writing to the DB.
|
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
|
|
|
Returns stats that must always balance:
|
|
|
|
|
|
|
|
|
|
scanned = updated + already_has_headers + parse_errors + read_errors
|
|
|
|
|
+ missing_file
|
|
|
|
|
|
|
|
|
|
`parsed_fallback` is a labeled SUBSET of `updated` (rows recovered by
|
|
|
|
|
parse_headers_fallback() after the typed parse raised), not a separate
|
|
|
|
|
outcome — it exists so degraded parses are never silently mixed into
|
|
|
|
|
ordinary successes. Anomalies that aren't errors (e.g. more than one
|
|
|
|
|
From: header) are logged (`headers.multiple_from`) rather than counted.
|
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
|
|
|
"""
|
|
|
|
|
stats = {
|
|
|
|
|
"scanned": 0,
|
|
|
|
|
"already_has_headers": 0,
|
|
|
|
|
"updated": 0,
|
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
|
|
|
"parsed_fallback": 0,
|
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
|
|
|
"read_errors": 0,
|
|
|
|
|
"parse_errors": 0,
|
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
|
|
|
"missing_file": 0,
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conn = await asyncpg.connect(dsn)
|
|
|
|
|
try:
|
|
|
|
|
rows = await fetch_batch(conn, limit, offset)
|
|
|
|
|
|
|
|
|
|
pending: list[tuple[str, str]] = []
|
|
|
|
|
|
|
|
|
|
async def _flush() -> None:
|
|
|
|
|
if apply:
|
|
|
|
|
await _update_batch(conn, pending)
|
|
|
|
|
pending.clear()
|
|
|
|
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
stats["scanned"] += 1
|
|
|
|
|
envelope_id = row["id"]
|
|
|
|
|
entities = _decode_jsonb(row["entities"]) or []
|
|
|
|
|
|
|
|
|
|
if _has_headers(entities):
|
|
|
|
|
stats["already_has_headers"] += 1
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
eml_path = archive_root / row["raw_ref"]
|
|
|
|
|
try:
|
|
|
|
|
raw = eml_path.read_bytes()
|
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
|
|
|
except FileNotFoundError:
|
|
|
|
|
_log.info("skip.missing_file", envelope_id=envelope_id,
|
|
|
|
|
expected_path=str(eml_path))
|
|
|
|
|
stats["missing_file"] += 1
|
|
|
|
|
continue
|
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
|
|
|
except OSError:
|
|
|
|
|
_log.warning("skip.read_error", envelope_id=envelope_id, path=str(eml_path))
|
|
|
|
|
stats["read_errors"] += 1
|
|
|
|
|
continue
|
|
|
|
|
|
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
|
|
|
# json.dumps stays INSIDE the try: a non-serializable header value
|
|
|
|
|
# must count as this row's parse_error, not crash the whole slice
|
|
|
|
|
# (that's exactly how the original full run lost 4999 rows).
|
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
|
|
|
try:
|
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
|
|
|
patch = json.dumps([parse_headers(raw)])
|
|
|
|
|
except Exception as typed_exc:
|
|
|
|
|
try:
|
|
|
|
|
patch = json.dumps([parse_headers_fallback(raw)])
|
|
|
|
|
except Exception:
|
|
|
|
|
_log.warning("skip.parse_error", envelope_id=envelope_id, exc_info=True)
|
|
|
|
|
stats["parse_errors"] += 1
|
|
|
|
|
continue
|
|
|
|
|
_log.info("headers.parsed_fallback", envelope_id=envelope_id,
|
|
|
|
|
typed_error=repr(typed_exc))
|
|
|
|
|
stats["parsed_fallback"] += 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
|
|
|
|
|
|
|
|
stats["updated"] += 1
|
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
|
|
|
pending.append((envelope_id, patch))
|
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
|
|
|
if len(pending) >= UPDATE_BATCH_SIZE:
|
|
|
|
|
await _flush()
|
|
|
|
|
|
|
|
|
|
await _flush()
|
|
|
|
|
finally:
|
|
|
|
|
await conn.close()
|
|
|
|
|
|
|
|
|
|
_log.info("run_complete", apply=apply, limit=limit, offset=offset, **stats)
|
|
|
|
|
return stats
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description="Backfill {'type': 'headers', ...} onto existing source='gmail' "
|
|
|
|
|
"envelope rows in kb-postgres (module 5, phase 2 backfill — plan §5)."
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"),
|
|
|
|
|
help="asyncpg DSN for kb-postgres (or set KB_DSN env var)")
|
|
|
|
|
parser.add_argument("--archive-root", type=Path, default=DEFAULT_ARCHIVE_ROOT,
|
|
|
|
|
help=f"Mail .eml archive root (default: {DEFAULT_ARCHIVE_ROOT})")
|
|
|
|
|
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT,
|
|
|
|
|
help=f"Envelopes in this slice (default: {DEFAULT_LIMIT})")
|
|
|
|
|
parser.add_argument("--offset", type=int, default=0,
|
|
|
|
|
help="Slice offset, ordered by envelope id (default: 0)")
|
|
|
|
|
parser.add_argument("--apply", action="store_true",
|
|
|
|
|
help="Actually write the UPDATE. Default is dry-run (parse + count only).")
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
if not args.dsn:
|
|
|
|
|
_log.error("missing_dsn", hint="pass --dsn or set KB_DSN")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
if not args.archive_root.is_dir():
|
|
|
|
|
_log.error("archive_root_not_found", path=str(args.archive_root))
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
stats = asyncio.run(
|
|
|
|
|
run(
|
|
|
|
|
dsn=args.dsn,
|
|
|
|
|
archive_root=args.archive_root,
|
|
|
|
|
limit=args.limit,
|
|
|
|
|
offset=args.offset,
|
|
|
|
|
apply=args.apply,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
mode = "APPLY" if args.apply else "DRY-RUN"
|
|
|
|
|
_log.info("summary", mode=mode, **stats)
|
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
|
|
|
failures = stats["read_errors"] + stats["parse_errors"] + stats["missing_file"]
|
|
|
|
|
sys.exit(1 if failures > 0 else 0)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|