homelab-codex-ws/packages/kb-mail/src/kb_mail/message.py

117 lines
4.7 KiB
Python
Raw Normal View History

feat(kb-mail): adapter IMAP + model stanu synca + migracja 005 Krok 7 fazy mailowej, warstwa wspoldzielona. Realizuje decyzje (a), (b), (f) reconu kb/audits/mail-sync-2026-08-06.md (zatwierdzone przez operatora 2026-08-06): jeden adapter IMAP na oba konta, stan synca jako tabela w bazie. Nowe moduly w packages/kb-mail: - imap.py — ImapAccount/ImapClient nad stdlib imaplib (zero nowych zaleznosci). Foldery otwierane READ-ONLY (EXAMINE) i pobierane przez BODY.PEEK[], zeby job nie ustawial \Seen na skrzynce operatora. Wybor folderu po atrybucie SPECIAL-USE, nigdy po nazwie — Gmail lokalizuje "[Gmail]/All Mail". search_from_uid filtruje zakres po stronie klienta, bo n:* zwraca ostatnia wiadomosc takze gdy przedzial pusty. - sync_state.py — tabela mail_sync_state + czyste funkcje: plan_folder_sync (pierwszy tick / przyrost / uniewaznienie UIDVALIDITY) i contiguous_last_uid (kursor przesuwa sie tylko po nieprzerwanym ciagu sukcesow — bledna wiadomosc jest ponawiana, nie przeskakiwana). - headers.py / message.py — parse_headers(+fallback) z gmail-header-backfill oraz message_id/parse_date/parse_attachments/eml_ref z gmail-bulk-import, przeniesione zamiast skopiowane. Klucz dedup musi pochodzic z jednej implementacji: kazdy insert przyrostowki trafia na 225 030 istniejacych id. Stare joby re-eksportuja te nazwy — ich CLI i testy bez zmian. kb_mail.db.insert_envelope zwraca teraz command tag (+ rows_affected, envelope_source) — bez tego nie da sie odroznic zwyklego duplikatu od kolizji Message-ID miedzy kontami (recon §2.4). Migracja 005_mail_sync_state.sql: addytywna, klucz (account, folder). Testy: 285 passed (111 kb-mail w tym 37 adaptera IMAP na fake serwerze i 26 planera kursora; 174 istniejace suity jobow bez zmian po ekstrakcji). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:26:51 +02:00
"""Message-level derivations shared by every job that turns `.eml` bytes into an `Envelope`:
the envelope id, the timestamp, the attachment manifest, and the archive path they imply.
Extracted from `gmail_bulk_import.importer` when `jobs/mail-imap-sync` needed the identical
four derivations (recon `kb/audits/mail-sync-2026-08-06.md` §2.1) the poller's dedup rests
entirely on producing byte-identical ids to the 225 030 rows the bulk import already wrote, so
a second implementation of `message_id()` is the one thing that must not exist.
`gmail_bulk_import.importer` re-exports these under its old private names, unchanged.
"""
from __future__ import annotations
import email.message
import hashlib
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import structlog
from .text import sanitize_surrogates as _sanitize
_log = structlog.get_logger(__name__)
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
# Mirrors archive._UNSAFE — used to reconstruct raw_ref for already-archived messages
# without writing them again.
_ARCHIVE_UNSAFE = str.maketrans({"/": "_", "\\": "_", ":": "_", "<": "", ">": ""})
def eml_ref(envelope_id: str, source: str, ts: datetime) -> str:
"""The archive-relative `.eml` path `save_eml` would write, computed without writing.
Kept in lockstep with `kb_mail.archive.save_eml` callers use it on `FileExistsError`
("already archived") to fill `Envelope.raw_ref` for a message they did not just write.
"""
safe_id = envelope_id.translate(_ARCHIVE_UNSAFE)
return f"{source}/{ts.year:04d}/{ts.month:02d}/{safe_id}.eml"
def message_id(msg: email.message.Message) -> str:
"""Return a stable envelope id from the Message-ID header, or a SHA-256 content hash.
This IS the dedup key (`envelope.id`, `ON CONFLICT (id) DO NOTHING`), so its output must
stay byte-identical across jobs and years 9 of the 225 030 existing rows carry the
`sha256-` form, the rest a bare Message-ID.
compat32 `.get()` returns an `email.header.Header` (not str) when the raw value holds
8-bit bytes `str()` + `_sanitize` keeps the id jsonb-safe and gives `.strip()` a real
string to work on. An unguarded `.strip()` on a Header raised AttributeError and killed a
whole bulk-import run (the archive has proven 8-bit header bytes).
"""
mid = _sanitize(str(msg.get("Message-ID", ""))).strip().strip("<>")
if mid:
return mid
return "sha256-" + hashlib.sha256(msg.as_bytes()).hexdigest()[:32]
def parse_date(msg: email.message.Message) -> datetime:
"""Parse the Date header to a UTC-aware datetime. Returns `EPOCH` as last resort.
`str()` first: a compat32 8-bit Date surfaces as an `email.header.Header`, and
`parsedate_to_datetime()` raises on a Header ( needless epoch fallback) even when
`str(header)` is perfectly parseable.
Note this is the SENDER's clock, which is why it must never be used as a sync cursor —
2 559 envelopes in the live corpus sit at epoch 1970 (recon §2.2 wariant C).
"""
date_str = 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:
pass
return EPOCH
def parse_attachments(msg: email.message.Message) -> list[dict]:
"""Return `entities[type=attachment]` descriptors from the MIME structure.
A part is an attachment if it has a filename, `Content-Disposition: attachment`, or is not
inline `text/plain`/`text/html`. Errors on individual parts are logged and skipped never
propagated, since one unwalkable part must not cost the whole message.
"""
attachments = []
for part in msg.walk():
if part.get_content_maintype() == "multipart":
continue
content_type = part.get_content_type()
disposition = part.get_content_disposition() or ""
filename = part.get_filename()
if (
content_type in ("text/plain", "text/html")
and disposition != "attachment"
and not filename
):
continue
try:
payload = part.get_payload(decode=True)
if payload is None:
continue
attachments.append({
"type": "attachment",
"filename": filename,
"content_type": content_type,
"size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
})
except Exception:
_log.warning("attachment_parse_failed",
filename=filename, content_type=content_type)
return attachments