EMAIL-WP-0005-T01: add Maildir mailbox source
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Adds MaildirMailboxSource reading new/ and cur/, wired through
source.maildir_dir and mailbox.protocol: maildir. Message identity is the
Maildir unique name without its :2,FLAGS suffix, so it survives the new/ to
cur/ move; it is carried on MailboxSourceMessage.dedup_uid and appended to the
message dedup key only when set, leaving fixture and IMAP keys unchanged.

Cursor ordering parses the Maildir delivery time instead of comparing names
lexically. mark_seen and a missing directory are rejected, matching the
read-only IMAP contract.

Also fixes the parse-failure path, which keyed identity on raw_message_ref.
A new/ to cur/ move rewrites that ref, so an unparseable message re-registered
as new on every rescan; it now prefers the source uid when one exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-14 01:31:58 +02:00
parent 9c0d6f1b40
commit 86f22c2e65
9 changed files with 365 additions and 18 deletions

View file

@ -47,6 +47,7 @@ class ReportsConfig:
@dataclass(frozen=True)
class SourceConfig:
fixture_dir: str | None = None
maildir_dir: str | None = None
@dataclass(frozen=True)
@ -103,7 +104,10 @@ def load_config(path: str | Path) -> AppConfig:
include_unknown_messages=bool(reports.get("include_unknown_messages", True)),
timestamp_timezone=str(reports.get("timestamp_timezone", "UTC")),
),
source=SourceConfig(fixture_dir=source.get("fixture_dir")),
source=SourceConfig(
fixture_dir=source.get("fixture_dir"),
maildir_dir=source.get("maildir_dir"),
),
expected_recipients=ExpectedRecipientsConfig(
path=expected_recipients.get("path"),
csv_column=str(expected_recipients.get("csv_column", "email")),

View file

@ -16,6 +16,10 @@ class MailboxSourceMessage:
raw_bytes: bytes
raw_message_ref: str
imap_uid: str | None = None
# Source-assigned identity folded into the message dedup key when the source
# guarantees one delivery per uid. IMAP already contributes its UID, so only
# Maildir sets this.
dedup_uid: str | None = None
class MailboxMessageSource(Protocol):
@ -62,6 +66,67 @@ class FixtureMailboxSource:
break
class MaildirMailboxSource:
"""Read-only source over a Maildir tree (`new/` and `cur/`).
Message identity is the Maildir unique name without the flag suffix, which
stays stable when the MTA or MUA moves a message from `new/` to `cur/`.
"""
def __init__(self, maildir_dir: str | Path, *, mark_seen: bool = False) -> None:
self.maildir_dir = Path(maildir_dir)
self.mark_seen = mark_seen
def iter_messages(
self,
*,
max_messages: int,
since_uid: str | None,
full_rescan: bool,
include_seen: bool,
since: datetime | None,
) -> Iterable[MailboxSourceMessage]:
# Range filtering runs in the scanner against the parsed message
# timestamp; Maildir filename times are delivery times, not message
# times, so they must not be used to exclude messages here.
del since
if self.mark_seen:
raise ValueError("Maildir mark_seen is intentionally unsupported; scans are read-only.")
if not self.maildir_dir.is_dir():
raise ValueError(f"Maildir directory not found: {self.maildir_dir}")
since_key = None if full_rescan or not since_uid else _maildir_sort_key(since_uid)
emitted = 0
for source_uid, path in sorted(self._entries().items(), key=lambda item: _maildir_sort_key(item[0])):
if since_key is not None and _maildir_sort_key(source_uid) <= since_key:
continue
if not include_seen and _maildir_is_seen(path.name):
continue
yield MailboxSourceMessage(
source_uid=source_uid,
raw_bytes=path.read_bytes(),
raw_message_ref=f"maildir://{self.maildir_dir}/{path.parent.name}/{path.name}",
imap_uid=None,
dedup_uid=source_uid,
)
emitted += 1
if max_messages and emitted >= max_messages:
break
def _entries(self) -> dict[str, Path]:
# `cur` is scanned last so a message already moved out of `new` wins.
entries: dict[str, Path] = {}
for subdir in ("new", "cur"):
directory = self.maildir_dir / subdir
if not directory.is_dir():
continue
for path in directory.iterdir():
if path.name.startswith(".") or not path.is_file():
continue
entries[_maildir_base(path.name)] = path
return entries
class ImapMailboxSource:
def __init__(self, config: AppConfig) -> None:
self.config = config
@ -140,11 +205,36 @@ def source_for_config(config: AppConfig, *, fixture_dir_override: str | None = N
if not fixture_dir:
raise ValueError("source.fixture_dir is required for fixture scans.")
return FixtureMailboxSource(fixture_dir)
if config.mailbox.protocol == "maildir":
maildir_dir = config.source.maildir_dir
if not maildir_dir:
raise ValueError("source.maildir_dir is required for maildir scans.")
return MaildirMailboxSource(maildir_dir, mark_seen=config.scan.mark_seen)
if config.mailbox.protocol == "imap":
return ImapMailboxSource(config)
raise ValueError(f"Unsupported mailbox protocol: {config.mailbox.protocol}")
def _maildir_base(filename: str) -> str:
"""Strip the `:2,FLAGS` info suffix so `new/` and `cur/` names match."""
return filename.split(":", 1)[0]
def _maildir_is_seen(filename: str) -> bool:
_base, _, info = filename.partition(":")
return "S" in info.partition(",")[2]
def _maildir_sort_key(source_uid: str) -> tuple[int, int, str]:
"""Order by Maildir delivery time, falling back to name ordering."""
try:
return (0, int(source_uid.split(".", 1)[0]), source_uid)
except ValueError:
return (1, 0, source_uid)
def _search_criteria(*, include_seen: bool, since: datetime | None) -> list[str]:
criteria = ["ALL" if include_seen else "UNSEEN"]
if since is not None:

View file

@ -32,6 +32,7 @@ def parse_message_bytes(
mailbox_id: str,
raw_message_ref: str | None,
imap_uid: str | None = None,
source_uid: str | None = None,
now: datetime | None = None,
) -> tuple[InboundMailboxMessage, ParsedMailboxMessage, EmailEvidenceCandidate | None]:
observed_at = now or datetime.now(UTC)
@ -40,6 +41,7 @@ def parse_message_bytes(
mailbox_id=mailbox_id,
raw_message_ref=raw_message_ref,
imap_uid=imap_uid,
source_uid=source_uid,
observed_at=observed_at,
reason="empty_message",
)
@ -51,6 +53,7 @@ def parse_message_bytes(
mailbox_id=mailbox_id,
raw_message_ref=raw_message_ref,
imap_uid=imap_uid,
source_uid=source_uid,
observed_at=observed_at,
reason=f"parser_error:{type(exc).__name__}",
)
@ -66,6 +69,7 @@ def parse_message_bytes(
dedup_key = _message_dedup_key(
mailbox_id=mailbox_id,
imap_uid=imap_uid,
source_uid=source_uid,
message_id=message_id,
received_at=received_at,
from_address=from_address,
@ -251,8 +255,12 @@ def _parse_failed(
imap_uid: str | None,
observed_at: datetime,
reason: str,
source_uid: str | None = None,
) -> tuple[InboundMailboxMessage, ParsedMailboxMessage, EmailEvidenceCandidate | None]:
dedup_key = "|".join([mailbox_id, imap_uid or "", raw_message_ref or "", reason])
# An unparseable message has no headers to key on, so identity falls back to
# the source uid when there is one — a Maildir name stays stable across the
# `new/` to `cur/` move that rewrites raw_message_ref.
dedup_key = "|".join([mailbox_id, imap_uid or "", source_uid or raw_message_ref or "", reason])
mailbox_message_id = str(uuid5(NAMESPACE_URL, "email-connect:message:" + dedup_key))
inbound = InboundMailboxMessage(
mailbox_message_id=mailbox_message_id,
@ -303,19 +311,23 @@ def _message_dedup_key(
from_address: str | None,
subject: str | None,
body: str,
source_uid: str | None = None,
) -> str:
body_hash = hashlib.sha256(body.encode("utf-8", errors="replace")).hexdigest()[:16]
return "|".join(
[
mailbox_id,
imap_uid or "",
message_id or "",
received_at.isoformat() if received_at else "",
from_address or "",
hashlib.sha256((subject or "").encode()).hexdigest()[:12],
body_hash,
]
)
parts = [
mailbox_id,
imap_uid or "",
message_id or "",
received_at.isoformat() if received_at else "",
from_address or "",
hashlib.sha256((subject or "").encode()).hexdigest()[:12],
body_hash,
]
# Appended only when the source supplies its own delivery identity, so keys
# for existing fixture and IMAP state stores stay unchanged.
if source_uid:
parts.append(source_uid)
return "|".join(parts)
def _clean_header(value: str | None) -> str | None:

View file

@ -75,6 +75,7 @@ def scan_mailbox(
mailbox_id=config.mailbox.id,
raw_message_ref=message.raw_message_ref,
imap_uid=message.imap_uid,
source_uid=message.dedup_uid,
)
if not _in_range(inbound.received_at, range_start=range_start, range_end=range_end):
continue