67 lines
2.1 KiB
Python
67 lines
2.1 KiB
Python
"""Message-ID and subject normalization (SPEC §7.1, §7.3)."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import re
|
||
|
|
|
||
|
|
# Repeated reply/forward prefixes to strip, case-insensitive (incl. Polish Odp:/PD:).
|
||
|
|
_PREFIX_RE = re.compile(r"^\s*(re|fw|fwd|odp|pd)\s*:\s*", re.IGNORECASE)
|
||
|
|
_WS_RE = re.compile(r"\s+")
|
||
|
|
_ANGLE_RE = re.compile(r"<([^>]*)>")
|
||
|
|
|
||
|
|
|
||
|
|
def normalize_message_id(raw: str | None) -> str | None:
|
||
|
|
"""Normalize a Message-ID per SPEC §7.1.
|
||
|
|
|
||
|
|
Strip angle brackets and whitespace, lowercase the domain part only.
|
||
|
|
Return None for obviously invalid ids (no ``@``).
|
||
|
|
"""
|
||
|
|
if raw is None:
|
||
|
|
return None
|
||
|
|
text = raw.strip()
|
||
|
|
m = _ANGLE_RE.search(text)
|
||
|
|
if m:
|
||
|
|
text = m.group(1)
|
||
|
|
text = text.strip().strip("<>").strip()
|
||
|
|
if "@" not in text:
|
||
|
|
return None
|
||
|
|
local, _, domain = text.rpartition("@")
|
||
|
|
if not local or not domain:
|
||
|
|
return None
|
||
|
|
return f"{local}@{domain.lower()}"
|
||
|
|
|
||
|
|
|
||
|
|
def normalize_reference_list(raw: str | None) -> list[str]:
|
||
|
|
"""Parse a References / In-Reply-To header value into normalized ids, in order."""
|
||
|
|
if not raw:
|
||
|
|
return []
|
||
|
|
out: list[str] = []
|
||
|
|
for token in _ANGLE_RE.findall(raw) or raw.split():
|
||
|
|
norm = normalize_message_id(token)
|
||
|
|
if norm and norm not in out:
|
||
|
|
out.append(norm)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def synthesize_message_id(raw_message: bytes) -> str:
|
||
|
|
"""Synthesize a non-authoritative Message-ID for a message that lacks one (§7.1)."""
|
||
|
|
digest = hashlib.sha256(raw_message).hexdigest()[:32]
|
||
|
|
return f"{digest}@synthetic.local"
|
||
|
|
|
||
|
|
|
||
|
|
def is_synthetic(message_id: str | None) -> bool:
|
||
|
|
"""Return True if ``message_id`` is a synthesized (non-authoritative) id."""
|
||
|
|
return message_id is not None and message_id.endswith("@synthetic.local")
|
||
|
|
|
||
|
|
|
||
|
|
def normalize_subject(subject: str) -> str:
|
||
|
|
"""Normalize a subject for the §7.3 fallback: strip repeated Re:/Fw:/... prefixes."""
|
||
|
|
text = subject or ""
|
||
|
|
while True:
|
||
|
|
stripped = _PREFIX_RE.sub("", text, count=1)
|
||
|
|
if stripped == text:
|
||
|
|
break
|
||
|
|
text = stripped
|
||
|
|
return _WS_RE.sub(" ", text).strip()
|