Agent-First Mail v0.13.0: Past the Mailbox Server
afmail no longer needs an IMAP server. Mail can arrive through Cloudflare Email Routing and R2 and leave through Cloudflare's sending API, messages from a web form can come in as plain JSON files, and an ordinary process can wake an agent only when there is mail to read. Plus a person deciding what happened to a send nobody can vouch for, and a 4,000-message mailbox that went from five and a half minutes per pull to about twenty seconds.
Until now an afmail workspace meant an IMAP mailbox: messages arrived by pulling a server’s folders and left through SMTP. That fits a person’s own inbox. It fits a support address on a domain you control less well, and it does not fit messages that never were email — a contact form, a chat widget, a queue another program writes to.
This release separates the workspace from where messages come from. Cases, drafts, review and confirmed pushes work the same whatever delivered the message; an adapter owns the receiving and sending details.
Mail through Cloudflare
A domain on Cloudflare can now receive mail with no mailbox server at all.
Email Routing hands each message to a Worker, the Worker writes the raw EML
into an R2 bucket, and afmail pull --cloudflare imports it. Each object is
acknowledged only after the import is durable — the evidence is on disk and the
local index knows the message — so an interrupted pull replays as a merge rather
than losing or duplicating mail. One object that cannot be imported is reported
as a warning and stays in R2; the scan carries on with the rest.
afmail cloudflare scaffold DIR writes the Worker project: source, locked
dependencies, tests, and a deployment README for either wrangler or cf. The
Worker enforces a spool quota through a Durable Object ledger, so a flood of
mail fills a bounded bucket rather than an unbounded bill. A write whose outcome
was unknown no longer stays charged forever: the ledger settles it with a
conditional-create fence, which either proves the original landed or makes sure
it never can.
Sending can go the same way. A draft’s send step may be cloudflare_send
instead of smtp_send, which submits the generated MIME to Cloudflare’s
send_raw API with the full envelope — Bcc included in the envelope, stripped
from the transmitted headers. The receipt (provider message id, delivered,
queued, bounced and suppressed recipients) is kept in the push result and the
audit log. A submission that times out, loses its response, or returns a
receipt that does not account for every recipient is
remote_outcome_unknown, never resubmitted automatically. A message id is not
an idempotency key.
Messages that were never email
A file channel is a pair of directories. A producer — a web form backend, a
chat bridge — atomically writes channel_message JSON records into the inbox;
afmail pull --files imports them as messages; replies a person approves are
published into the outbox as JSON for the producer to deliver. No SMTP, no
network credentials, no invented headers: a native message keeps its original
record and a typed reply route — channel, source, conversation — instead of
MIME and IMAP locations. Participant ids are opaque strings, not email
addresses.
Native messages go through triage, cases, drafts and Review like any other. An
optional reply_to_external_message_id links a reply to its parent within the
same source and conversation, and message show, detailed triage lists and the
Review page show that link in both directions.
The producer owns its inputs, so afmail tells it what it may retire: a pull
lists what it imported, and inputs it skipped for budget reasons are named in
files.skipped_inputs rather than only counted. Publishing into the outbox
never overwrites: identical content counts as already published, different
content under the same name is a conflict.
Waking an agent only when mail arrives
Checking a mailbox on a timer from inside an agent spends model context on
nothing. afmail cloudflare watch is an ordinary long-running program instead.
R2 object-create notifications go to a Cloudflare Queue; watch polls the
queue, imports the new mail, and runs a configured command only when there is
durable new-message work. Idle ticks are silent and use no model.
The command runs without a shell, gets no subject, sender or body in its
arguments, and receives two trusted locators: AFMAIL_TASK_PATH, a persisted
JSON batch of message ids, and AFMAIL_TASK_ID, a stable idempotency key that
does not change on retries. It should exit zero once a scheduler has durably
accepted the task. A nonzero exit, a failure to start, or a timeout leaves the
same batch pending with the same id. Handler descendants are stopped on exit,
timeout or interruption — a process group on Unix, a Job Object on Windows
assigned before the handler’s first instruction. afmail cloudflare consume
runs one tick and exits.
The consumer’s journal is bounded: completed tasks are pruned, and receipts are kept for 15 days, beyond Cloudflare Queues’ maximum retention of 14.
A send nobody can vouch for
When the connection drops after a message was handed to SMTP, or Cloudflare’s
response never arrives, afmail cannot know whether the mail went out. It marks
the step remote_outcome_unknown and never retries it on its own. Until now
that left the push stuck.
afmail push recover PUSH_ID now previews an uncertain send step — SMTP,
Cloudflare or file — with the evidence it has. A person checks the mailbox, the
recipient, or the outbox, and settles it. --sent --confirm PLAN_ID marks the
step done, and the remaining steps — appending the Sent copy, say — run on the
next confirmed push. --not-sent --confirm PLAN_ID returns the step to
pending, so sending again takes a new preview and confirmation. Each settlement
is audited as the person’s statement, and nothing is ever replayed without it.
--not-sent is refused when a file publication with exactly that content
already exists.
Around it: a failure to re-resolve the SMTP password after the outcome was recorded is tagged as never sent, a send whose draft was already finalized resumes at finalization instead of failing on the missing draft, and a plan whose send step this build cannot perform is refused at preview — never after a Drafts APPEND has already happened.
At the size of a real mailbox
A 4,164-message workspace with 1,571 cases took five and a half minutes per pull, and nearly seven minutes to preview a purge. Almost none of it was the network. Reading one message rebuilt a snapshot of every case, notification, disposition and pending push, so any loop over messages rescanned the whole workspace once per message.
Loops now read against one snapshot. On that mailbox a pull takes about twenty seconds, most of it waiting for the IMAP server; a purge preview takes a few. Detailed triage lists, disposition and notification views, push planning and remote reconciliation share the fix. Whether any of a set of messages is still referenced — by a case, draft, push or notification — is answered for the whole set in one pass instead of one scan per message. A test now counts those scans and fails if they grow with the number of messages.
Smaller things
- Settling a suggestion in Review no longer has a default. The choices are cards with nothing selected, and Settle stays disabled until the person picks one; rejecting or choosing another outcome requires a reason, and accepting may carry a note.
- An address named in more than one of To, Cc and Bcc is in the envelope once. It was there once per header, which made a Cloudflare receipt that listed it once look like a send that had not reached every recipient.
- An incremental pull no longer moves its cursor past a UID the server left out of a response; it reports the gap and fetches it next time.
case addandcase create --messagerefuse a message whose push for another action has started before writing anything. Notification moves and creates run under a transaction marker thatdoctorcan see.--cloudflareand--filespulls reject--since-daysand--all-historyat parse time, and--helpshows the legal combinations.- Attachment saves are atomic, display names keep their quoting in reply addresses, and attachment reads are bounded by the inspected file size.
- afmail no longer embeds the Worker template’s
node_modules— a dependency upgrade that rannpmthere had compiled about 300 MiB into the binary.
Upgrading a workspace
A workspace written by v0.12.0 must be upgraded once:
$ afmail init
It rewrites each message’s metadata in place, and the workspace works as
before. Until then, commands that read messages stop with
workspace_upgrade_required and that hint. The upgrade is one-way; keep the
workspace in Git, as the setup guide suggests, if you may want to go back.
Breaking changes
- Message metadata is schema 3, adding the delivery envelope. Run
afmail initonce per workspace. remaining_countis gone from push results. It always equalledunattempted_count; read that instead.- Agent-First UI 0.9.0, Agent-First Data 0.36.1 and Agent-First Slug 0.9.0 are the pinned dependencies, and the minimum Rust version is 1.99.
Getting it
$ brew install agentfirstkit/tap/afmail
$ cargo install agent-first-mail