homelab-codex-ws/packages/kb-mail/tests/test_message.py

95 lines
4 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
"""Tests for the message-level derivations extracted out of gmail-bulk-import.
The derivations themselves have been exercised by that job's suite (and by 225 030 real
messages) since June; what is new here is that they are now shared, and that `eml_ref` takes
`source` as a parameter instead of hardcoding `gmail/`. These tests pin the properties the
poller depends on: ids identical to the ones already in the table, and archive paths that
agree with `save_eml` for BOTH sources.
"""
from __future__ import annotations
import email
import email.policy
from datetime import datetime, timezone
from pathlib import Path
from kb_mail.archive import save_eml
from kb_mail.message import EPOCH, eml_ref, message_id, parse_attachments, parse_date
def _msg(raw: bytes):
return email.message_from_bytes(raw, policy=email.policy.compat32)
class TestMessageId:
def test_strips_angle_brackets(self):
assert message_id(_msg(b"Message-ID: <abc@example.com>\r\n\r\nbody")) == "abc@example.com"
def test_missing_header_falls_back_to_a_content_hash(self):
mid = message_id(_msg(b"Subject: no id\r\n\r\nbody"))
assert mid.startswith("sha256-")
assert len(mid) == len("sha256-") + 32
def test_content_hash_is_stable_for_identical_bytes(self):
raw = b"Subject: no id\r\n\r\nbody"
assert message_id(_msg(raw)) == message_id(_msg(raw))
def test_eight_bit_header_bytes_do_not_raise(self):
# compat32 hands back an email.header.Header here, not a str — an unguarded
# .strip() on it killed a full bulk-import run once.
mid = message_id(_msg(b"Message-ID: <\xc4\x85bc@example.com>\r\n\r\nbody"))
assert mid
class TestParseDate:
def test_parses_to_utc(self):
ts = parse_date(_msg(b"Date: Mon, 15 Jun 2026 14:00:00 +0200\r\n\r\nbody"))
assert ts == datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc)
def test_missing_date_is_epoch(self):
assert parse_date(_msg(b"Subject: x\r\n\r\nbody")) == EPOCH
def test_unparseable_date_is_epoch(self):
assert parse_date(_msg(b"Date: not a date at all\r\n\r\nbody")) == EPOCH
def test_naive_date_is_assumed_utc(self):
ts = parse_date(_msg(b"Date: Mon, 15 Jun 2026 14:00:00 -0000\r\n\r\nbody"))
assert ts.tzinfo is timezone.utc
class TestParseAttachments:
def test_inline_text_is_not_an_attachment(self):
assert parse_attachments(_msg(b"Content-Type: text/plain\r\n\r\nhello")) == []
def test_attachment_manifest_carries_sha256_and_size(self):
raw = (
b'Content-Type: multipart/mixed; boundary="b"\r\n\r\n--b\r\n'
b"Content-Type: text/plain\r\n\r\nhello\r\n--b\r\n"
b"Content-Type: application/pdf\r\n"
b'Content-Disposition: attachment; filename="doc.pdf"\r\n'
b"Content-Transfer-Encoding: base64\r\n\r\nJVBERg==\r\n--b--\r\n"
)
[att] = parse_attachments(_msg(raw))
assert att["type"] == "attachment"
assert att["filename"] == "doc.pdf"
assert att["size"] == 4 # decoded payload, not the base64 text
assert len(att["sha256"]) == 64
class TestEmlRef:
def test_layout_is_source_year_month(self):
ts = datetime(2026, 6, 15, tzinfo=timezone.utc)
assert eml_ref("abc@example.com", "fastmail", ts) == "fastmail/2026/06/abc@example.com.eml"
def test_unsafe_characters_are_translated(self):
ts = datetime(2026, 6, 15, tzinfo=timezone.utc)
assert eml_ref("<a/b:c>", "gmail", ts) == "gmail/2026/06/a_b_c.eml"
async def test_agrees_with_save_eml_for_a_new_source(self, tmp_path: Path):
# The two must not drift: on FileExistsError the caller fills raw_ref from eml_ref
# for a file save_eml wrote on an earlier run.
ts = datetime(2026, 6, 15, tzinfo=timezone.utc)
written = await save_eml(tmp_path, "id/with:chars", "fastmail", ts, b"raw")
assert written == eml_ref("id/with:chars", "fastmail", ts)
assert (tmp_path / written).read_bytes() == b"raw"