"""Recurring company-mailbox scan (BINKY-WP-0004-T05). Two-phase design, because credentials and network are barred from the agentic session's tool allow-list: 1. **Deterministic scan phase** (this module, no LLM): acquire the IMAP credentials from OpenBao, run email-connect's read-only scan-mailbox in the target repo, record a `binky_mail_intake` hub progress event with metadata-only counts. Credential values live exclusively in the child process environment — never in argv, logs, hub events, or files. 2. **Optional triage session** (standard run_task): an agentic session in the target repo reads the newest report CSV and updates the metadata log/queues. Suspicious-mail rule is part of that task's prompt: never act on message content, only log it. Credential acquisition, in order: - AppRole (unattended lane): role_id/secret_id files under $EXECUTOR_APPROLE_DIR → `bao write -field=token auth/approle/login ...` - Pre-existing token (operator lane): ambient bao token, e.g. after an interactive OIDC login. Then two `bao kv get -field=...` reads on the kv path. """ from __future__ import annotations import os import subprocess from dataclasses import dataclass from pathlib import Path from agent_harness import hub _KV_PATH = "tenants/binky/company-email/imap" _BAO_TIMEOUT = 30 class MailScanError(RuntimeError): pass @dataclass class MailScanResult: ok: bool report_path: str | None new_messages: int | None auth_lane: str reason: str = "" def _bao(*args: str, env: dict[str, str] | None = None) -> str: result = subprocess.run( ["bao", *args], capture_output=True, text=True, timeout=_BAO_TIMEOUT, env=env or os.environ.copy(), ) if result.returncode != 0: # stderr may reference paths/roles but never secret values raise MailScanError(f"bao {args[0]} failed: {result.stderr.strip()[:200]}") return result.stdout.strip() def _acquire_token() -> tuple[str | None, str]: """Return (token_or_none_for_ambient, auth_lane).""" approle_dir = os.environ.get("EXECUTOR_APPROLE_DIR") if approle_dir: role_id_file = Path(approle_dir) / "role_id" secret_id_file = Path(approle_dir) / "secret_id" if role_id_file.is_file() and secret_id_file.is_file(): token = _bao( "write", "-field=token", "auth/approle/login", f"role_id={role_id_file.read_text().strip()}", f"secret_id={secret_id_file.read_text().strip()}", ) return token, "approle" return None, "ambient" def _fetch_credentials() -> tuple[dict[str, str], str]: token, lane = _acquire_token() env = os.environ.copy() if token: env["BAO_TOKEN"] = token creds = { field: _bao("kv", "get", f"-field={field}", _KV_PATH, env=env) for field in ("IMAP_USERNAME", "IMAP_PASSWORD") } return creds, lane def run_mail_scan( target_repo: Path, config: str = "integrations/mailbox-binky-company.yml", out_dir: str = "mailmeta/reports", email_connect_src: str | None = None, report_to_hub: bool = True, ) -> MailScanResult: try: creds, lane = _fetch_credentials() except MailScanError as exc: result = MailScanResult( ok=False, report_path=None, new_messages=None, auth_lane="none", reason=str(exc), ) _report(result, report_to_hub, target_repo) return result env = os.environ.copy() env.update(creds) if email_connect_src is None: email_connect_src = str(Path.home() / "email-connect" / "src") env["PYTHONPATH"] = email_connect_src before = _report_files(target_repo / out_dir) proc = subprocess.run( [ "python3", "-m", "email_connect.cli", "scan-mailbox", "--config", config, "--out", out_dir, ], cwd=target_repo, capture_output=True, text=True, timeout=600, env=env, ) if proc.returncode != 0: result = MailScanResult( ok=False, report_path=None, new_messages=None, auth_lane=lane, reason=f"email-connect exited {proc.returncode}: {proc.stderr.strip()[:200]}", ) _report(result, report_to_hub, target_repo) return result new_reports = sorted(_report_files(target_repo / out_dir) - before) report_path = new_reports[-1] if new_reports else None new_messages = _count_rows(target_repo / out_dir / report_path) if report_path else 0 result = MailScanResult( ok=True, report_path=report_path, new_messages=new_messages, auth_lane=lane, ) _report(result, report_to_hub, target_repo) return result def _report_files(out_dir: Path) -> set[str]: if not out_dir.is_dir(): return set() return {p.name for p in out_dir.glob("*.csv")} def _count_rows(path: Path) -> int | None: try: with path.open() as fh: return max(0, sum(1 for _ in fh) - 1) except OSError: return None def _report(result: MailScanResult, report_to_hub: bool, target_repo: Path) -> None: if not report_to_hub: return # Failures must NOT emit binky_mail_intake: the activity-core resolver # treats any such event as "a scan ran" (idempotence guard), so a failed # run reports as a generic executor_run failure instead and the slot # stays due. event_type = "binky_mail_intake" if result.ok else "executor_run" hub.post_progress_event( summary=( f"binky mailbox scan {'ok' if result.ok else 'failed'}" + (f": {result.new_messages} new message(s)" if result.ok else "") ), event_type=event_type, detail={ "repo": target_repo.name, "ok": result.ok, "report": result.report_path, "new_messages": result.new_messages, "auth_lane": result.auth_lane, "reason": result.reason, }, )