from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime from uuid import uuid4 from .config import AppConfig from .evidence import endpoint_quality_from_candidate from .mailbox import source_for_config from .models import MailboxScan from .parser import parse_message_bytes from .reporting import write_evidence_report from .storage import StateStore @dataclass(frozen=True) class ScanResult: scan: MailboxScan report_path: Path | None def scan_mailbox( config: AppConfig, *, output_dir: str | None = None, full_rescan: bool = False, report_only_new: bool = False, dry_run: bool = False, fixture_dir: str | None = None, since: str | None = None, ) -> ScanResult: started_at = datetime.now(UTC) scan_id = str(uuid4()) since_at = _parse_since(since or config.scan.since) source = source_for_config(config, fixture_dir_override=fixture_dir) store = StateStore(config.storage.path) messages_seen = 0 messages_new = 0 messages_parsed = 0 evidence_created = 0 new_evidence_keys: list[str] = [] last_source_uid: str | None = None try: cursor = None if full_rescan else store.get_scan_cursor(config.mailbox.id, config.mailbox.folder) for message in source.iter_messages( max_messages=config.scan.max_messages_per_run, since_uid=cursor, full_rescan=full_rescan, include_seen=config.scan.include_seen, since=since_at, ): messages_seen += 1 last_source_uid = message.source_uid inbound, parsed, candidate = parse_message_bytes( message.raw_bytes, mailbox_id=config.mailbox.id, raw_message_ref=message.raw_message_ref, imap_uid=message.imap_uid, ) if since_at and inbound.received_at and inbound.received_at < since_at: continue if dry_run: messages_parsed += 1 continue is_new = store.upsert_message(inbound) messages_new += int(is_new) store.insert_parsed(parsed) messages_parsed += 1 if candidate is not None: candidate = _enrich_candidate(candidate, inbound, parsed) inserted = store.insert_evidence(candidate) evidence_created += int(inserted) if inserted: new_evidence_keys.append(candidate.deduplication_key) quality_update = endpoint_quality_from_candidate(candidate) if quality_update is not None: store.apply_endpoint_quality_update( config.mailbox.id, quality_update, observed_at=candidate.observed_at, ) if not dry_run and last_source_uid: store.set_scan_cursor( config.mailbox.id, config.mailbox.folder, last_source_uid, updated_at=datetime.now(UTC), ) report_path = None if not dry_run: report_rows = store.evidence_rows( deduplication_keys=new_evidence_keys if report_only_new else None, ) report_path = write_evidence_report( report_rows, output_dir=output_dir or config.reports.output_dir, scan_id=scan_id, mailbox_id=config.mailbox.id, ) finished_at = datetime.now(UTC) scan = MailboxScan( scan_id=scan_id, mailbox_id=config.mailbox.id, started_at=started_at, finished_at=finished_at, scan_mode="full_rescan" if full_rescan else config.scan.mode, folder=config.mailbox.folder, status="completed", messages_seen=messages_seen, messages_new=messages_new, messages_parsed=messages_parsed, evidence_events_created=evidence_created, report_path=str(report_path) if report_path else None, since=since_at, ) if not dry_run: store.insert_scan(scan) return ScanResult(scan=scan, report_path=report_path) finally: store.close() def _enrich_candidate(candidate, inbound, parsed): metadata = { **candidate.metadata, "source_from": inbound.from_address, "source_to": ", ".join(inbound.to_addresses), "source_subject": inbound.subject, "message_id_header": inbound.message_id_header, "original_recipient": parsed.original_recipient, "first_seen_at": inbound.first_seen_at.isoformat(), "last_seen_at": inbound.last_seen_at.isoformat(), } return type(candidate)( **{ **candidate.__dict__, "metadata": metadata, } ) def _parse_since(value: str | None) -> datetime | None: if not value: return None normalized = value.strip() if not normalized: return None if len(normalized) == 10: normalized = normalized + "T00:00:00+00:00" parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00")) if parsed.tzinfo is None: return parsed.replace(tzinfo=UTC) return parsed.astimezone(UTC)