EMAIL-WP-0005-T01: add Maildir mailbox source
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:
parent
9c0d6f1b40
commit
86f22c2e65
9 changed files with 365 additions and 18 deletions
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue