feat(bib): alias listserv re-sends by body hash — one item per content (refs #665)

This commit is contained in:
kert
2026-08-26 13:41:41 -04:00
parent a632908911
commit b0a168fc64
4 changed files with 219 additions and 16 deletions

View File

@@ -20,7 +20,9 @@ from __future__ import annotations
import email import email
import email.utils import email.utils
import hashlib
import imaplib import imaplib
import json
import logging import logging
import re import re
import ssl import ssl
@@ -124,8 +126,32 @@ def _ingest_message(
subject = (msg.get("Subject") or "(no subject)").strip() subject = (msg.get("Subject") or "(no subject)").strip()
sender_domain = _sender_domain(sender) sender_domain = _sender_domain(sender)
body = _extract_body(msg) body = _extract_body(msg)
title = subject[:255]
body_sha1 = hashlib.sha1(" ".join((body or "").split()).encode()).hexdigest()
item = Source(title=subject[:255], url=url) # Listservs re-send identical content under a fresh Message-ID
# (Class B in docs/superpowers/plans/2026-08-26-zotero-dedupe.md).
# If an item with this exact title+body already exists, treat this
# message as an alias of it rather than creating a new Source.
# json_extract is more robust than a LIKE-on-serialized-JSON match
# since it doesn't depend on json.dumps's separator spacing.
con = store._con()
dup = con.execute(
"SELECT key, extra_json FROM items"
" WHERE item_type = 'source' AND title = ?"
" AND json_extract(extra_json, '$.body_sha1') = ?",
(title, body_sha1),
).fetchone()
if dup:
ej = json.loads(dup["extra_json"] or "{}")
aliases = ej.get("alias_mids", [])
if raw_mid not in aliases:
aliases.append(raw_mid)
ej["alias_mids"] = aliases
store.update(dup["key"], extra_json=json.dumps(ej))
return 0
item = Source(title=title, url=url)
item.doc_type = "Email" item.doc_type = "Email"
item.institution = sender_domain or "Email" item.institution = sender_domain or "Email"
item.date_published = _date_iso(msg.get("Date", "")) item.date_published = _date_iso(msg.get("Date", ""))
@@ -144,8 +170,24 @@ def _ingest_message(
if item.date_published: if item.date_published:
item.add_tag(f"year:{item.date_published[:4]}") item.add_tag(f"year:{item.date_published[:4]}")
# Preserve any extra_json already on this exact url (e.g. alias_mids
# accumulated from prior re-sends of this same Message-ID) — upsert()
# doesn't merge extra_json, it overwrites with item.to_row()'s output.
prior = con.execute("SELECT extra_json FROM items WHERE url = ?", (url,)).fetchone()
prior_ej = json.loads(prior["extra_json"] or "{}") if prior else {}
key = store.upsert(item) key = store.upsert(item)
# Record the body hash so future re-sends under a fresh Message-ID
# can be matched to this item instead of creating a duplicate. Merge
# order: prior_ej first (carries forward alias_mids), then this
# item's own to_row() extra_json (carries the current doc_type),
# then body_sha1 last so it always reflects this ingest's body.
fresh_ej = json.loads(item.to_row()["extra_json"] or "{}")
store.update(
key, extra_json=json.dumps({**prior_ej, **fresh_ej, "body_sha1": body_sha1})
)
# Attachments → temp dir → attach_file # Attachments → temp dir → attach_file
attached = 0 attached = 0
msg_scratch = scratch_root / _slug(raw_mid)[:60] msg_scratch = scratch_root / _slug(raw_mid)[:60]

View File

@@ -3,18 +3,23 @@
from __future__ import annotations from __future__ import annotations
import email.message import email.message
import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from bib.email_ingest import ( from bib.email_ingest import (
Mailbox, Mailbox,
_date_iso, _date_iso,
_extract_body, _extract_body,
_ingest_message,
_safe_filename, _safe_filename,
_sender_domain, _sender_domain,
_slug, _slug,
ingest, ingest,
) )
from bib.store import Store
# ── Pure helpers ────────────────────<E29480><E29480>───────────────────────────── # ── Pure helpers ────────────────────<E29480><E29480>─────────────────────────────
@@ -118,6 +123,9 @@ class TestIngest:
store = MagicMock() store = MagicMock()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
# No pre-existing item to alias against — the resend-dedup lookup
# in _ingest_message must find nothing for these mocked messages.
store._con.return_value.execute.return_value.fetchone.return_value = None
mb = Mailbox( mb = Mailbox(
host="mail.test", port=993, username="test@test.com", password="pw" host="mail.test", port=993, username="test@test.com", password="pw"
@@ -160,7 +168,130 @@ class TestIngest:
store = MagicMock() store = MagicMock()
store.upsert.return_value = "K" store.upsert.return_value = "K"
store._con.return_value.execute.return_value.fetchone.return_value = None
mb = Mailbox(host="h", port=993, username="u@d", password="p") mb = Mailbox(host="h", port=993, username="u@d", password="p")
stats = ingest(store, mb, limit=1) stats = ingest(store, mb, limit=1)
assert stats["seen"] == 1 assert stats["seen"] == 1
assert stats["ingested"] == 1 assert stats["ingested"] == 1
# ── _ingest_message: content-hash aliasing of listserv re-sends ──
@pytest.fixture
def store():
return Store(":memory:")
@pytest.fixture
def mailbox():
return Mailbox(
host="mail.test", port=993, username="cmslists@test.com", password="pw"
)
def make_msg(subject: str, body: str, mid: str, date: str):
m = email.message.EmailMessage()
m["Subject"] = subject
m["From"] = "CMS Updates <cmslists@subscriptions.cms.hhs.gov>"
m["Message-ID"] = f"<{mid}>"
m["Date"] = date
m.set_content(body)
return m
def _raw_extra_json(store: Store, key: str) -> dict:
"""Read extra_json straight off the row.
``Source`` only declares ``doc_type`` — fields packed into
``extra_json`` that aren't declared on the model (``body_sha1``,
``alias_mids``) get silently dropped by pydantic's ``extra="ignore"``
default when the row round-trips through ``Item.from_row()`` /
``to_row()``. Reading the column directly is the only way to see
what's actually persisted.
"""
row = (
store._con()
.execute( # noqa: SLF001
"SELECT extra_json FROM items WHERE key = ?", (key,)
)
.fetchone()
)
return json.loads(row["extra_json"] or "{}")
class TestResendAliasing:
def test_same_body_new_mid_aliases_instead_of_new_item(
self, store, mailbox, tmp_path
):
m1 = make_msg(
"Upcoming iQIES Hold Times",
"Same body.",
"aaa@x.example",
"Mon, 27 Apr 2026 10:00:00 -0400",
)
m2 = make_msg(
"Upcoming iQIES Hold Times",
"Same body.",
"bbb@x.example",
"Fri, 10 Jul 2026 10:00:00 -0400",
)
_ingest_message(store, mailbox, m1, tmp_path)
_ingest_message(store, mailbox, m2, tmp_path)
items = store.list_items(query="iQIES")
assert len(items) == 1
ej = _raw_extra_json(store, items[0].key)
assert "bbb@x.example" in ej.get("alias_mids", [])
def test_resend_is_idempotent(self, store, mailbox, tmp_path):
"""Ingesting the identical alias message twice doesn't duplicate
its Message-ID in alias_mids."""
m1 = make_msg(
"Idempotency Check",
"Same body.",
"eee@x.example",
"Mon, 27 Apr 2026 10:00:00 -0400",
)
m2 = make_msg(
"Idempotency Check",
"Same body.",
"fff@x.example",
"Fri, 10 Jul 2026 10:00:00 -0400",
)
_ingest_message(store, mailbox, m1, tmp_path)
_ingest_message(store, mailbox, m2, tmp_path)
_ingest_message(store, mailbox, m2, tmp_path)
items = store.list_items(query="Idempotency")
assert len(items) == 1
ej = _raw_extra_json(store, items[0].key)
assert ej.get("alias_mids", []).count("fff@x.example") == 1
def test_different_body_same_subject_stays_separate(self, store, mailbox, tmp_path):
m1 = make_msg(
"Weekly Digest",
"Body one.",
"ccc@x.example",
"Mon, 27 Apr 2026 10:00:00 -0400",
)
m2 = make_msg(
"Weekly Digest",
"Body two.",
"ddd@x.example",
"Fri, 10 Jul 2026 10:00:00 -0400",
)
_ingest_message(store, mailbox, m1, tmp_path)
_ingest_message(store, mailbox, m2, tmp_path)
assert len(store.list_items(query="Weekly Digest")) == 2
def test_new_item_records_body_sha1(self, store, mailbox, tmp_path):
m1 = make_msg(
"First Send",
"Only body.",
"ggg@x.example",
"Mon, 27 Apr 2026 10:00:00 -0400",
)
_ingest_message(store, mailbox, m1, tmp_path)
items = store.list_items(query="First Send")
assert len(items) == 1
ej = _raw_extra_json(store, items[0].key)
assert len(ej.get("body_sha1", "")) == 40

View File

@@ -8,6 +8,21 @@ from unittest.mock import MagicMock
from bib.email_ingest import Mailbox, _ingest_message, _sender_domain from bib.email_ingest import Mailbox, _ingest_message, _sender_domain
def _mock_store() -> MagicMock:
"""MagicMock store safe to pass through _ingest_message.
_ingest_message's resend-dedup lookup calls
``store._con().execute(...).fetchone()`` (refs #665) — on a bare
MagicMock that returns a truthy Mock, which _ingest_message
misreads as an existing duplicate and short-circuits before ever
calling store.upsert(). Configuring fetchone() to return None
makes the mock behave like an empty store.
"""
s = MagicMock()
s._con.return_value.execute.return_value.fetchone.return_value = None
return s
class TestIngestMessageWithAttachment: class TestIngestMessageWithAttachment:
def test_extracts_attachment(self, tmp_path): def test_extracts_attachment(self, tmp_path):
msg = email.message.EmailMessage() msg = email.message.EmailMessage()
@@ -22,7 +37,7 @@ class TestIngestMessageWithAttachment:
subtype="pdf", subtype="pdf",
filename="doc.pdf", filename="doc.pdf",
) )
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
mb = Mailbox(host="h", port=993, username="u@d", password="p") mb = Mailbox(host="h", port=993, username="u@d", password="p")
attached = _ingest_message(store, mb, msg, tmp_path / "scratch") attached = _ingest_message(store, mb, msg, tmp_path / "scratch")
@@ -45,7 +60,7 @@ class TestIngestFinallyBlock:
from bib.email_ingest import ingest from bib.email_ingest import ingest
store = MagicMock() store = _mock_store()
mb = Mailbox(host="h", port=993, username="u@d", password="p") mb = Mailbox(host="h", port=993, username="u@d", password="p")
mock_conn = MagicMock() mock_conn = MagicMock()
@@ -65,7 +80,7 @@ class TestIngestFinallyBlock:
from bib.email_ingest import ingest from bib.email_ingest import ingest
store = MagicMock() store = _mock_store()
mb = Mailbox(host="h", port=993, username="u@d", password="p") mb = Mailbox(host="h", port=993, username="u@d", password="p")
mock_conn = MagicMock() mock_conn = MagicMock()
@@ -96,7 +111,7 @@ class TestIngestMessageNoFilename:
# Note: no filename parameter # Note: no filename parameter
) )
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
mb = Mailbox(host="h", port=993, username="u@d", password="p") mb = Mailbox(host="h", port=993, username="u@d", password="p")
attached = _ingest_message(store, mb, msg, tmp_path / "scratch") attached = _ingest_message(store, mb, msg, tmp_path / "scratch")
@@ -117,7 +132,7 @@ class TestIngestMessageNoFilename:
filename="doc.pdf", filename="doc.pdf",
) )
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
store.attach_file.side_effect = Exception("attach fail") store.attach_file.side_effect = Exception("attach fail")
mb = Mailbox(host="h", port=993, username="u@d", password="p") mb = Mailbox(host="h", port=993, username="u@d", password="p")

View File

@@ -18,6 +18,21 @@ from bib.email_ingest import (
) )
def _mock_store() -> MagicMock:
"""MagicMock store safe to pass through _ingest_message.
_ingest_message's resend-dedup lookup calls
``store._con().execute(...).fetchone()`` (refs #665) — on a bare
MagicMock that returns a truthy Mock, which _ingest_message
misreads as an existing duplicate and short-circuits before ever
calling store.upsert(). Configuring fetchone() to return None
makes the mock behave like an empty store.
"""
s = MagicMock()
s._con.return_value.execute.return_value.fetchone.return_value = None
return s
def _make_mailbox(): def _make_mailbox():
return Mailbox( return Mailbox(
host="mail.example.com", host="mail.example.com",
@@ -73,7 +88,7 @@ class TestIngest:
("OK", [(b"2", _make_msg(subject="Second").as_bytes())]), # FETCH uid 2 ("OK", [(b"2", _make_msg(subject="Second").as_bytes())]), # FETCH uid 2
("OK", None), # STORE uid 2 ("OK", None), # STORE uid 2
] ]
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
stats = ingest(store, _make_mailbox()) stats = ingest(store, _make_mailbox())
assert stats["seen"] == 2 assert stats["seen"] == 2
@@ -84,7 +99,7 @@ class TestIngest:
conn = MagicMock() conn = MagicMock()
mc_imap_cls.return_value = conn mc_imap_cls.return_value = conn
conn.uid.return_value = ("NO", [b""]) conn.uid.return_value = ("NO", [b""])
store = MagicMock() store = _mock_store()
stats = ingest(store, _make_mailbox()) stats = ingest(store, _make_mailbox())
assert stats["ingested"] == 0 assert stats["ingested"] == 0
@@ -96,7 +111,7 @@ class TestIngest:
("OK", [b"1"]), # SEARCH ("OK", [b"1"]), # SEARCH
("OK", [None]), # FETCH returns None ("OK", [None]), # FETCH returns None
] ]
store = MagicMock() store = _mock_store()
stats = ingest(store, _make_mailbox()) stats = ingest(store, _make_mailbox())
assert stats["errors"] == 1 assert stats["errors"] == 1
@@ -109,7 +124,7 @@ class TestIngest:
("OK", [(b"1", _make_msg().as_bytes())]), # FETCH uid 1 ("OK", [(b"1", _make_msg().as_bytes())]), # FETCH uid 1
("OK", None), # STORE uid 1 ("OK", None), # STORE uid 1
] ]
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
stats = ingest(store, _make_mailbox(), limit=1) stats = ingest(store, _make_mailbox(), limit=1)
assert stats["seen"] == 1 assert stats["seen"] == 1
@@ -122,14 +137,14 @@ class TestIngest:
("OK", [b"1"]), ("OK", [b"1"]),
Exception("boom"), Exception("boom"),
] ]
store = MagicMock() store = _mock_store()
stats = ingest(store, _make_mailbox()) stats = ingest(store, _make_mailbox())
assert stats["errors"] == 1 assert stats["errors"] == 1
class TestIngestMessage: class TestIngestMessage:
def test_basic(self, tmp_path): def test_basic(self, tmp_path):
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
msg = _make_msg() msg = _make_msg()
parsed = email.message_from_bytes(msg.as_bytes(), policy=email.policy.default) parsed = email.message_from_bytes(msg.as_bytes(), policy=email.policy.default)
@@ -138,7 +153,7 @@ class TestIngestMessage:
store.upsert.assert_called_once() store.upsert.assert_called_once()
def test_with_attachment(self, tmp_path): def test_with_attachment(self, tmp_path):
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
msg = _make_msg(attachment_name="doc.pdf", attachment_data=b"PDF content") msg = _make_msg(attachment_name="doc.pdf", attachment_data=b"PDF content")
parsed = email.message_from_bytes(msg.as_bytes(), policy=email.policy.default) parsed = email.message_from_bytes(msg.as_bytes(), policy=email.policy.default)
@@ -146,7 +161,7 @@ class TestIngestMessage:
assert n == 1 assert n == 1
def test_no_message_id(self, tmp_path): def test_no_message_id(self, tmp_path):
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
msg = _make_msg(message_id="") msg = _make_msg(message_id="")
del msg["Message-ID"] del msg["Message-ID"]
@@ -155,7 +170,7 @@ class TestIngestMessage:
assert n == 0 assert n == 0
def test_with_list_id(self, tmp_path): def test_with_list_id(self, tmp_path):
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
msg = _make_msg(list_id="<cms-updates.listserv.cms.gov>") msg = _make_msg(list_id="<cms-updates.listserv.cms.gov>")
parsed = email.message_from_bytes(msg.as_bytes(), policy=email.policy.default) parsed = email.message_from_bytes(msg.as_bytes(), policy=email.policy.default)
@@ -163,7 +178,7 @@ class TestIngestMessage:
assert n == 0 assert n == 0
def test_unnamed_attachment(self, tmp_path): def test_unnamed_attachment(self, tmp_path):
store = MagicMock() store = _mock_store()
store.upsert.return_value = "KEY1" store.upsert.return_value = "KEY1"
msg = MIMEMultipart() msg = MIMEMultipart()
msg["Subject"] = "Test" msg["Subject"] = "Test"