Workspace Model
An afmail workspace is a local, file-first mailbox workspace. Files are the read interface; the CLI is the effect interface.
Layout
account-workspace/
AGENTS.md
triage/
<message_id>.md
spam/ # created when spam messages first exist
data/
spam.json
index.md
<message_id>.md
trash/ # created when trashed messages first exist
data/
trash.json
index.md
<message_id>.md
deleted/ # created when remote-deleted messages first exist
data/
deleted.json
index.md
<message_id>.md
cases/
<group>/
<case_uid>-<name>/
case.md
notes.md
data/
case.json
drafts.json
views/
messages/
<message_id>.md
drafts/
files/
views/
messages/
<message_id>.md
drafts/
files/
contacts/ # created when contacts first exist
<group>/
<contact_uid>-<name>.md
identities/ # optional persona overrides, one <identity>.md per config identity
archived-cases/
<group>/
<case_uid>-<name>/
case.md
notes.md
data/
case.json
drafts.json
views/
messages/
<message_id>.md
drafts/
files/
notifications/
<notification_uid>-<name>/
notification.md
notes.md
data/
notification.json
views/
messages/
<message_id>.md
messages/
<message_id>.json
templates/
<language>/
i18n/
review/
shared/
markdown/
review/
.afmail/
DO_NOT_EDIT.txt
config.json
workspace.json
pull/
cursors.json
cache/
progress.json
render-cache.json
logs/events.jsonl
transactions/
requests/
push/
messages/
<message_id>.eml
<message_id>.json
<message_id>.files/
triage/ and cases/ are active attention surfaces. spam/, trash/, and
deleted/ are generated review views for local discard states, created only
after the first matching message appears.
archived-cases/ contains both archiving case workspaces waiting for queued
sends/archive effects and finalized archived cases. Typed lifecycle metadata,
not another directory or CLI flag, distinguishes them.
notifications/<notification_uid>-<name>/ contains notification messages
in one notification collection.
Case roots contain only user-facing Markdown entry points (case.md and
notes.md) plus working directories. Notification roots contain notification.md
and notes.md. Canonical local object state lives under data/; generated,
rebuildable Markdown detail views live under views/. drafts/ and files/
are user-visible working materials.
case.md, notification.md, triage/*.md, spam/*.md, trash/*.md,
deleted/*.md, and views/**/*.md are generated read views. They are safe to
rebuild with afmail render refresh; use notes.md for durable notes instead
of generated views. Case and notification message links point to
views/messages/<message_id>.md.
Suggestions do not change that rule. Message agent_suggestion and
human_suggestion state lives in .afmail/messages/<message_id>.json; case and
notification suggestion state lives in the object’s existing canonical
data/*.json. Generated Markdown may render a suggestion but never owns it.
Deleting any generated view cannot discard a pending review, and
afmail render refresh never reconstructs state from Markdown or audit history.
Show/list/status/push-preview commands read existing views and project message
state in memory; they never rebuild a missing view as a side effect. A missing
or invalid generated view returns generated_view_unavailable with an explicit
afmail render refresh repair hint.
.afmail/DO_NOT_EDIT.txt is a warning sentinel. The rest of .afmail/ is
machine-managed evidence, remote state, push queue, and audit history; use the
CLI for effects instead of editing it by hand. Generated Markdown template
overrides live under user-editable templates/, outside .afmail/. Optional
identity persona files live under user-editable identities/, also outside
.afmail/.
.afmail/workspace.json is machine-managed durable identity, intentionally
tracked with the private workspace. It contains the immutable workspace_uid
used to bind plans and must be preserved when cloning, moving, or restoring the
workspace. A missing or damaged identity is an error; afmail never silently
rotates it. Concurrent-process lock files live outside the workspace in the
user runtime temp directory, so the first shared read after a clone does not
change the workspace tree or mtimes.
Persisted JSON state documents identify their on-disk format with
schema_name and schema_version. CLI stdout, diagnostics, errors, and
.afmail/logs/events.jsonl audit events remain Agent-First Data protocol
messages and use code.
The managed .gitignore intentionally does not ignore .afmail/messages/: raw
mail evidence and remote metadata are durable local state. Tracking those files
in git means the repository contains private mail bodies and attachment bytes.
The managed ignore block covers the machine-local .afmail/push/,
.afmail/logs/, .afmail/transactions/, .afmail/requests/, and
.afmail/cache/ directories, plus installed workspace skill directories such as
.codex/skills/agent-first-mail/ or .claude/skills/agent-first-mail/,
messages/*.json, triage/*.md,
spam/*.md, trash/*.md, deleted/*.md, and generated object Markdown views.
Only .afmail/cache/ is safe to delete at any time. The other ignored state
contains pending intent, recovery sentinels, idempotency records, or audit
history and is machine-local but lossy to remove.
Message State
Message evidence lives in .afmail/messages/<message_id>.eml. Its canonical
metadata—including remote mailbox locations plus the current Agent and human
suggestions—lives in .afmail/messages/<message_id>.json. Parsed root
messages/<message_id>.json files are rebuildable projections. Triage, case,
notification, spam, trash, and deleted views are generated from message evidence
plus canonical metadata and object collections. Inbound
attachments belong to the message. Attachment metadata is stored on the message
record; afmail message attachment fetch MESSAGE_ID [PART_ID] materializes
files under .afmail/messages/<message_id>.files/ and refreshes generated read
views so fetched paths appear in message renderings.
A message can be referenced by multiple active, archiving, or archived cases. A message can belong to at most one notification collection. If a message needs multiple contexts, create or use cases instead of multi-archiving the direct message.
Local discard dispositions are canonical in their own directories:
spam/data/spam.json, trash/data/trash.json, and
deleted/data/deleted.json. Fresh workspaces omit empty disposition
directories and JSON; once created, those state files may remain if a
collection later becomes empty. The Markdown files under those directories are
generated views.
Cases
Active cases live at cases/<group>/<case_uid>-<name>/. Archiving and finalized
archived cases live at archived-cases/<group>/<case_uid>-<name>/. The
status: archiving value and missing archived_rfc3339 identify the temporary
state while queued sends and the archive push finish. The <group> segment is the case
group carried forward from active work, not an archive category; for example,
archived-cases/support/... is an archived case from the support case group.
case_uid is globally unique and stable across active, archiving, and archived
cases.
rename --name updates data/case.json and the readable directory suffix.
Case refs must start with cYYYYMMDDNNN; notification refs must start with
nYYYYMMDDNNN. A ref may include a readable suffix after one dash, so
c20260521001-anything and c20260521001 are equivalent, as are
n20260521001-anything and n20260521001. Names alone are not looked up. Group
and tag values are local path-segment identifiers. Human names may use Unicode
such as Chinese, for example 应用反馈-肥料登记 or 服务通知. Do not use path
separators (/ or \) or the dot-only segments ./...
Case metadata is canonical in data/case.json. Active cases have an empty
status; archiving cases use status: archiving; finalized cases use
status: archived. Final archived cases carry top-level
archived_rfc3339; archiving cases do not set it until finalization. Message
membership is stored in the same data/case.json collection under items[],
with per-message summary and added_rfc3339. Archived cases do not use
notification collections.
Case-local data/drafts.json files are afmail-managed machine state. They
record only the last validation hash/time for drafts/*.md files. Humans and
agents should edit draft Markdown, not data/drafts.json; queued save/send
items reference the draft by case and filename and use the latest content at
push time.
Contacts
Identities are “us”: configured send personas with an address, display name,
and default flag in .afmail/config.json. Contacts are everyone else. Multiple
identities may share one email address when the same mailbox needs separate
personas, such as support and sales.
Contact cards live at contacts/<group>/<contact_uid>-<name>.md. contact_uid
starts with pYYYYMMDDNNN; a ref may include a readable suffix after one dash.
The card is a Markdown file with a kind: contact frontmatter (display name,
emails, phones, organization, role, tags, timestamps) and a free-form notes
body. Each card is canonical state — edit it with afmail contact commands, not
by hand.
Email addresses are globally unique across active contacts; contact create and
contact email add reject an address already held by another contact. The
sender→contact association is materialized onto each message as a contact
object (messages/<message_id>.json) at pull/render time and refreshed
automatically when a contact’s emails or display name change. afmail render refresh rebuilds it for every message. There is no separate contact index file.
Drafts And Case Files
Draft Markdown lives under a case drafts/ directory. Outbound attachments
belong to the draft/case, not to inbound message evidence. Use
afmail case draft attach REF DRAFT_NAME PATH to add one: external files are
copied into the case files/ directory with a safe filename, and files already
inside the case are recorded as case-relative paths without another copy.
The draft frontmatter attachments: list contains case-relative paths such as
files/screenshot.png. Validation and push check that each path is relative,
safe, and points to an existing file under the case workspace. Adding or editing
attachments changes the draft; queued draft upload / draft send items still
use the latest Markdown content. Use afmail case draft remove to delete the
draft and any queued save/send item.
Draft frontmatter may include identity: <slug>. New/reply/change commands
accept --identity <slug>; when omitted, afmail records or resolves the config
default identity. An identity footer from identities/<slug>.md is inserted
outside the draft compose/quote sections immediately so reviewers see the exact
footer before queueing or sending. Send time uses the resolved identity for the
RFC 5322 From header and SMTP envelope sender, but it does not secretly append
or mutate footers.
Every current-format draft has exactly one
<!-- afmail:compose:start --> / <!-- afmail:compose:end --> pair. Edit the
authored text only inside that pair; draft change --body does the same without
removing reply history or the identity footer. Reply scaffolds keep remote quoted
history in a separate quote marker pair after compose. Marker lines and template
headings are editor schema, so afmail removes them when it builds the outbound
plain-text MIME body.
An empty compose section is intentionally valid for local editing and remote
Drafts upload, and appears as completeness: incomplete. It cannot enter a send
queue: draft send returns draft_incomplete, leaves the file/queue unchanged,
and push checks completeness again in case a queued draft was edited. Draft
list, show, and typed Review documents share the states editable,
queued_upload, queued_send, pushing, and failed; queued_send never
means the message was sent.
Notification Collections
The canonical membership file for a notification collection is
notifications/<notification_uid>-<name>/data/notification.json:
{
"schema_name": "notification",
"schema_version": 2,
"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"
}
]
}
summary is optional. Generated notification.md renders a Markdown list using
notification.message_index config. The built-in notification templates display the
message subject when a summary field is empty. Generated message views live
under notifications/<notification_uid>-<name>/views/messages/<message_id>.md.
Markdown and Review Templates
Built-in MiniJinja templates render generated Markdown, human-facing scaffolds,
and Review HTML. A workspace can override language entry templates under
templates/<language>/ and shared Markdown/Review layouts under
templates/shared/. Legacy .afmail/templates/ files are ignored.
Common generated-view template keys include:
case/case.md.j2andcase/message.md.j2notification/notification.md.j2andnotification/message.md.j2triage/view.md.j2andmessage/section.md.j2review/page.html.j2,review/message.html.j2, and the Review editorsi18n/catalog.j2for localized copy and punctuation
Run afmail render templates to export built-ins, then afmail render refresh
to rebuild generated Markdown after template edits. Review loads its templates
when the page is rendered.
Markdown and Review receive the same schema-versioned typed render documents. Each document contains canonical data once plus output-independent semantic analysis; it never carries a Markdown-shaped or HTML-shaped display projection. Body and draft text exist once and semantic byte ranges identify authored and quoted portions. Templates own localization, fallback text, formatting, escaping, ordering, visibility, and markup.
Workspace Review templates can use MiniJinja, HTML, and CSS but cannot provide JavaScript. afmail rejects JavaScript-capable Review overrides and supplies its own behavior-only autosave/submit runtime. See Markdown and Review Templates for all paths, data fields, filters, language packs, and Review behavior hooks.
Deleted Remote Messages
When a remote message disappears and has no case/notification/draft/push reference,
afmail keeps the local evidence under .afmail/messages/, marks its local
state as deleted_remote, and exposes it under generated deleted/ views.
afmail purge previews old local spam, trashed, and deleted_remote
message records; add spam, trash, or deleted to limit it to one
disposition, and use --older-than-days to override the default 30-day
threshold. The preview does not write the workspace. Permanent deletion requires
the exact returned --confirm PLAN_ID; a changed candidate or artifact rejects
the stale plan before deletion. When a referenced remote message disappears, afmail keeps the
business state such as case or notification and only marks remote locations
missing so existing case/notification state stays resolvable.
Notes
notes.md files are plain Markdown with no frontmatter. They exist for active
cases, archived cases, and notification collections. They are the only
durable local notes surface inside those objects; generated views are disposable.
Case review automatically saves edited notes.md locally after a short idle
period. Draft review does the same for the reviewed draft. Each save uses the
canonical Workspace API under an exclusive workspace lock after a base-hash
check, and the review result records the completed files in saved_files.
Triage review only returns typed decisions and does not mutate the workspace.
No review action queues, pushes, or sends mail; closing the window does not undo
an earlier completed local save.