File Formats
This document describes the afmail v2 archive-oriented disk model.
Persisted JSON state documents use schema_name plus schema_version to
identify their on-disk format. Agent-First Data protocol outputs and audit
events use code instead.
Workspace Identity
.afmail/workspace.json is durable, private-Git-trackable identity:
{
"schema_name": "workspace_identity",
"schema_version": 2,
"layout_version": 2,
"workspace_uid": "workspace_0123456789abcdef0123456789abcdef",
"created_rfc3339": "2026-07-11T08:00:00Z"
}
The UID is workspace_ plus 32 lowercase hex digits generated from 128 bits of
secure randomness. Init is idempotent and never changes the UID or creation
time. Parsing is strict, including unknown fields. Missing and invalid identity files return
workspace_identity_missing and workspace_identity_invalid; afmail never
silently generates a replacement for a non-empty existing workspace. Preserve
this file when moving, cloning, or restoring the private mailbox repository.
A v1 identity or other legacy layout returns workspace_upgrade_required from
ordinary commands. Run afmail init once to migrate; runtime readers do not
support both layouts.
Workspace Configuration
.afmail/config.json is a sparse override document. afmail config show
returns the effective typed config after defaults are applied. The
afmail config set command validates a candidate before saving it. Review
provider limits live under review:
| Field | Default | Meaning |
|---|---|---|
max_channel_text_bytes | 65536 | Maximum UTF-8 bytes in one Review user message or controller response |
max_inline_image_bytes | 16777216 | Maximum declared, bounded-read, and decoded bytes for one raster preview |
Both values must be greater than zero. Remote page lifetime is owned by AFUI,
not this workspace. Direct Review links and pages created by afui session serve use the same global AFUI attention.idle_timeout_s and
attention.grace_period_s; link_expires_in_s is therefore not an afmail
configuration key.
Private Git Restore Checks
afmail does not have a separate backup or restore format. A private Git clone, fetch, or worktree move is expected to preserve the workspace files directly. After restoring that repository on the same or another machine:
- Run
afmail statusin the workspace and verify theworkspace_uid, readiness fields, object counts, andstorage.*_bytestotals look like the intended mailbox. - Run
afmail doctorbefore writing new local state or confirming remote pushes. Doctor reports canonical file problems, incomplete transactions, pending idempotency request records, contact index conflicts, and retained push/audit recovery state. - Resolve reported issues using the existing doctor and push recovery flows. Do not assume that deleting files from the restored worktree removes mail evidence from Git history or from other clones/backups.
IMAP Body Fetch Safety
imap.allow_unbounded_body_fetch defaults to false. Before a mutating pull,
afmail probes a separate read-only connection with a one-byte RFC partial BODY
fetch. Supported servers use bounded chunks and reject a lying oversized
literal before parsing or persistence. A server that closes/rejects this probe
fails with imap_partial_fetch_unsupported and zero local changes.
Some compatibility servers, notably GreenMail, do not implement partial BODY
ranges. Setting imap.allow_unbounded_body_fetch: true explicitly permits the
fallback that fetches one declared-in-limit literal at a time and checks actual
bytes afterward. This preserves interoperability but trusts the server not to
lie so severely that one response exhausts memory; enable it only for a trusted
server. It never disables count/bytes budgets or the declared/actual skip.
Message identity
A message’s identity is derived from its raw RFC822 bytes, not from its
Message-ID header. Two remote copies collapse into one local message only when
those bytes are identical; a copy that differs by a single added header — a
mailing-list List-Id, a per-folder X- header — is a second message with its
own id and its own evidence. This is why identity is byte-exact rather than
header-based: a Message-ID is supplied by the sender, is not unique in
practice, and a mailbox that rewrites headers per folder would otherwise make
two materially different documents share one identity.
The practical consequences:
- One message can hold several remote locations (Inbox and All Mail, say). Those
are recorded on the one message record, and
pullreports them asupdated_location_countrather than as new messages. - Because the check needs the body, a duplicate is only recognized after it is
fetched.
pullrefunds that message’s count and byte budget when it turns out to be a copy, butpull --dry-runcannot know in advance — itscandidate_countis an upper bound, flagged bycandidate_count_is_upper_bound. message_already_existsreports a message whose bytes are already stored;message_id_conflictreports the same id arriving with different bytes.- A mailbox whose server resets
UIDVALIDITYis re-anchored rather than re-imported:pullreportsimap_uidvalidity_changedalong withuid_validity_reset_countanduid_validity_reset_mailbox_ids.
Message JSON
Raw message evidence lives at .afmail/messages/<message_id>.eml. Canonical
message metadata lives beside it at .afmail/messages/<message_id>.json; this
single document owns delivery envelopes, remote IMAP state, plus the current Agent/human suggestion
round. Parsed root messages/<message_id>.json files are rebuildable projections
with schema_name: "message" and schema_version: 3; they expose the canonical
suggestions at the message top level alongside parsed headers, attachment
metadata, body_text, remote and workspace overlays, contact links, and identity
matches.
An older generated message schema can cause ordinary read commands to return
message_cache_invalid. Run afmail doctor to inspect it: an invalid cache is
marked repairable: true only when the current program can reconstruct it from
the raw .eml, valid canonical metadata, and local object state without writing
anything. afmail doctor repair --confirm rebuilds those projections locally;
it does not push remote effects. If the workspace itself needs migration, run
afmail init first. Never change a cache’s schema number by hand.
body_text is the sender’s text/plain part verbatim (or a tag-stripped text
when the mail has only HTML); hashes and quote segmentation are computed over
it and it never changes shape. A message with a real HTML part also carries
body_markdown, the reading copy: that part converted to Markdown, so links
keep their text and target ([选购](https://…)), images become their alt
text, and code, styling, and interactive content are dropped. Both are
rebuilt from the .eml whenever the cache is materialized. The render
document’s analysis.body_markdown splits it into authored/quoted byte ranges
like analysis.body and additionally lays each part out as authored /
quoted block lists (kind paragraph, heading, list_item, quote, code, rule;
runs of text with optional href, emphasis, strong, code). The last
run of a link also says where it goes without anyone following it: host is
the registrable domain of href (Public Suffix List, ASCII form, so a
Unicode host and its punycode twin compare equal), and
sender_domain_match says whether that is the sender’s own registrable
domain — the authenticated domain, else the From domain — by plain
equality, with no lookalike heuristics; it is absent when the sender’s domain
is unknown. Review renders those blocks: a link is text with its destination
shown beside it, marked when it is not the sender’s, and never navigates the
window — opening goes through the system browser. Generated Markdown views
quote the Markdown text itself in a ```markdown fence.
link claimed another domain or imitates the sender’s.
Shared reads parse canonical EML/metadata evidence and apply workspace, identity,
and contact projections in memory without rewriting this cache. Pull, explicit
mutation/repair, and afmail render refresh are the materialization boundaries.
The contact field is present only when the From address matches a contact
card. It contains the exact typed ContactFrontmatter, not a separately shaped
UID/name snapshot, and is refreshed at pull/render time and on contact changes:
{
"contact": {
"kind": "contact",
"contact_uid": "p20260521001",
"display_name": "Zhang San",
"emails": ["zhangsan@example.com"],
"phones": [],
"organization": "Example",
"role": "Customer",
"tags": [],
"created_rfc3339": "2026-05-21T10:00:00Z",
"updated_rfc3339": "2026-05-21T10:00:00Z"
}
}
Inbound mail records which configured identity appears to have received the
message. Unique matches set identity, identity_email, and identity_match
(email or name). Ambiguous same-address personas set identity_match: "multiple" plus identity_candidates. Unknown recipient addresses set
identity_match: "unmatched" plus observed_recipient_emails.
{
"identity": "support",
"identity_email": "hello@example.com",
"identity_match": "name"
}
Canonical message metadata:
{
"schema_name": "message_metadata",
"schema_version": 3,
"message_id": "message_20260521_3af9c1b2e8d04f6a",
"remote": {
"locations": [
{
"mailbox_name": "INBOX",
"mailbox_id": "inbox",
"uid_validity": 44,
"uid": 900,
"flags": ["\\Seen"],
"observed_rfc3339": "2026-06-01T17:30:00Z",
"missing_rfc3339": null
}
]
},
"case_relationship_suggestion": {
"case_uids": ["c20260521001"],
"evidence": "reply_headers_matched_local_case"
},
"agent_suggestion": {
"agent_suggestion_uid": "agent_suggestion_01",
"message_review_base_hash": "52aca0f5",
"suggested_action": {"kind": "case_add", "case_ref": "c20260521001"},
"reason": "This continues the existing case.",
"confidence_percent": 94,
"created_rfc3339": "2026-06-01T17:32:00Z"
}
}
Cloudflare delivery metadata is kept in this sidecar, never injected into MIME:
{
"delivery": {
"envelope_from": "sender@example.net",
"envelope_to": ["hidden@example.com", "alias@example.com"],
"received_rfc3339": "2026-10-21T00:00:00Z",
"sources": [
{"kind": "cloudflare_r2", "bucket": "afmail-spool", "key": "inbox/20261021T000000Z-01234567.eml"}
]
}
}
Delivery fields are optional. sources also supports {"kind":"file","path":"incoming.eml"}.
When identical raw bytes arrive again, afmail merges them into the existing
message: recipient addresses form a case-insensitive, order-preserving union,
source records are unique, and the earliest delivery timestamp is retained.
The first available envelope sender is retained. Reading the sidecar merges
its recipient addresses into MessageFile.envelope_to before identity matching,
so a recipient hidden from To/Cc is still recognized. A matching Message-ID
header alone never merges messages with different bytes. IMAP locations and
Cloudflare delivery sources coexist independently on the same content identity.
human_suggestion, when present, holds one of four outcomes. accepted and
changed bind the current agent_suggestion_uid and the same review hash —
accepted copies the complete Agent action, changed stores a complete
replacement action. rejected stores no action and requires a reason, whether
or not it answers a current agent_suggestion. decided has no
agent_suggestion_uid at all — the person originated the disposition
themselves — and stores a complete action. A new Agent suggestion or explicit
clear removes the prior human response.
Triage message:
{"workspace": {"status": "triage"}}
Local discard dispositions are canonical in object directories, not per-message state sidecars:
{
"schema_name": "spam",
"schema_version": 2,
"collection_uid": "spam",
"collection_name": "spam",
"status": "spam",
"items": [
{
"message_id": "message_20260521_3af9c1b2e8d04f6a",
"added_rfc3339": "2026-06-01T17:30:00Z"
}
]
}
The matching files are spam/data/spam.json, trash/data/trash.json, and
deleted/data/deleted.json (schema_name: "deleted_remote"). Fresh
workspaces do not create empty disposition directories or JSON; a disposition
data file is created when the first message enters that disposition and may
remain if the collection later becomes empty.
Notification message:
{
"workspace": {
"status": "notification",
"notification_uid": "n20260521001",
"notification_rfc3339": "2026-06-01T17:30:00Z"
}
}
Message-side remote effects are tracked separately from local disposition. A
message that is already locally filed, spammed, or trashed can still show queued
server work under workspace.push.pending[]:
{
"workspace": {
"status": "spam",
"push": {
"pending": [
{
"push_id": "push_20260606T120000Z",
"kind": "message.spam",
"queued_rfc3339": "2026-06-06T12:00:00Z"
}
]
}
}
}
When a queued remote effect succeeds, pending[] is cleared and
last_completed_rfc3339 records the last successful server-side write.
Other local statuses include case, spam, trashed, push_queued, and
deleted_remote. The parser also accepts triage, notification, and
archived as workspace statuses, even though the materialized
case/disposition views do not emit them.
Generated read views for negative dispositions live at spam/index.md,
spam/<message_id>.md, trash/index.md, trash/<message_id>.md,
deleted/index.md, and deleted/<message_id>.md. Fresh workspaces do not
create empty disposition views. Existing generated views are rebuildable with
afmail render refresh and ignored by the managed .gitignore; the durable
message state remains in .afmail/messages/ and messages/*.json.
afmail purge, afmail purge spam, afmail purge trash, and
afmail purge deleted are pure previews. They return a purge_plan whose ID
binds the immutable workspace identity, selected dispositions, age threshold,
candidate/skipped decisions, and candidate artifact bytes. Supplying that exact
ID through --confirm PLAN_ID permanently deletes the reviewed records and
then refreshes the same views; changed state rejects the old plan before any
deletion.
Message attachment metadata lives in the materialized message JSON. Attachments are not copied into case files by assignment or case creation:
Agent-facing message show and generated template message objects mark remote
mail fields with content_trust: untrusted_remote and a
content_provenance object containing source: remote_mail; the stable message
ID remains in its one canonical message_id field. Markdown templates consume
canonical fields through output-specific escaping filters, so raw headers stay
available as evidence without being treated as trusted Markdown structure.
{
"attachments": [
{
"part_id": "2",
"filename": "pricing.txt",
"content_type": "text/plain",
"size_bytes": 128,
"file_path": ".afmail/messages/message_20260521_3af9c1b2e8d04f6a.files/pricing.txt"
}
]
}
part_id is the MIME part id used by
afmail message attachment fetch MESSAGE_ID [PART_ID]. file_path is present
only when a local message-cache copy exists under
.afmail/messages/<message_id>.files/; there is no duplicate fetched flag.
The managed .gitignore ignores rebuildable messages/*.json, generated
Markdown read views, .afmail/push/, .afmail/logs/, .afmail/transactions/,
.afmail/requests/, .afmail/cache/, and installed
workspace skill directories such as .codex/skills/agent-first-mail/ or
.claude/skills/agent-first-mail/. It intentionally does not ignore
.afmail/messages/. If you track
.afmail/messages/ in git, the repository will contain private mail bodies,
canonical message metadata, and raw attachment bytes.
.afmail/cache/ is the only metadata directory that is safe to remove, and only
while no afmail process is running: it holds workspace.lock, and removing the
lock file under a running process lets a second writer take a fresh lock beside
it. The other ignored directories contain lossy machine-local state.
Triage View
Generated triage views live at triage/<message_id>.md and include YAML
frontmatter with only kind: triage_view and message_id. Triage views are
rebuildable and are not a notes or suggestion surface. Relationship evidence
lives once in the typed case_relationship_suggestion of canonical message
metadata; the template may render it, but neither suggestions, relationships,
parallel IDs, nor derived counts are copied into the built-in frontmatter.
afmail never reads generated Markdown back as state, so workspace templates are
free to replace or omit that frontmatter.
An Agent that has a recommendation reads review_base_hash from
afmail message show and calls afmail triage suggestion set; afmail writes
the typed proposal to canonical message metadata and records
agent_suggestion_set. suggestion clear requires the current
agent_suggestion_uid and records suggestion_cleared, preventing an old
review from clearing a newer round. A proposal is optional, not a
precondition: afmail triage review opens on a message with no
agent_suggestion just as it does on one with one, and accepts no
caller-owned context file either way. Settling one message in the review
window persists its human_suggestion and records human_suggestion_set
immediately — there is no page-wide submit, and the terminal result is read
back from the workspace when the session ends rather than carried out of it.
Message actions are a closed enum: case_create, case_add,
notification_create, notification_add, spam, and trash. These are
proposals only. Applying one still requires the corresponding canonical domain
command; remote effects retain their separate preview/confirm boundary.
Case Workspace
Active cases live at cases/<group>/<case_uid>-<name>/; archiving and archived
cases live at archived-cases/<group>/<case_uid>-<name>/. The <group> segment is a case
group retained for history/lookup, not an archive category.
case_uid is a stable cYYYYMMDDNNN identity and notification_uid is a stable
nYYYYMMDDNNN identity. Human-readable names live in the shared
collection_name field and in the directory suffix. Refs may be either the bare
UID or UID-any-readable-suffix; names alone are not valid refs. Human names
may use Unicode path segments such as Chinese, but they must not contain path
separators or be dot-only segments.
Case metadata is canonical in data/case.json:
{
"schema_name": "case",
"schema_version": 3,
"collection_uid": "c20260521001",
"collection_name": "应用反馈-肥料登记",
"tags": [],
"created_rfc3339": "2026-05-22T09:00:00Z",
"updated_rfc3339": "2026-05-22T09:00:00Z",
"items": [
{
"message_id": "message_20260521_3af9c1b2e8d04f6a",
"summary": "Renewal pricing and contract timing.",
"added_rfc3339": "2026-05-22T09:00:00Z"
}
]
}
The collection stores membership once in items. It does not persist
message_count, thread_count, attachment_count, or a derived last-message
timestamp. Markdown and Review templates derive counts and ordering from the
contained collection and message documents.
Case-level agent_suggestion and human_suggestion use
case_review_base_hash and a case-specific action enum (case_archive,
case_unarchive, case_move, case_rename, case_tag, case_untag,
case_trash, or case_delete). They live in this same case.json; no separate
suggestion file exists. Notes and drafts remain their own canonical user files
and are never copied into the suggestion envelope.
Active cases have an empty status; archiving cases use status: archiving
while waiting for queued sends and the independent remote archive push;
finalized cases use status: archived. Archived cases set
archived_rfc3339; archiving cases do not. Cases do not store notification
collections.
Case membership is canonical in data/case.json under items[]; each item uses
the same message collection item shape as notification collections:
message_id, optional summary, and added_rfc3339.
Generated case read views live at case.md and
views/messages/<message_id>.md inside the case workspace. case.md is rendered
from case/case.md.j2, starts directly with a Markdown heading (no YAML
frontmatter), and links to messages from the case root as
views/messages/<message_id>.md. Generated views are rebuilt from
data/case.json and message evidence/cache.
Draft Markdown files use frontmatter fields kind, case_uid, send_intent,
reply_to_message_id, identity, subject, to, cc, bcc, and
attachments.
When queued, afmail also manages sync_intent, whose only values are
save_draft and send:
---
kind: draft
case_uid: c20260521001
send_intent: reply
reply_to_message_id: message_20260521_3af9c1b2e8d04f6a
identity: support
subject: "Re: Contract renewal"
to:
- alice@example.com
cc: []
bcc:
- finance-audit@example.com
attachments:
- files/pricing.txt
---
# Reply
<!-- afmail:compose:start -->
Thanks, I will send the revised terms today.
<!-- afmail:compose:end -->
On a previous message, Alice wrote:
<!-- afmail:quote:start -->
> Could you send the revised terms?
<!-- afmail:quote:end -->
The compose marker pair is part of the current draft schema. Edit only the
content between it; do not delete, duplicate, reorder, or paste another marker
into the authored body. Reply templates keep quoted history in a separate quote
pair after compose. There is deliberately no fallback for pre-schema drafts:
validation returns draft_invalid with an upgrade hint until the file is
rewritten once into this format.
draft validate, list/show output, and draft review expose
completeness: complete | incomplete. Template headings, quoted history, and
identity footers are outside compose and do not make a draft complete. An
incomplete draft may be queued with draft upload, but draft send returns
draft_incomplete without creating or changing a push item. Push validates the
latest draft again before any remote step. Editor-only headings and marker lines
are removed from the generated text/plain MIME body.
At least one to or bcc recipient is required. Bcc addresses remain part of
the canonical local draft and the sender’s private Drafts/Sent evidence, and
they are shown in push preview for confirmation. SMTP submission includes them
as envelope recipients but removes the Bcc header from the DATA bytes
delivered to recipients.
Draft status is projected from both frontmatter and the matching durable push
item: editable | queued_upload | queued_send | pushing | failed. A queued send
is never called sent; successful push consumes the draft, and the outbound
message plus audit event record the completed send. Compact draft list rows
also expose operational queued_action (save_draft, send, or null) and
remote_changed: false. draft show and Review instead contain the exact
typed DraftRenderDocument: locator data, one canonical
draft.frontmatter/draft.body_markdown, and output-neutral analysis. They do
not repeat the list projection.
Draft review Save writes the canonical draft locally through the Workspace API,
under an exclusive lock and only when its base hash still matches. Its result
includes the completed local write in saved_files; cancelling, dismissing, or
finishing the review does not undo it. Case and notification review follow the
same rule for notes.md. Submitting triage, case, or notification review writes
the hash-bound human_suggestion to the canonical JSON and records an audit
event, but does not execute the suggested domain action or modify notes or
drafts as part of submission. Review Save never queues, pushes, or sends mail.
attachments: is a list of case-relative paths for outbound files. Use
afmail case draft attach REF DRAFT_NAME PATH to populate it. External sources
are copied into the case files/ directory; files already under the case are
recorded as safe relative paths. Inbound message-cache paths under
.afmail/messages/<message_id>.files/ are message evidence and should not be
used as draft attachment paths.
identity: is a config identity slug. If absent, afmail resolves the configured
default identity. Optional footers are inserted outside the compose/quote
sections when the draft is created or its identity changes; send time does not
append hidden text.
data/drafts.json is afmail-managed case-local state. It records each draft’s
last_validated_hash and last_validated_rfc3339. Agents and humans edit
draft Markdown directly (or use draft change); queued draft upload and
draft send items reference the draft by case and filename and use the latest
valid Markdown at push time. They should not edit data/drafts.json directly.
Identity Persona Override
.afmail/config.json is the canonical identity registry. Each configured
identity has a slug, display name, email address, and exactly one default.
Optional identities/<identity>.md files may override the display name and add a
footer plus agent-facing notes. They do not override email or default.
---
kind: identity
identity: support
name: "Support Team"
footer: |
--
Support Team
Agent-First Kit
---
Use this identity for customer support replies.
The frontmatter identity must exist in config and must match the file stem.
Contacts are other people; identities are our send personas.
Contact Card
Contact cards live at contacts/<group>/<contact_uid>-<name>.md. Each card is
canonical Markdown with a kind: contact frontmatter and a free-form notes body:
---
kind: contact
contact_uid: p20260521001
display_name: Zhang San
emails:
- zhang@example.com
phones:
- "13800138000"
organization: Acme
role: Buyer
tags:
- vip
created_rfc3339: 2026-06-01T17:30:00Z
updated_rfc3339: 2026-06-01T17:30:00Z
---
Free-form notes.
contact_uid starts with pYYYYMMDDNNN and is stable for the life of the contact.
Email addresses are globally unique across all contacts. There is no separate
email→contact index file; the sender→contact link is materialized onto each
message’s contact field (see Message JSON) at pull/render time and on contact
changes as the exact typed ContactFrontmatter, never as a flattened contact
summary. The built-in contact scaffold starts with an empty notes body; any body
text, including a heading added by a workspace scaffold, is canonical
notes content.
afmail contact show and contact notes show return one exact typed
ContactRenderDocument: parsed frontmatter at contact, raw body at
notes_markdown, and path/group only under locator. They do not return the
whole file again as an opaque content string or repeat the contact fields.
Notification Collection
notifications/<notification_uid>-<name>/data/notification.json is canonical:
{
"schema_name": "notification",
"schema_version": 3,
"collection_uid": "n20260521001",
"collection_name": "服务通知",
"items": [
{
"message_id": "message_20260415_4e218374a33cbdc5",
"summary": "Contacts Permissions policy update; review if app uses contacts.",
"added_rfc3339": "2026-06-01T17:30:00Z"
}
]
}
Notification-level suggestions live in this same JSON and use
notification_review_base_hash with the closed notification_rename and
notification_delete action enum. As with messages and cases, a new Agent
suggestion clears the prior human response and generated Markdown never owns
this state.
notification.md is generated and rebuildable. Its template receives the exact
canonical notification collection plus contained typed message documents. The
template alone chooses summary/subject fallback, fields, labels, ordering,
links, and layout; there is no parallel notification presentation config or
render-data preference projection.
Message views live at
notifications/<notification_uid>-<name>/views/messages/<message_id>.md and
preserve the readable message rendering used elsewhere.
Generated Markdown and Review Templates
One MiniJinja system renders generated Markdown, human-facing scaffolds, and
Review HTML. A workspace can override language entries under
templates/<language>/ and shared layouts under templates/shared/. Legacy
.afmail/templates/ files are ignored.
Template keys include case/case.md.j2, case/message.md.j2,
notification/notification.md.j2, notification/message.md.j2,
triage/view.md.j2, message/section.md.j2, status/index.md.j2,
status/message.md.j2, contact/default.md.j2, draft/*.md.j2,
notes/*.md.j2, workspace/DO_NOT_EDIT.txt.j2, review/*.html.j2, and
i18n/catalog.j2. Shared Markdown and Review layouts plus Review CSS live
under templates/shared/.
Generated Markdown and Review contain the same typed version-3 render
documents. document.message is one exact canonical message record;
output-independent document.analysis adds semantic direction, action, quote
ranges, and security conclusions without copying body text. Collection and
Review contexts contain these documents rather than flattening, aliasing, or
rebuilding them. Templates own all display copy, formatting, escaping,
fallback, ordering, visibility, and markup. Template failures return
template_render_failed; afmail does not fall back to built-ins when a
selected-language workspace override exists but is invalid.
Review overrides live in an AFUI frontend, not in templates/, and may contain
MiniJinja, HTML, and CSS but no JavaScript or inline event handlers. afmail
validates their sources and the composed HTML, then injects only its trusted
behavior runtime. See docs/templates.md for the complete path, data,
multilingual, filter, and Review-hook contract.
afmail render templates exports the built-in content templates for every
supported language into templates/, and the built-in Review UI into each
.afui/frontends/afmail/<ui_kind>/ directory that already exists. Existing
files are kept unless --force is used.
Push Items
Push items live in .afmail/push/<push_id>.json. Each item uses
schema_name: "push_item" and schema_version: 2. Its persisted kind is
draft (case/draft locator plus optional remote draft UID) or message (one
stable message id plus its canonical action). The action is queue intent and
is never reconstructed from the message’s later workspace status. Resolved
action steps and remote locations are plan-time projections until execution
starts. At the first tracked message step, afmail freezes them in the optional
message_action_snapshot field so recovery can use the exact reviewed effect
after later pulls change canonical mailbox state. Outbound MIME, message bytes,
and hashes remain plan-time projections rather than editable queue fields.
Common durable fields include timestamps, attempt_count, step_states,
optional local_stage, optional audit_pending, and last_error. All version-1
push items are rejected; afmail does not guess a message action that was never
persisted.
The target UID of a move comes from the server’s COPYUID response code
when it sends one (RFC 4315 for COPY, RFC 6851 for MOVE); only without it does
afmail search the target mailbox by Message-ID, and a search that finds
nothing or several candidates is not resolved by guessing. A move step whose
remote effect completed without a target UID remains remote_outcome_unknown.
After pull, recovery may finish that step without replaying MOVE only when
the target message is present and every planned source location is no longer
active. A target copy by itself is not completion evidence.
For an uncertain first Drafts APPEND in a send item, with every later step
(including SMTP) explicitly pending and unstarted, use the recovery command
instead of editing .afmail state:
afmail push recover PUSH_ID
afmail push recover PUSH_ID --confirm
The first command only checks the configured Drafts and Sent mailboxes for the
stable RFC822 Message-ID. Confirmation repeats the checks under the workspace
lock and repairs local state only. A unique Drafts match with no Sent match
marks APPEND succeeded; absence in both returns APPEND to pending and retryable,
retaining its original error. Normal local draft cancellation/requeueing is
then available again. A Sent copy, ambiguous lookup, failed query,
changed step plan, incomplete step evidence or started SMTP prevents recovery.
An audit event records the checks and intended transition before writing the
item; a failed audit write leaves the uncertain item untouched. Absence is a
point-in-time observation and cannot exclude later server visibility or work
by another client. Recovery never appends, sends, moves or flags remote mail.
Preview afmail push again and authorize its new plan separately to continue.
APPEND errors retain the server’s status response, including its response code
and text, in the error message and server_response error field when available.
The durable step error retains that response for later push list inspection.
An explicit rejection, or failure before the message literal is uploaded, is a
failed step; a connection loss after upload remains remote_outcome_unknown.
Old records cannot reconstruct server responses that the old library discarded.
After all remote steps and local finalize stages succeed, the item first enters
local_stage: "audit_pending" and persists:
{
"audit_pending": {
"event_id": "event_...",
"created_rfc3339": "2026-07-11T08:00:00Z",
"push_kind": "send",
"case_uid": "c20260711001",
"draft_name": "reply.md",
"action": "send",
"succeeded_step_count": 2,
"attempt_count": 1
}
}
The fixed event identity makes push_succeeded append idempotent. Queue deletion
happens only after the append succeeds. If the caller observes an append/fsync
error even though the line reached disk, retry detects the existing event and
does not duplicate it. A damaged audit tail blocks append with
audit_log_recovery_required. Doctor reports retained records as
push_audit_pending; recovery never resolves current action config or replays a
remote step.
Push Plan Output
Bare afmail push, afmail push --dry-run, and afmail push list are pure plan
operations. They return schema_name: "push_plan", schema_version: 1, an
immutable workspace_uid, canonical workspace_path, config_effect_id, and
plan_id: "push_plan_<64 lowercase hex>". push uses code push_plan; push list uses code push_list.
Each item exposes its queue-content hash, resolved action/steps and current step
states, effect scope, locations, and errors. Outbound review data includes
identity, to/cc, subject, review path, draft size/hash, exact MIME hash, and each
attachment’s filename, size, path, and content hash. Append-style message actions
include the exact source EML size/hash. effect_scope is remote or
local_recovery; the latter is used for audit_pending only.
The canonical digest binds workspace UID, effect-relevant configuration, every raw queue record, resolved/skipped items, drafts, deterministic MIME, attachments, and appended message bytes. Password values and password-environment variable names are excluded because they authorize transport but do not change the reviewed mailbox effect. Identity and action configuration are included.
Only afmail push --confirm PLAN_ID applies the queue. A missing argument is a
CLI parse error (exit 2). A stale or foreign ID returns push_plan_changed
before reconcile, remote-client creation, IMAP, or SMTP; details include both IDs,
workspace identity/path, and remote_calls_started: false. A confirmed result
uses code: "push_result", echoes the plan/workspace identity, and reports
succeeded, failed, partially applied, audit-pending, reconciled, and remaining
items. Outbound reply send defaults mark the replied-to message with \Seen and
\Answered; adding a message to a case does not mark remote mail as seen by
default.
Push summaries distinguish these counts:
| Field | Meaning |
|---|---|
failed_count | Items attempted in this batch that failed and remain queued. |
remaining_count | Legacy count of later items this batch did not attempt. |
unattempted_count | Explicit name for the same count as remaining_count. |
pending_queue_count | Items from this plan still queued: unattempted + failed + audit-pending. |
recovery_required_count | Partially applied/uncertain failures + audit-pending successes. |
For 31 items with 9 successes followed by 1 uncertain APPEND failure, the counts
are failed_count: 1, unattempted_count: 21, pending_queue_count: 22, and
recovery_required_count: 1.
When a case with queued send drafts is archived locally, the archive move is a
separate message push item persisted with kind: "message". The case stays status: archiving under
archived-cases/ until outbound pushes have removed their local drafts and the
archive push has succeeded, then afmail writes status: archived and its
final timestamp.
Local Transactions
Incomplete local writes are recorded under
.afmail/transactions/<transaction_id>.json while afmail updates related local
files. Successful operations remove the transaction file. If one remains,
writers stop and afmail doctor reports transaction_incomplete. A recoverable
case_archive transaction is resumed by afmail doctor repair --confirm after
the underlying validation error has been corrected. Transactions that cannot be
recovered unambiguously remain blocked for manual inspection.
Status Storage Summary
afmail status includes a storage object with local byte counts so agents can
surface mailbox workspace growth without adding another command. The fields are
best-effort local filesystem totals, not a Git object-size report:
raw_bytes— direct files under.afmail/messages/, including raw.emlevidence and canonical message metadata/state files.cache_bytes— rebuildable parsed message cache undermessages/plus.afmail/cache/render-cache.json.attachment_bytes— fetched inbound*.files/attachment content and casefiles/directories used by drafts.view_bytes— generated Markdown read views in triage, status folders, case views, archived-case views, and notification views.audit_bytes—.afmail/logs/.queue_bytes—.afmail/push/,.afmail/requests/,.afmail/transactions/, and.afmail/cache/progress.json.total_bytes— the sum of the categories above.
Idempotent Request Records
Supported non-idempotent local mutations may be run with global
--request-id ID. afmail stores .afmail/requests/<request_id>.json with
schema_name: "idempotency_request" and schema_version: 1.
Supported commands include triage, case, and notification suggestion set and
clear. A caller that may retry one of these commands after interrupted output
should reuse one request ID only for that exact normalized input.
The record stores the command key, a BLAKE3 hash of normalized inputs, status
pending | completed, timestamps, and the completed command result. It does not
copy raw note text or draft body text into the record; body-like inputs only
contribute to the input hash. Reusing the same request id with the same input
returns the stored result without repeating the local effect. Reusing it with
different input returns idempotency_conflict. A pending record returns
idempotency_incomplete so agents do not duplicate a possibly completed local
effect after a crash or interrupted output path.
afmail doctor reports pending records as idempotency_incomplete; review the
workspace effect before retrying with a new request id.
For case create, read-only preparation happens before a new pending record is
written. Invalid names, groups, source messages, configuration or target paths
therefore leave the request ID unused; corrected input can reuse it. Existing
records are checked first, so a completed request is replayed without running
preparation again. The prepared values are used by the local operation, and
failures after that operation starts still retain pending.
Workspace Progress
The latest long-running pull or confirmed push writes a runtime snapshot to
.afmail/cache/progress.json. It uses schema_name: workspace_progress, schema_version: 1, command/status/phase timestamps,
phase-specific fields, and final result or error summaries. While a
command is active, status is running; its final value is succeeded,
partial, or failed and corresponds to the terminal event and process exit
code.
At phase: "finish", fields.success is true only for succeeded (it is false for both
partial and failed). result is a scalar summary of a returned command
result. error is null after success; partial/failed aggregate results record
an error summary, while an AppError failure records code, message, and
retryable. For a batch or push failure,
error may project one representative item envelope; the complete per-item
arrays and push audit_warnings remain in the command result. Read the snapshot
with afmail status; it is a progress surface, not an audit log. A final
partial status corresponds to exit code 3, not success.
Pull Cursors
.afmail/pull/cursors.json is an afmail-managed optimization file. It uses
Only IMAP uses schema_name: "pull_cursors" and schema_version: 1, keyed by configured
mailbox id. Each entry records the resolved mailbox_name, uid_validity,
highest_uid, count of pulls since the last full reconcile, and update time.
Cursor hits let a normal subsequent pull request only UIDs after highest_uid.
Cursor absence, corruption, schema mismatch, mailbox rename, UIDVALIDITY reset,
or the periodic full-reconcile interval safely falls back to the full scan path.
The cursor bounds only header and body fetches: every pull, incremental or
not, still lists the UIDs of each selected mailbox and marks local remote
locations that are no longer present as missing, so a message moved or
deleted remotely is noticed even when no new mail arrived.
Dry-run pulls do not update this file. A pull that skips a UID because of budget
or body-fetch limits does not advance the cursor beyond the skipped UID.
Header fetches use ascending windows of at most 25 UIDs. Each window is processed before the next one, including known-message flag updates and the shared count/bytes budgets; only selected new-message headers are retained for the body stage. The dry-run estimate also processes headers in windows.
A missing cursor file is an ordinary first pull. Other read, parse or schema
failures produce a structured warning and preserve the old entry under a unique
.corrupt name beside it before falling back to a full scan. If preservation
fails, the pull stops rather than overwriting that entry. These retained copies
are for manual inspection; doctor does not automatically parse or restore
them.
Audit Events
Audit logs are JSONL records in .afmail/logs/events.jsonl. Notification and
archive-related events include:
notification_creatednotification_message_addednotification_message_movednotification_renamednotification_summary_setnotification_message_removedmessage_unspammedmessage_untrashedcase_archivingcase_archivedcase_unarchivedcase_renamed
Audit events are private mailbox evidence. Each record includes stable audit
metadata such as event_id, created_rfc3339, actor, kind, targets, and
the caller-supplied reason when the command requires or accepts one. Event
payloads also persist operation metadata needed to explain and recover the
change, such as message/case/contact/notification/draft ids, case or
notification names, summaries, contact display names, email addresses, groups,
tags, push ids, remote step counts, and error envelopes.
Audit events do not intentionally copy full message bodies, draft bodies,
attachment bytes, notes bodies, or credential values. Those contents live in
their primary evidence files (.afmail/messages/, draft Markdown, notes.md,
attachments, or config secrets) and may still be present in the private
workspace repository according to the mailbox evidence trust boundary. Treat
--reason text and short summaries as durable audit metadata; use notes.md
only when the user wants long-term working memory.
Native channel evidence
Non-email messages retain original UTF-8 JSON in
.afmail/messages/MESSAGE_ID.message, with the configured source id, direction,
and timestamp. Their common materialized message includes origin with the
external id and typed reply_route; it has no eml_path or RFC822 id. Native
render documents use content_provenance.source: file_message. Draft frontmatter
inherits reply_route. This is the same fact in Markdown and Review. See
channels.md for the v1 record and file adapter contract.