2026-06-24 22:34:53 +02:00
|
|
|
"""Unit tests for Gmail bulk importer — no DB, no external services required."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
refactor(kb-mail): importer Gmail — entities załączników, --limit, batch, bez Dockera, DSN→PIHA
- Usuwa Dockerfile i docker-compose.yml; job odpalany lokalnie na PIHA (pip install -e)
- _parse_attachments: manifest MIME → entities[]{type,filename,content_type,size,sha256};
bajty zostają w .eml, wyciąganie/OCR = faza 2
- Batch inserty co 500 wpisów (executemany + ON CONFLICT DO NOTHING); idempotentny
na skipped przez _eml_ref (mirrors archive._UNSAFE); pełna wznawialność
- --limit N: ucina pętlę po N wiadomościach do testów na próbce
- epoch_fallback: licznik + WARNING gdy Date nieparsowalne/brak
- Nowe stats: msgs_with_attachments, total_attachments, total_attachment_bytes
- DSN w docstringu: localhost:5433/kb i piha:5433/kb; usunięto solaria:5433
- 9 nowych testów (24 razem), wszystkie zielone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:37:27 +02:00
|
|
|
import hashlib
|
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
|
|
|
import json
|
2026-06-24 22:34:53 +02:00
|
|
|
import mailbox
|
|
|
|
|
from datetime import datetime, timezone
|
refactor(kb-mail): importer Gmail — entities załączników, --limit, batch, bez Dockera, DSN→PIHA
- Usuwa Dockerfile i docker-compose.yml; job odpalany lokalnie na PIHA (pip install -e)
- _parse_attachments: manifest MIME → entities[]{type,filename,content_type,size,sha256};
bajty zostają w .eml, wyciąganie/OCR = faza 2
- Batch inserty co 500 wpisów (executemany + ON CONFLICT DO NOTHING); idempotentny
na skipped przez _eml_ref (mirrors archive._UNSAFE); pełna wznawialność
- --limit N: ucina pętlę po N wiadomościach do testów na próbce
- epoch_fallback: licznik + WARNING gdy Date nieparsowalne/brak
- Nowe stats: msgs_with_attachments, total_attachments, total_attachment_bytes
- DSN w docstringu: localhost:5433/kb i piha:5433/kb; usunięto solaria:5433
- 9 nowych testów (24 razem), wszystkie zielone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:37:27 +02:00
|
|
|
from email import encoders
|
|
|
|
|
from email.mime.base import MIMEBase
|
|
|
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
|
from email.mime.text import MIMEText
|
2026-06-24 22:34:53 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
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 gmail_bulk_import import importer
|
refactor(kb-mail): importer Gmail — entities załączników, --limit, batch, bez Dockera, DSN→PIHA
- Usuwa Dockerfile i docker-compose.yml; job odpalany lokalnie na PIHA (pip install -e)
- _parse_attachments: manifest MIME → entities[]{type,filename,content_type,size,sha256};
bajty zostają w .eml, wyciąganie/OCR = faza 2
- Batch inserty co 500 wpisów (executemany + ON CONFLICT DO NOTHING); idempotentny
na skipped przez _eml_ref (mirrors archive._UNSAFE); pełna wznawialność
- --limit N: ucina pętlę po N wiadomościach do testów na próbce
- epoch_fallback: licznik + WARNING gdy Date nieparsowalne/brak
- Nowe stats: msgs_with_attachments, total_attachments, total_attachment_bytes
- DSN w docstringu: localhost:5433/kb i piha:5433/kb; usunięto solaria:5433
- 9 nowych testów (24 razem), wszystkie zielone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:37:27 +02:00
|
|
|
from gmail_bulk_import.importer import (
|
|
|
|
|
_message_id,
|
|
|
|
|
_parse_attachments,
|
|
|
|
|
_parse_date,
|
|
|
|
|
run_import,
|
|
|
|
|
)
|
2026-06-24 22:34:53 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_msg(headers: dict) -> mailbox.mboxMessage:
|
|
|
|
|
msg = mailbox.mboxMessage()
|
|
|
|
|
for k, v in headers.items():
|
|
|
|
|
msg[k] = v
|
|
|
|
|
return msg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_mbox(path: Path, msgs: list[mailbox.mboxMessage]) -> None:
|
|
|
|
|
mbox = mailbox.mbox(str(path), create=True)
|
|
|
|
|
for msg in msgs:
|
|
|
|
|
mbox.add(msg)
|
|
|
|
|
mbox.flush()
|
|
|
|
|
mbox.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _msg_with_id(mid: str = "<test-unique@example.com>") -> mailbox.mboxMessage:
|
|
|
|
|
return _make_msg({
|
|
|
|
|
"From": "alice@example.com",
|
|
|
|
|
"Message-ID": mid,
|
|
|
|
|
"Date": "Tue, 10 Jun 2025 12:00:00 +0000",
|
|
|
|
|
"Subject": "Hello",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
refactor(kb-mail): importer Gmail — entities załączników, --limit, batch, bez Dockera, DSN→PIHA
- Usuwa Dockerfile i docker-compose.yml; job odpalany lokalnie na PIHA (pip install -e)
- _parse_attachments: manifest MIME → entities[]{type,filename,content_type,size,sha256};
bajty zostają w .eml, wyciąganie/OCR = faza 2
- Batch inserty co 500 wpisów (executemany + ON CONFLICT DO NOTHING); idempotentny
na skipped przez _eml_ref (mirrors archive._UNSAFE); pełna wznawialność
- --limit N: ucina pętlę po N wiadomościach do testów na próbce
- epoch_fallback: licznik + WARNING gdy Date nieparsowalne/brak
- Nowe stats: msgs_with_attachments, total_attachments, total_attachment_bytes
- DSN w docstringu: localhost:5433/kb i piha:5433/kb; usunięto solaria:5433
- 9 nowych testów (24 razem), wszystkie zielone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:37:27 +02:00
|
|
|
def _mime_with_attachments(
|
|
|
|
|
mid: str = "<attach-test@example.com>",
|
|
|
|
|
payloads: list | None = None,
|
|
|
|
|
) -> mailbox.mboxMessage:
|
|
|
|
|
"""Build a multipart mboxMessage with binary attachments.
|
|
|
|
|
|
|
|
|
|
payloads: list of (filename, content_type, bytes). Defaults to PDF + PNG.
|
|
|
|
|
"""
|
|
|
|
|
if payloads is None:
|
|
|
|
|
payloads = [
|
|
|
|
|
("report.pdf", "application/pdf", b"PDF content here"),
|
|
|
|
|
("photo.png", "image/png", b"PNG bytes"),
|
|
|
|
|
]
|
|
|
|
|
mime = MIMEMultipart()
|
|
|
|
|
mime["From"] = "alice@example.com"
|
|
|
|
|
mime["To"] = "bob@example.com"
|
|
|
|
|
mime["Subject"] = "Files attached"
|
|
|
|
|
mime["Message-ID"] = mid
|
|
|
|
|
mime["Date"] = "Tue, 10 Jun 2025 12:00:00 +0000"
|
|
|
|
|
mime.attach(MIMEText("See attachments", "plain"))
|
|
|
|
|
for filename, ctype, data in payloads:
|
|
|
|
|
maintype, subtype = ctype.split("/", 1)
|
|
|
|
|
att = MIMEBase(maintype, subtype)
|
|
|
|
|
att.set_payload(data)
|
|
|
|
|
encoders.encode_base64(att)
|
|
|
|
|
att.add_header("Content-Disposition", "attachment", filename=filename)
|
|
|
|
|
mime.attach(att)
|
|
|
|
|
return mailbox.mboxMessage(mime)
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 22:34:53 +02:00
|
|
|
class TestMessageId:
|
|
|
|
|
def test_uses_message_id_header(self):
|
|
|
|
|
msg = _make_msg({"Message-ID": "<foo-123@example.com>"})
|
|
|
|
|
assert _message_id(msg) == "foo-123@example.com"
|
|
|
|
|
|
|
|
|
|
def test_strips_angle_brackets(self):
|
|
|
|
|
msg = _make_msg({"Message-ID": " <bar@baz.com> "})
|
|
|
|
|
assert _message_id(msg) == "bar@baz.com"
|
|
|
|
|
|
|
|
|
|
def test_fallback_to_sha256_when_missing(self):
|
|
|
|
|
msg = _make_msg({"From": "a@b.com", "Subject": "no id"})
|
|
|
|
|
result = _message_id(msg)
|
|
|
|
|
assert result.startswith("sha256-")
|
|
|
|
|
assert len(result) == len("sha256-") + 32
|
|
|
|
|
|
|
|
|
|
def test_sha256_is_stable(self):
|
|
|
|
|
msg = _make_msg({"From": "a@b.com", "Subject": "stable"})
|
|
|
|
|
assert _message_id(msg) == _message_id(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestParseDate:
|
|
|
|
|
def test_parses_rfc2822(self):
|
|
|
|
|
msg = _make_msg({"Date": "Tue, 10 Jun 2025 12:00:00 +0000"})
|
|
|
|
|
ts = _parse_date(msg)
|
|
|
|
|
assert ts == datetime(2025, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
def test_result_is_utc_aware(self):
|
|
|
|
|
msg = _make_msg({"Date": "Tue, 10 Jun 2025 12:00:00 +0000"})
|
|
|
|
|
assert _parse_date(msg).tzinfo is not None
|
|
|
|
|
|
|
|
|
|
def test_falls_back_to_epoch_on_bad_date(self):
|
|
|
|
|
msg = _make_msg({"Date": "not-a-date"})
|
|
|
|
|
assert _parse_date(msg) == datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
def test_falls_back_to_epoch_when_missing(self):
|
|
|
|
|
msg = _make_msg({"From": "a@b.com"})
|
|
|
|
|
assert _parse_date(msg) == datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
def test_converts_offset_to_utc(self):
|
|
|
|
|
msg = _make_msg({"Date": "Tue, 10 Jun 2025 14:00:00 +0200"})
|
|
|
|
|
ts = _parse_date(msg)
|
|
|
|
|
assert ts == datetime(2025, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
refactor(kb-mail): importer Gmail — entities załączników, --limit, batch, bez Dockera, DSN→PIHA
- Usuwa Dockerfile i docker-compose.yml; job odpalany lokalnie na PIHA (pip install -e)
- _parse_attachments: manifest MIME → entities[]{type,filename,content_type,size,sha256};
bajty zostają w .eml, wyciąganie/OCR = faza 2
- Batch inserty co 500 wpisów (executemany + ON CONFLICT DO NOTHING); idempotentny
na skipped przez _eml_ref (mirrors archive._UNSAFE); pełna wznawialność
- --limit N: ucina pętlę po N wiadomościach do testów na próbce
- epoch_fallback: licznik + WARNING gdy Date nieparsowalne/brak
- Nowe stats: msgs_with_attachments, total_attachments, total_attachment_bytes
- DSN w docstringu: localhost:5433/kb i piha:5433/kb; usunięto solaria:5433
- 9 nowych testów (24 razem), wszystkie zielone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:37:27 +02:00
|
|
|
class TestParseAttachments:
|
|
|
|
|
def test_plain_text_message_has_no_attachments(self):
|
|
|
|
|
assert _parse_attachments(_msg_with_id()) == []
|
|
|
|
|
|
|
|
|
|
def test_two_attachments_manifest(self):
|
|
|
|
|
atts = _parse_attachments(_mime_with_attachments())
|
|
|
|
|
assert len(atts) == 2
|
|
|
|
|
|
|
|
|
|
pdf = next(a for a in atts if a["filename"] == "report.pdf")
|
|
|
|
|
assert pdf["type"] == "attachment"
|
|
|
|
|
assert pdf["content_type"] == "application/pdf"
|
|
|
|
|
assert pdf["size"] == len(b"PDF content here")
|
|
|
|
|
assert pdf["sha256"] == hashlib.sha256(b"PDF content here").hexdigest()
|
|
|
|
|
|
|
|
|
|
png = next(a for a in atts if a["filename"] == "photo.png")
|
|
|
|
|
assert png["content_type"] == "image/png"
|
|
|
|
|
assert png["size"] == len(b"PNG bytes")
|
|
|
|
|
assert png["sha256"] == hashlib.sha256(b"PNG bytes").hexdigest()
|
|
|
|
|
|
|
|
|
|
def test_attachment_descriptors_have_required_keys(self):
|
|
|
|
|
for att in _parse_attachments(_mime_with_attachments()):
|
|
|
|
|
assert {"type", "filename", "content_type", "size", "sha256"} <= att.keys()
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 22:34:53 +02:00
|
|
|
class TestRunImport:
|
|
|
|
|
async def test_dry_run_counts_without_writing(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id("<a@e.com>"), _msg_with_id("<b@e.com>")])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive, dry_run=True)
|
|
|
|
|
|
|
|
|
|
assert stats["processed"] == 2
|
|
|
|
|
assert stats["imported"] == 2
|
|
|
|
|
assert not archive.exists()
|
|
|
|
|
|
|
|
|
|
async def test_imports_eml_to_archive(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id()])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["imported"] == 1
|
|
|
|
|
assert stats["skipped"] == 0
|
|
|
|
|
assert stats["errors"] == 0
|
refactor(kb-mail): importer Gmail — entities załączników, --limit, batch, bez Dockera, DSN→PIHA
- Usuwa Dockerfile i docker-compose.yml; job odpalany lokalnie na PIHA (pip install -e)
- _parse_attachments: manifest MIME → entities[]{type,filename,content_type,size,sha256};
bajty zostają w .eml, wyciąganie/OCR = faza 2
- Batch inserty co 500 wpisów (executemany + ON CONFLICT DO NOTHING); idempotentny
na skipped przez _eml_ref (mirrors archive._UNSAFE); pełna wznawialność
- --limit N: ucina pętlę po N wiadomościach do testów na próbce
- epoch_fallback: licznik + WARNING gdy Date nieparsowalne/brak
- Nowe stats: msgs_with_attachments, total_attachments, total_attachment_bytes
- DSN w docstringu: localhost:5433/kb i piha:5433/kb; usunięto solaria:5433
- 9 nowych testów (24 razem), wszystkie zielone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:37:27 +02:00
|
|
|
assert len(list(archive.rglob("*.eml"))) == 1
|
2026-06-24 22:34:53 +02:00
|
|
|
|
|
|
|
|
async def test_eml_stored_under_gmail_source(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id()])
|
|
|
|
|
|
|
|
|
|
await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
eml_files = list(archive.rglob("*.eml"))
|
|
|
|
|
assert eml_files[0].parts[len(archive.parts)] == "gmail"
|
|
|
|
|
|
|
|
|
|
async def test_idempotent_second_run_skips(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id()])
|
|
|
|
|
|
|
|
|
|
await run_import(mbox_path, archive)
|
|
|
|
|
stats2 = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats2["imported"] == 0
|
|
|
|
|
assert stats2["skipped"] == 1
|
|
|
|
|
assert stats2["errors"] == 0
|
|
|
|
|
|
|
|
|
|
async def test_two_distinct_messages_both_imported(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id("<msg1@e.com>"), _msg_with_id("<msg2@e.com>")])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["imported"] == 2
|
|
|
|
|
assert stats["errors"] == 0
|
|
|
|
|
assert len(list(archive.rglob("*.eml"))) == 2
|
|
|
|
|
|
|
|
|
|
async def test_archive_only_mode_no_dsn(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id()])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive, dsn=None)
|
|
|
|
|
|
|
|
|
|
assert stats["imported"] == 1
|
|
|
|
|
assert list(archive.rglob("*.eml"))
|
refactor(kb-mail): importer Gmail — entities załączników, --limit, batch, bez Dockera, DSN→PIHA
- Usuwa Dockerfile i docker-compose.yml; job odpalany lokalnie na PIHA (pip install -e)
- _parse_attachments: manifest MIME → entities[]{type,filename,content_type,size,sha256};
bajty zostają w .eml, wyciąganie/OCR = faza 2
- Batch inserty co 500 wpisów (executemany + ON CONFLICT DO NOTHING); idempotentny
na skipped przez _eml_ref (mirrors archive._UNSAFE); pełna wznawialność
- --limit N: ucina pętlę po N wiadomościach do testów na próbce
- epoch_fallback: licznik + WARNING gdy Date nieparsowalne/brak
- Nowe stats: msgs_with_attachments, total_attachments, total_attachment_bytes
- DSN w docstringu: localhost:5433/kb i piha:5433/kb; usunięto solaria:5433
- 9 nowych testów (24 razem), wszystkie zielone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:37:27 +02:00
|
|
|
|
|
|
|
|
async def test_limit_truncates_processing(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id(f"<m{i}@e.com>") for i in range(5)])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive, limit=3)
|
|
|
|
|
|
|
|
|
|
assert stats["processed"] == 3
|
|
|
|
|
assert stats["imported"] == 3
|
|
|
|
|
|
|
|
|
|
async def test_limit_with_dry_run(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id(f"<m{i}@e.com>") for i in range(10)])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive, dry_run=True, limit=4)
|
|
|
|
|
|
|
|
|
|
assert stats["processed"] == 4
|
|
|
|
|
assert stats["imported"] == 4
|
|
|
|
|
assert not archive.exists()
|
|
|
|
|
|
|
|
|
|
async def test_epoch_fallback_counted(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [
|
|
|
|
|
_make_msg({"Message-ID": "<bad@e.com>", "Date": "not-a-date"}),
|
|
|
|
|
_make_msg({"Message-ID": "<missing@e.com>"}),
|
|
|
|
|
_msg_with_id("<good@e.com>"),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["epoch_fallback"] == 2
|
|
|
|
|
assert stats["processed"] == 3
|
|
|
|
|
|
|
|
|
|
async def test_attachment_stats_aggregated(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [
|
|
|
|
|
_msg_with_id("<plain@e.com>"),
|
|
|
|
|
_mime_with_attachments("<att@e.com>"),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["msgs_with_attachments"] == 1
|
|
|
|
|
assert stats["total_attachments"] == 2
|
|
|
|
|
assert stats["total_attachment_bytes"] == (
|
|
|
|
|
len(b"PDF content here") + len(b"PNG bytes")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def test_batch_flush_multiple_messages(self, tmp_path):
|
|
|
|
|
"""BATCH_SIZE=500; 3 messages stay in pending until end-of-loop flush."""
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id(f"<m{i}@e.com>") for i in range(3)])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["imported"] == 3
|
|
|
|
|
assert stats["errors"] == 0
|
|
|
|
|
assert len(list(archive.rglob("*.eml"))) == 3
|
|
|
|
|
|
|
|
|
|
async def test_attachment_entities_in_stats(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_mime_with_attachments()])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["imported"] == 1
|
|
|
|
|
assert stats["total_attachments"] == 2
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Regression tests for the 2026-07-14 hardening audit (four fixes).
|
|
|
|
|
#
|
|
|
|
|
# The audit reproduced each of these on crafted mboxes; the corpus is proven to
|
|
|
|
|
# contain raw 8-bit header bytes, so every scenario below is a real re-import
|
|
|
|
|
# risk, not a hypothetical.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# Raw 8-bit byte (0xe9) inside Message-ID: compat32 .get() returns an
|
|
|
|
|
# email.header.Header, whose .strip() raised AttributeError. Because
|
|
|
|
|
# _message_id ran BEFORE the per-message try, one such header killed the run.
|
|
|
|
|
_EIGHTBIT_MID_EML = (
|
|
|
|
|
b"From: alice@example.com\r\n"
|
|
|
|
|
b"Message-ID: <bad\xe9id@example.com>\r\n"
|
|
|
|
|
b"Date: Tue, 10 Jun 2025 12:00:00 +0000\r\n"
|
|
|
|
|
b"Subject: hi\r\n\r\nbody"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Raw 8-bit byte inside the Date header's trailing comment: .get() returns a
|
|
|
|
|
# Header, parsedate_to_datetime(Header) raises → needless epoch_fallback, even
|
|
|
|
|
# though str(header) parses to a real date (2009-05-19 10:27:09 +0200).
|
|
|
|
|
_EIGHTBIT_DATE_EML = (
|
|
|
|
|
b"From: alice@example.com\r\n"
|
|
|
|
|
b"Message-ID: <clean-date@example.com>\r\n"
|
|
|
|
|
b"Date: Tue, 19 May 2009 10:27:09 +0200 (Ho\xe9ra)\r\n"
|
|
|
|
|
b"Subject: hi\r\n\r\nbody"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_mbox_raw(path: Path, raw_messages: list[bytes]) -> None:
|
|
|
|
|
mbox = mailbox.mbox(str(path), create=True)
|
|
|
|
|
for raw in raw_messages:
|
|
|
|
|
mbox.add(mailbox.mboxMessage(raw))
|
|
|
|
|
mbox.flush()
|
|
|
|
|
mbox.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeConn:
|
|
|
|
|
"""Stand-in asyncpg connection: records executemany rows or fails them."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, fail: bool = False):
|
|
|
|
|
self.fail = fail
|
|
|
|
|
self.inserted: list = []
|
|
|
|
|
self.executemany_calls = 0
|
|
|
|
|
|
|
|
|
|
async def executemany(self, query, rows):
|
|
|
|
|
self.executemany_calls += 1
|
|
|
|
|
if self.fail:
|
|
|
|
|
raise RuntimeError("DB down")
|
|
|
|
|
self.inserted.extend(list(rows))
|
|
|
|
|
|
|
|
|
|
async def close(self):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _patch_connect(monkeypatch, conn: _FakeConn) -> None:
|
|
|
|
|
async def _fake_connect(dsn):
|
|
|
|
|
return conn
|
|
|
|
|
monkeypatch.setattr(importer.asyncpg, "connect", _fake_connect)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEightBitMessageId:
|
|
|
|
|
def test_message_id_8bit_header_does_not_crash(self):
|
|
|
|
|
# Fix 1: str() + _sanitize gives .strip() a real string. Old code did
|
|
|
|
|
# Header.strip() → AttributeError.
|
|
|
|
|
msg = mailbox.mboxMessage(_EIGHTBIT_MID_EML)
|
|
|
|
|
mid = _message_id(msg)
|
|
|
|
|
assert isinstance(mid, str)
|
|
|
|
|
assert mid.endswith("id@example.com")
|
|
|
|
|
json.dumps(mid).encode("utf-8") # jsonb-safe: no lone surrogate
|
|
|
|
|
|
|
|
|
|
async def test_run_survives_8bit_message_id(self, tmp_path):
|
|
|
|
|
# Old code raised out of run_import here; the run must now complete and
|
|
|
|
|
# count the message normally.
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox_raw(mbox_path, [_EIGHTBIT_MID_EML, _msg_with_id("<ok@e.com>").as_bytes()])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["processed"] == 2
|
|
|
|
|
assert stats["imported"] == 2
|
|
|
|
|
assert stats["errors"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPerMessageGuard:
|
|
|
|
|
async def test_one_bad_message_counts_once_and_run_continues(self, tmp_path, monkeypatch):
|
|
|
|
|
# Fix 2: the parse helpers now live inside the per-message try, so a
|
|
|
|
|
# message that raises is one error, not the death of the run.
|
|
|
|
|
calls = {"n": 0}
|
|
|
|
|
real_message_id = importer._message_id
|
|
|
|
|
|
|
|
|
|
def flaky(msg):
|
|
|
|
|
calls["n"] += 1
|
|
|
|
|
if calls["n"] == 1:
|
|
|
|
|
raise RuntimeError("boom parsing first message")
|
|
|
|
|
return real_message_id(msg)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(importer, "_message_id", flaky)
|
|
|
|
|
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id("<a@e.com>"), _msg_with_id("<b@e.com>")])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["processed"] == 2
|
|
|
|
|
assert stats["errors"] == 1
|
|
|
|
|
assert stats["imported"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFailedInsertDoesNotPoison:
|
|
|
|
|
async def test_final_flush_failure_does_not_kill_run(self, tmp_path, monkeypatch):
|
|
|
|
|
# Fix 3: the final flush used to be uncaught (try/finally, no except),
|
|
|
|
|
# so a failing insert lost all stats. Now it is swallowed and counted.
|
|
|
|
|
conn = _FakeConn(fail=True)
|
|
|
|
|
_patch_connect(monkeypatch, conn)
|
|
|
|
|
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id(f"<m{i}@e.com>") for i in range(3)])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive, dsn="postgresql://fake")
|
|
|
|
|
|
|
|
|
|
# Run completes, archive is ahead of DB, and the drift is visible.
|
|
|
|
|
assert stats["imported"] == 3
|
|
|
|
|
assert stats["db_inserted"] == 0
|
|
|
|
|
assert stats["db_insert_failed"] == 3
|
|
|
|
|
assert stats["errors"] == 0
|
|
|
|
|
|
|
|
|
|
async def test_poisoned_batch_does_not_re_flush_forever(self, tmp_path, monkeypatch):
|
|
|
|
|
# Fix 3: pending is cleared even on insert failure, so a poisoned batch
|
|
|
|
|
# is not re-flushed by every subsequent message. With BATCH_SIZE=1 each
|
|
|
|
|
# message flushes exactly once; old code re-flushed a growing pending
|
|
|
|
|
# list and died in the uncaught final flush.
|
|
|
|
|
monkeypatch.setattr(importer, "BATCH_SIZE", 1)
|
|
|
|
|
conn = _FakeConn(fail=True)
|
|
|
|
|
_patch_connect(monkeypatch, conn)
|
|
|
|
|
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id(f"<m{i}@e.com>") for i in range(3)])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive, dsn="postgresql://fake")
|
|
|
|
|
|
|
|
|
|
assert conn.executemany_calls == 3 # one per message, not a growing batch
|
|
|
|
|
assert stats["imported"] == 3
|
|
|
|
|
assert stats["db_insert_failed"] == 3
|
|
|
|
|
assert stats["errors"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStatsBalance:
|
|
|
|
|
async def test_processed_balances_and_db_reconciles(self, tmp_path, monkeypatch):
|
|
|
|
|
# Fix 4: imported/skipped count archive writes; db_inserted counts DB
|
|
|
|
|
# rows. Both invariants must always hold.
|
|
|
|
|
conn = _FakeConn()
|
|
|
|
|
_patch_connect(monkeypatch, conn)
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id(f"<m{i}@e.com>") for i in range(3)])
|
|
|
|
|
|
|
|
|
|
stats1 = await run_import(mbox_path, archive, dsn="postgresql://fake")
|
|
|
|
|
|
|
|
|
|
# second run over the same archive: every message is skipped, but each
|
|
|
|
|
# still goes to the DB (ON CONFLICT DO NOTHING)
|
|
|
|
|
conn2 = _FakeConn()
|
|
|
|
|
_patch_connect(monkeypatch, conn2)
|
|
|
|
|
stats2 = await run_import(mbox_path, archive, dsn="postgresql://fake")
|
|
|
|
|
|
|
|
|
|
for s in (stats1, stats2):
|
|
|
|
|
assert s["processed"] == s["imported"] + s["skipped"] + s["errors"]
|
|
|
|
|
assert s["db_inserted"] + s["db_insert_failed"] == s["imported"] + s["skipped"]
|
|
|
|
|
|
|
|
|
|
assert stats1["imported"] == 3 and stats1["db_inserted"] == 3
|
|
|
|
|
assert stats2["skipped"] == 3 and stats2["db_inserted"] == 3
|
|
|
|
|
|
|
|
|
|
async def test_archive_only_mode_leaves_db_counters_zero(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox(mbox_path, [_msg_with_id(f"<m{i}@e.com>") for i in range(2)])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive, dsn=None)
|
|
|
|
|
|
|
|
|
|
assert stats["imported"] == 2
|
|
|
|
|
assert stats["db_inserted"] == 0
|
|
|
|
|
assert stats["db_insert_failed"] == 0
|
|
|
|
|
assert stats["processed"] == stats["imported"] + stats["skipped"] + stats["errors"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestParseDate8Bit:
|
|
|
|
|
def test_8bit_date_header_parses_via_str_not_epoch(self):
|
|
|
|
|
# Fix 4 (date): str(header) before epoch_fallback. Old code fed the
|
|
|
|
|
# Header straight to parsedate_to_datetime → AttributeError → epoch.
|
|
|
|
|
msg = mailbox.mboxMessage(_EIGHTBIT_DATE_EML)
|
|
|
|
|
ts = _parse_date(msg)
|
|
|
|
|
assert ts == datetime(2009, 5, 19, 8, 27, 9, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
async def test_8bit_date_not_counted_as_epoch_fallback(self, tmp_path):
|
|
|
|
|
mbox_path = tmp_path / "mail.mbox"
|
|
|
|
|
archive = tmp_path / "archive"
|
|
|
|
|
_make_mbox_raw(mbox_path, [_EIGHTBIT_DATE_EML])
|
|
|
|
|
|
|
|
|
|
stats = await run_import(mbox_path, archive)
|
|
|
|
|
|
|
|
|
|
assert stats["epoch_fallback"] == 0
|
|
|
|
|
assert stats["imported"] == 1
|