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.
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 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 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: 1; 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.
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 is a snapshot refreshed at pull/render time and on contact changes:
{
"contact": {
"contact_uid": "p20260521001",
"display_name": "Zhang San"
}
}
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": 1,
"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
}
]
},
"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"
}
}
human_suggestion, when present, must 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, and deferred stores no 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": 1,
"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,
"fetched": true,
"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]. When fetched is true,
file_path points to the message-cache copy under
.afmail/messages/<message_id>.files/.
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 always safe to remove;
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 kind: triage_view, message_id, message_ids,
message_count, attachment_count, and optional suggestion fields
(suggested_case_uids, suggested_reason). Triage views are
rebuildable and are not a notes or suggestion surface. No
agent_suggestion/human_suggestion field is valid in this frontmatter.
suggested_case_uids and suggested_reason remain non-executable relationship
hints generated by afmail, not an Agent’s executable proposal. The Agent reads
message_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. afmail triage review accepts no
caller-owned context file. A submitted review persists human_suggestion and
records human_suggestion_set before returning its finite result.
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. Older workspaces may contain a
legacy thread_count field; current writers ignore it and do not emit it.
{
"schema_name": "case",
"schema_version": 2,
"collection_uid": "c20260521001",
"collection_name": "应用反馈-肥料登记",
"tags": [],
"created_rfc3339": "2026-05-22T09:00:00Z",
"updated_rfc3339": "2026-05-22T09:00:00Z",
"message_count": 1,
"attachment_count": 0,
"items": [
{
"message_id": "message_20260521_3af9c1b2e8d04f6a",
"summary": "Renewal pricing and contract timing.",
"added_rfc3339": "2026-05-22T09:00:00Z"
}
]
}
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, 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: []
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.
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. queued_action is either
save_draft, send, or null, and draft output keeps
remote_changed: false before push succeeds.
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
---
## Zhang San
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.
Notification Collection
notifications/<notification_uid>-<name>/data/notification.json is canonical:
{
"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"
}
]
}
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. By default it renders each message as
a numbered heading (the queued action label plus the message summary, or the
subject when the summary is empty), followed by bullets for the sender, a
message-id link to the detail view, and the time. The configurable
notification.message_index.item_fields (default time, from, summary) is exposed
as document.preferences.notification_index_item_fields; the template decides
which rows and labels to render.
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, draft/*.md.j2, notes/*.md.j2, workspace/*.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-2 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 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 templates for every supported language.
Existing language-specific workspace templates 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: 1. Its persisted kind is
draft (case/draft locator plus optional remote draft UID) or message (one
stable message id). Resolved action steps, outbound MIME, message bytes, and
hashes are plan-time projections, not editable queue fields. Common durable
fields include timestamps, attempt_count, step_states, optional
local_stage, optional audit_pending, and last_error.
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.
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 lifecycle archiving under
archived-cases/ until outbound pushes have removed their local drafts and the
archive push has succeeded, then afmail writes lifecycle 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.
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
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.
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.
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.