feat(kb-mail): etap 2 — jednorazowy bulk importer Gmail (mbox → archiwum)
One-shot job `jobs/gmail-bulk-import/` wczytuje plik .mbox z Google Takeout i importuje każdą wiadomość do archiwum .eml + opcjonalnie do koperty w DB. Idempotentny (FileExistsError → skip; ON CONFLICT DO NOTHING w DB). 15 testów jednostkowych (bez DB, bez zewnętrznych serwisów) — wszystkie zielone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
02d0fa391f
commit
6dc1d325f1
11
jobs/gmail-bulk-import/Dockerfile
Normal file
11
jobs/gmail-bulk-import/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY packages/kb-mail/ /packages/kb-mail/
|
||||
RUN pip install --no-cache-dir /packages/kb-mail/
|
||||
|
||||
COPY jobs/gmail-bulk-import/ /app/
|
||||
RUN pip install --no-cache-dir /app/
|
||||
|
||||
ENTRYPOINT ["gmail-bulk-import"]
|
||||
13
jobs/gmail-bulk-import/docker-compose.yml
Normal file
13
jobs/gmail-bulk-import/docker-compose.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
services:
|
||||
gmail-bulk-import:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: jobs/gmail-bulk-import/Dockerfile
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ${MBOX_PATH:?set MBOX_PATH=/path/to/allmail.mbox}:/data/input.mbox:ro
|
||||
- ${ARCHIVE_ROOT:?set ARCHIVE_ROOT=/path/to/archive}:/data/archive
|
||||
command:
|
||||
- --mbox=/data/input.mbox
|
||||
- --archive=/data/archive
|
||||
- --dsn=${KB_DSN:-}
|
||||
23
jobs/gmail-bulk-import/pyproject.toml
Normal file
23
jobs/gmail-bulk-import/pyproject.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "gmail-bulk-import"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"asyncpg>=0.29",
|
||||
"structlog>=24.1",
|
||||
"kb-mail",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
gmail-bulk-import = "gmail_bulk_import.importer:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
146
jobs/gmail-bulk-import/src/gmail_bulk_import/importer.py
Normal file
146
jobs/gmail-bulk-import/src/gmail_bulk_import/importer.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Gmail Takeout bulk importer — one-shot job.
|
||||
|
||||
Reads a .mbox file (Google Takeout "All Mail" export) and imports each
|
||||
message into the append-only .eml archive and optionally into kb-postgres.
|
||||
|
||||
Idempotent: re-running against the same archive skips already-imported
|
||||
messages (FileExistsError from archive → skipped; ON CONFLICT DO NOTHING in DB).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import email.policy
|
||||
import hashlib
|
||||
import mailbox
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg
|
||||
import structlog
|
||||
|
||||
from kb_mail.archive import save_eml
|
||||
from kb_mail.db import insert_envelope
|
||||
from kb_mail.envelope import Envelope
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _message_id(msg: mailbox.mboxMessage) -> str:
|
||||
"""Return a stable envelope id from Message-ID header or SHA-256 content hash."""
|
||||
mid = msg.get("Message-ID", "").strip().strip("<>")
|
||||
if mid:
|
||||
return mid
|
||||
return "sha256-" + hashlib.sha256(msg.as_bytes()).hexdigest()[:32]
|
||||
|
||||
|
||||
def _parse_date(msg: mailbox.mboxMessage) -> datetime:
|
||||
"""Parse Date header to UTC-aware datetime. Falls back to epoch if unparseable."""
|
||||
date_str = msg.get("Date", "")
|
||||
if date_str:
|
||||
try:
|
||||
dt = parsedate_to_datetime(date_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
except Exception:
|
||||
_log.warning("unparseable_date", header=date_str[:120])
|
||||
return datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def _import_one(
|
||||
msg: mailbox.mboxMessage,
|
||||
archive_root: Path,
|
||||
conn: Optional[asyncpg.Connection],
|
||||
) -> str:
|
||||
"""Import a single message. Returns 'imported', 'skipped', or 'error'."""
|
||||
try:
|
||||
envelope_id = _message_id(msg)
|
||||
ts = _parse_date(msg)
|
||||
raw = msg.as_bytes(policy=email.policy.compat32)
|
||||
|
||||
try:
|
||||
raw_ref = await save_eml(archive_root, envelope_id, "gmail", ts, raw)
|
||||
except FileExistsError:
|
||||
return "skipped"
|
||||
|
||||
if conn is not None:
|
||||
env = Envelope(id=envelope_id, source="gmail", ts=ts, raw_ref=raw_ref)
|
||||
await insert_envelope(conn, env)
|
||||
|
||||
return "imported"
|
||||
except Exception:
|
||||
_log.exception("message_failed", id=msg.get("Message-ID", "<no-id>"))
|
||||
return "error"
|
||||
|
||||
|
||||
async def run_import(
|
||||
mbox_path: Path,
|
||||
archive_root: Path,
|
||||
dsn: Optional[str] = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, int]:
|
||||
"""Import all messages from an mbox file.
|
||||
|
||||
Returns stats: {processed, imported, skipped, errors}.
|
||||
With dry_run=True counts messages without writing anything.
|
||||
"""
|
||||
stats: dict[str, int] = {"processed": 0, "imported": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
conn: Optional[asyncpg.Connection] = None
|
||||
if not dry_run and dsn:
|
||||
conn = await asyncpg.connect(dsn)
|
||||
|
||||
try:
|
||||
mbox = mailbox.mbox(str(mbox_path), create=False)
|
||||
for msg in mbox:
|
||||
stats["processed"] += 1
|
||||
if dry_run:
|
||||
stats["imported"] += 1
|
||||
else:
|
||||
outcome = await _import_one(msg, archive_root, conn)
|
||||
if outcome == "imported":
|
||||
stats["imported"] += 1
|
||||
elif outcome == "skipped":
|
||||
stats["skipped"] += 1
|
||||
else:
|
||||
stats["errors"] += 1
|
||||
if stats["processed"] % 500 == 0:
|
||||
_log.info("progress", **stats)
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
_log.info("import_complete", dry_run=dry_run, **stats)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Import Gmail Takeout .mbox into KB archive and envelope DB."
|
||||
)
|
||||
parser.add_argument("--mbox", required=True, type=Path, metavar="PATH",
|
||||
help="Path to Gmail Takeout .mbox file")
|
||||
parser.add_argument("--archive", required=True, type=Path, metavar="DIR",
|
||||
help="Archive root directory")
|
||||
parser.add_argument("--dsn", metavar="DSN",
|
||||
help="asyncpg DSN for kb-postgres (omit for archive-only mode)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Count messages only; no writes to archive or DB")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.mbox.is_file():
|
||||
_log.error("mbox_not_found", path=str(args.mbox))
|
||||
sys.exit(1)
|
||||
|
||||
stats = asyncio.run(
|
||||
run_import(args.mbox, args.archive, args.dsn or None, args.dry_run)
|
||||
)
|
||||
sys.exit(1 if stats["errors"] > 0 else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
148
jobs/gmail-bulk-import/tests/test_importer.py
Normal file
148
jobs/gmail-bulk-import/tests/test_importer.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Unit tests for Gmail bulk importer — no DB, no external services required."""
|
||||
from __future__ import annotations
|
||||
|
||||
import mailbox
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gmail_bulk_import.importer import _message_id, _parse_date, run_import
|
||||
|
||||
|
||||
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",
|
||||
})
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
eml_files = list(archive.rglob("*.eml"))
|
||||
assert len(eml_files) == 1
|
||||
|
||||
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):
|
||||
"""Without a DSN, job runs archive-only (no DB connection attempted)."""
|
||||
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"))
|
||||
Loading…
Reference in a new issue