Agent-First Mail v0.12.0: Where the Link Goes

by Agent-First Kit Contributors

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.

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

Getting it

$ brew install agentfirstkit/tap/afmail
$ cargo install agent-first-mail