Code Structure

This document maps the agent-first-mail crate after the module split. It is a maintenance guide for contributors; user-facing mailbox behavior is documented in workspace.md, file-formats.md, and cli.md.

Top-Level Flow

afmail has four main layers:

  1. src/main.rs binds the registry’s actions, resolves argv, installs the resolved output plan, and owns process exit codes.
  2. src/cli/ holds the closed cli-spec-v1 registry (spec.rs), the resolved command types, and the projection from a resolved invocation onto a Command (build.rs).
  3. src/runner/ dispatches parsed commands to workspace operations and owns structured command logging/progress output.
  4. src/api/ defines HTTP DTOs, OpenAPI/JSON Schema generation, bearer/network serving, and thin handlers that enter the same runner dispatch as CLI calls.

Most behavior then lands in src/store/Workspace, which owns the file-first mailbox workspace. Network-facing code stays outside the store in imap_pull/, imap_client.rs, smtp_send.rs, remote.rs, and push_queue/.

Source Map

src/
  main.rs            registry binding, argv resolution, and exit handling
  cli/               cli-spec-v1 registry, command model, and invocation projection
  runner/            command dispatch, output, locks, push/purge, and blocking review sessions
  api/               HTTP handlers, typed DTOs, OpenAPI/Schema generation and export
  config/            typed config schema, defaults, key access, and validation
  store/             workspace file model and local state transitions
  imap_pull/         read-only IMAP pull sessions, remote identity, special-use folders
  imap_client.rs     low-level IMAP session, mailbox info, and move outcomes
  smtp_send.rs       outbound message construction and SMTP sending
  remote.rs          MailRemote trait over IMAP/SMTP side effects, plus the test fake
  push_queue/        durable local push items plus preview/execute helpers
  types/             stable ids, message/case/archive/push DTOs
  types/command_result.rs item counts, terminal-event selection, and process-exit mapping
  mail.rs            MIME parsing into message DTOs
  markdown.rs        Markdown frontmatter split/parse/render and conversation extraction
  frontmatter.rs     draft/triage/case-message frontmatter types
  draft_content.rs   typed compose/quote boundaries, completeness, and outbound projection
  render_document.rs versioned output-neutral documents shared by Markdown and Review
  templates.rs       shared Markdown/HTML MiniJinja loading, filters, and Review safety checks
  review_frontend.rs discovery, version, and trust of a user's own Review UI
  skill_admin.rs     skill install/status backing `afmail skill`
  progress.rs        workspace progress snapshot sink and status projection
  error.rs           AppError type with structured error codes and hints
  util.rs            crate-wide atomic-write, hashing, and flag helpers
  workspace_identity.rs  immutable workspace UID file
  workspace_lock.rs      external runtime lock-file guard
  plan_id.rs             canonical workspace-bound plan digest

Store Modules

store/mod.rs keeps the Workspace type, workspace discovery/init/status, and high-level wiring. Sibling modules add focused impl Workspace blocks:

Keep new store behavior near the durable state it changes. Prefer another small module only when a concern has its own file format, lifecycle, or command group.

runner/review.rs owns the review domain for triage, cases, notifications, and drafts. It contains the same typed render documents used by Markdown, renders the page through AFUI’s UiPage — one engine for both the doctor’s check and the live session, so a template cannot pass one and fail the other — while holding only a shared lock, then returns one AFDATA result read back from the workspace once the session ends. There is no batch and no page-wide submit: each item — a triage message, or the one case/notification a case/notification review is about — settles the moment a person decides it, writing typed, hash-bound human suggestion state immediately, but never applying the suggested domain action itself. A stale agent_suggestion is marked in place rather than refusing to open the whole review; only accepting or changing it is refused, at settle time. Case-note, notification-note, and draft edits debounce into automatic saves that acquire an exclusive lock, verify the base hash, and call the canonical Workspace API; the result lists each completed write in saved_files. Review never queues, pushes, or sends remote effects, and ending a session leaves every settlement and saved local change exactly as it was written.

runner/review/host.rs is the only place that domain meets agent-first-ui. AFUI owns delivery — the listener, the credential in the URL path, attention and warning-grace enforcement, the security headers and CSP nonce, the exact-Host and Origin checks, the isolated browser window on a disposable profile, and shutdown — so afmail hosts nothing itself. --mode link and afui session serve use the same AFUI attention policy (15 unattended minutes plus 5 minutes of warning grace by default), renewed by visible-page attention or framed UI traffic. afmail neither receives a lifetime setting nor constructs an AFUI expiry type. The host module contributes only the server-rendered GET / page and attaches AFUI’s typed runtime with managed blobs. Notes, draft saves, settlement, refresh, image loading, and the Agent turn are typed Review actions; afmail defines their domain payloads while AFUI owns the envelope, correlation, reconnect, progress/result/error completion, retained state, and temporary byte URLs. A user message becomes a review_input AFDATA progress event; one strict review_response JSON line on the still-running command’s stdin completes the one waiting AFUI call. With refresh:true, AFUI publishes retained changed state; the page requests a freshly rendered HTML blob, whose render first rereads the exact surface from canonical workspace documents, so controller text cannot inject a draft or suggestion. The host then builds the terminal result by rereading the workspace regardless of how the session ended: closed means the window closed (or, under --mode link/--mode session, the session’s own end route was hit) — the ordinary, unremarkable end of every review, not a decision — and expired means the AFUI-owned Link page reached the end of its unattended warning grace. The session’s type parameter to AFUI is (): nothing is ever handed back through completion, so Outcome::Completed is unreachable here. Saved files and the settled/unsettled summary are reported the same way for both outcomes, because the session’s state outlives the session.

The old Review-specific HTML/CSS/DOM-rendering bundle does not exist. Review templates own server-rendered structure, style, and localized copy; runner/review_runtime.js is an afmail-owned behavior-only runtime. It binds autosave/settlement controls to afui.connect, keeps the built-in UI’s visible turn history, loads attachment/page bytes through AFUI-managed blobs, and replaces only the declared Review content subtree after a canonical refresh. Review templates cannot supply JavaScript; the runtime clones template-declared turn markup rather than constructing page structure or carrying user-facing copy.

review_frontend.rs is where a person’s own Review UI comes from. agent-first-ui owns the location, the lookup order, safe mode, and the trust gate; this module names afmail’s provider id, the ui_kind per surface, and the Review UI API version afmail implements, and turns a frontend that cannot be loaded into an explicit error rather than a quiet built-in page. Only Review UI templates come from there — content templates stay in the workspace templates/ tree, and templates.rs refuses to serve either one from the other’s tree.

api/server.rs owns the Axum boundary but not domain behavior. Handlers reject malformed typed JSON, authenticate, obtain any required idempotency key, convert the DTO to an existing Command, and call runner::dispatch::execute_command_at. That path applies the normal workspace lock and incomplete-transaction guard. api/schema.rs generates both OpenAPI components and standalone JSON Schemas from the DTOs; committed files under openapi/ have a drift test.

File-State Boundaries

The workspace keeps durable state in a few canonical places:

push_queue/plan.rs is the pure Plan boundary: it resolves queue items, draft/MIME/attachment bytes, action steps, and content hashes without writing. Only a matching --confirm PLAN_ID enters exclusive reconcile/apply; audit-only recovery remains explicitly effect_scope: local_recovery.

Generated Markdown views (triage/, case.md, notification.md, views/, spam/*.md, trash/*.md, and deleted/*.md) should be rebuildable from those canonical files with afmail render refresh.

Refactor Rules

Validation Loop

Run every validation through the repository-owned project entry point from the repository root:

./scripts/test.sh static
./scripts/test.sh unit

static covers formatting, Clippy for the default set, the empty feature set, and each standalone api, demo, and review feature, plus generated CLI documentation drift, manifest JSON validity, and a dependency-tree assertion that axum and tokio are absent when default features are disabled. unit runs default-feature tests; minimal runs the same crate with no default features. all runs all three. Do not replace these entry points with hand-written Cargo command sequences.

The default feature set is api,demo,review. Disabled implementations are not compiled, but their command shapes remain discoverable and return a structured feature_disabled error. Keep the small fallback modules free of the optional dependencies so --no-default-features remains a real capability boundary.

Use focused modes for large integration targets instead of ad hoc Cargo command lines:

./scripts/test.sh cli [FILTER]
./scripts/test.sh request-id [FILTER]
./scripts/test.sh workspace [FILTER]
./scripts/test.sh config [FILTER]
./scripts/test.sh surface [FILTER]
./scripts/test.sh draft [FILTER]
./scripts/test.sh push [FILTER]

request-id runs the split-out request-id retry/audit integration target (tests/request_id_field_edits.rs). workspace runs the split-out workspace identity integration target (tests/workspace_identity.rs). config runs the split-out config and dependency contract target (tests/config_contract.rs). surface runs the split-out CLI surface contract target (tests/cli_surface_contract.rs). draft runs the split-out draft contract target (tests/draft_contract.rs). push runs the split-out push contract target (tests/push_contract.rs).

Fixture Batch, Container E2E, and GreenMail Workflow

The reusable mailbox story lives under tests/fixtures/mail-batch/: a manifest.json plus 30 realistic .eml files. Tests should load that manifest instead of inventing new one-off EML strings when they need a realistic inbox.

Run all ignored GreenMail/Dovecot container tests through the fixed E2E mode, or pass one Rust test-name filter as the second argument:

./scripts/test.sh e2e
./scripts/test.sh e2e container_greenmail_pull_reply_send_e2e

The same fixture batch powers a real agent-operation workspace. This prepares a live GreenMail mailbox and configured afmail workspace, but does not pull or process mail; the agent must actually operate the inbox:

./scripts/test.sh greenmail-prepare

After it prints the workspace path, open your agent there and say:

Check mail.

The prepared workspace uses ./bin/afmail, so it works without a global install. To install the current source globally for demo commands outside the workspace:

cargo install --path . --bin afmail --locked --force

The prepared workspace also includes a small mock CRM (./bin/crm). The prepare output suggests short demo prompts:

Clean spam.
Check the refund request.
refund this order, reply.

The CRM helper is intentionally optional context for customer/order cases, not a scripted requirement; the agent can use it when the email thread mentions an order and more context would help. The demo-owned AGENTS.md only receives this CRM lookup hint; normal mailbox behavior comes from the installed afmail skill, while afmail init deliberately does not create or edit AGENTS.md.

For an automated scripted demo pass, use:

./scripts/test.sh greenmail