email-connect/src/email_connect/scanner.py

157 lines
5.3 KiB
Python
Raw Normal View History

2026-06-02 01:19:09 +02:00
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from uuid import uuid4
from .config import AppConfig
2026-06-02 02:07:50 +02:00
from .evidence import endpoint_quality_from_candidate
from .mailbox import source_for_config
2026-06-02 01:19:09 +02:00
from .models import MailboxScan
2026-06-02 02:07:50 +02:00
from .parser import parse_message_bytes
2026-06-02 01:19:09 +02:00
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,
2026-06-02 02:07:50 +02:00
since: str | None = None,
2026-06-02 01:19:09 +02:00
) -> ScanResult:
started_at = datetime.now(UTC)
scan_id = str(uuid4())
2026-06-02 02:07:50 +02:00
since_at = _parse_since(since or config.scan.since)
source = source_for_config(config, fixture_dir_override=fixture_dir)
2026-06-02 01:19:09 +02:00
store = StateStore(config.storage.path)
messages_seen = 0
messages_new = 0
messages_parsed = 0
evidence_created = 0
2026-06-02 02:07:50 +02:00
new_evidence_keys: list[str] = []
last_source_uid: str | None = None
2026-06-02 01:19:09 +02:00
try:
2026-06-02 02:07:50 +02:00
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,
):
2026-06-02 01:19:09 +02:00
messages_seen += 1
2026-06-02 02:07:50 +02:00
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
2026-06-02 01:19:09 +02:00
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)
2026-06-02 02:07:50 +02:00
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),
)
2026-06-02 01:19:09 +02:00
report_path = None
if not dry_run:
2026-06-02 02:07:50 +02:00
report_rows = store.evidence_rows(
deduplication_keys=new_evidence_keys if report_only_new else None,
)
2026-06-02 01:19:09 +02:00
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,
2026-06-02 02:07:50 +02:00
since=since_at,
2026-06-02 01:19:09 +02:00
)
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,
}
)
2026-06-02 02:07:50 +02:00
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)