Agent-First Mail v0.12.0: Where the Link Goes
HTML mail used to reach the Review page as walls of tracking URLs or as text with every link target stripped. It is read through a Markdown copy now, and every link says which domain it goes to and whether that is the sender's. Plus a cleared Cc that was still sent, a move that waited forever for a search index, and a rejection that is no longer mistaken for an unknown.
Most of this release is about mail as it actually arrives — newsletters, HTML-only messages, servers that answer slowly or not quite as expected — and about the places where afmail’s record of what happened and what really happened had drifted apart.
Reading HTML mail
A newsletter’s text/plain part is link text on one line and the full tracking
URL on the next. A message with only an HTML part was worse: tag-stripped text
with every link target gone. Both reached the Review page and the generated
Markdown views exactly like that, so a person saw walls of URLs and no links.
A message with a real HTML part now carries body_markdown beside body_text.
Links keep their text and their target, images become their alt text, scripts,
styles and interactive content are dropped, and the invisible preheader padding
newsletters use is stripped. body_text is untouched: it stays what hashes and
quote segmentation are computed over.
The Review template does not receive that Markdown as HTML. AFUI’s page
environment refuses any value carrying pre-rendered markup, on purpose, so the
copy is laid out as a typed block tree — paragraph, heading, list item, quote,
code, rule, and runs with optional link, emphasis and code — and the template
writes the <p> and <a> itself under ordinary escaping. Only http, https
and mailto targets ever become a link. The generated Markdown views quote the
copy in a fenced block.
The message cache schema moves to 3, so existing caches are rebuilt from their
.eml on the next refresh. Times in pages and views also stop being RFC 3339:
the datetime filter shows the workspace’s local YYYY-MM-DD HH:MM, while
machine fields and <time datetime=…> keep the raw value.
Every link says where it goes
A link in a Review page used to be an <a target="_blank">, which the macOS
window silently ignored and the Chromium window opened in a throwaway profile.
Neither showed the destination.
A link is now text that knows where it goes. Each one carries its target’s
registrable domain — Public Suffix List, ASCII form, so a Unicode host and its
punycode twin compare equal — and whether that is the sender’s own domain: the
authenticated one, else the From domain. That is a single rule, plain
equality, with no lookalike heuristics. A link that leaves the sender’s domain
is marked as such, and that is all. Legitimate senders that host their links on
a brand-hyphen domain get the same mark, and that is their problem rather than
the reader’s.
Clicking a link opens a panel with the domain and the full URL, offering copy or open in system browser. The page itself never navigates.
Said and done
Several fixes in this release share a shape: afmail reported one thing and did another.
A cleared Cc was still sent. case draft change --clear-cc --clear-bcc
reported both fields changed, and the old recipients stayed in the draft’s
frontmatter. Frontmatter editing only ever set keys; it never removed one. It
removes keys that are gone now, still leaving hand-written comments and the
body byte-for-byte alone.
A move could wait forever. After a successful UID MOVE, afmail searched
the target mailbox for the Message-ID to learn where the message landed. When
that search came back empty, the push item correctly became
remote_outcome_unknown and waited for a pull to reconcile. But an incremental
pull with no new UIDs skipped reconciliation entirely, so recovery refused
forever — even after the same pull had imported the moved copy. Pull now
reconciles locations on every run. The move also no longer needs the search
when the server says where the message went: the COPYUID code in the response
names the target UID directly, and the search is only the fallback. An
ambiguous Message-ID is reported, not resolved by taking the largest UID.
A message without a Message-ID could not be moved. The move refused before
touching the server. With COPYUID it no longer needs one, and items that
stalled on that old refusal resume their move step on the next push without
repeating the steps that already succeeded.
A rejection is not an unknown. Every failed send, append or move used to
leave its step remote_outcome_unknown, because a lost connection might have
applied the effect. An explicit NO/BAD from IMAP or a negative SMTP reply
is different: the server said it did not do it. Those now fail normally and can
be retried. The one exception is a rejection that follows an append which
already succeeded in the same step — retrying that could duplicate the message,
so it stays unknown.
A message that came back stayed deleted. A message marked deleted remotely that reappeared on the server is restored to the status its mailbox implies.
Pull, at the size of a real mailbox
Header fetches go out in ascending windows of at most 25 UIDs, each processed —
flag updates and the shared count and byte budgets included — before the next,
so a large first pull no longer holds every header at once. IMAP connection
attempts have a 30-second timeout per resolved address, trying each address in
turn. A corrupt pull cursor is kept beside itself as a uniquely named .corrupt
copy, with a structured warning, before falling back to a full scan; if it
cannot be kept, the pull stops rather than overwriting it.
Recursive traversal of the mail store no longer holds a directory handle open per level, so a deep tree under a low file-descriptor limit stops failing partway through.
Smaller things
case create validates its input before consuming the request id, so a typo
can be corrected and retried with the same key. The HTTP API’s JSON, query and
path errors use fixed messages and a static error.details.kind, never echoing
the rejected value.
The workspace lock moved to .afmail/cache/workspace.lock, beside everything
else the workspace owns, instead of a per-user temporary directory that two
processes could disagree about. The cache directory therefore must not be
removed while an afmail process is running.
doctor marks an invalid message cache repairable only after a read-only
reconstruction from the raw .eml and canonical metadata succeeds, and
doctor repair --confirm rebuilds it locally. Undoing a spam or trash whose
push has already started is refused before it opens a transaction, rather than
leaving an incomplete one behind; one left by an earlier version is repairable.
An unusable output sink reports output_setup_failed, like every other spore.
The README now leads with the Git-checkout shape and the credential-free demo,
and a new setup guide covers configuration, where a password may live, and
Gmail — an app password, a user-created Archive label, and no Sent append.
Outlook and Microsoft 365 are stated as unsupported: password login is gone
there, and afmail has no OAuth.
Breaking changes
- The
datetimetemplate filter renders localYYYY-MM-DD HH:MM, not RFC 3339. A custom frontend that parsed its output should read the raw field. - The message cache schema is 3. Existing caches are rebuilt from
.emlon the next refresh; nothing needs to be done by hand. - The workspace lock lives at
.afmail/cache/workspace.lock. Do not remove the cache directory while afmail is running. - An explicit server rejection fails its push step instead of leaving it
remote_outcome_unknown. - Agent-First UI 0.6.1, Agent-First Data 0.35.0 and Agent-First Slug 0.8.0 are the pinned dependencies, and templates run on minijinja 3.
Getting it
$ brew install agentfirstkit/tap/afmail
$ cargo install agent-first-mail