Skip to content

X-Apple-Action: JUNK on an email that passed DMARC

Posted in Email, Llm, Aws

By Dušan Dželebdžić

Photo by Miguel A Amutio on Unsplash
Photo by Miguel A Amutio on Unsplash

The first reply from my sales agent to a contact-form lead landed in Junk. I only found out because the lead was me, testing from my own address, which happens to be hosted on iCloud Mail. I'd filled in the form on biro.works, the website studio I'm building where AI agents draft replies and a human approves every one before it goes out. The reply went out on time and said the right things. It just said them from the Junk folder.

My first guess was that it didn't look like a reply. When the agent answers in a thread, it only sends its own text, with no quoted message underneath. Surely quoting the original would make it look more legitimate?

Half right. The real problem was the opposite: the email looked like a reply when it wasn't one.

Authentication was fine

The obvious suspect with a brand new domain is authentication, so that's where I looked first. Here's the relevant part of the headers iCloud added, trimmed:

X-Apple-MoveToFolder: Junk
X-Apple-Action: JUNK/Junk
X-ICL-Score: 4.33303324422
X-Spam-Flag: yes
Authentication-Results: dmarc.icloud.com; dmarc=pass header.from=biro.works
Authentication-Results: dkim-verifier.icloud.com; dkim=pass header.d=biro.works
Authentication-Results: spf.icloud.com; spf=pass smtp.mailfrom=...@bounce.biro.works

SPF passed. DKIM passed. DMARC passed. The domain's DMARC policy uses strict SPF alignment (aspf=s), so the SES bounce subdomain doesn't count toward SPF alignment, but DKIM is signed as d=biro.works and carries DMARC on its own. Everything a deliverability checklist asks for was green.

And iCloud still gave it a 4.33 and moved it to Junk.

That's the part worth remembering. DMARC answers "did this domain really send it?" It says nothing about whether the message looks like something you want. iCloud clearly scores that separately, and X-ICL-Score is the number to look at. I can't see inside it, but I could see what the message looked like.

A reply to nothing

These were the remaining headers:

From: hello@biro.works
Reply-To: lead+...@biro.works
Subject: Re: Website redesign for <the lead's company>

No In-Reply-To. No References. The DKIM signature's header list confirmed it: those headers were never sent.

So the first email a stranger ever got from us had a Re: subject, claiming to answer a message that never existed. That's a very old spammer trick for getting opened, and filters have seen a lot of it.

Where did the Re: come from? From my own fix, sort of. In an earlier post, Never let the LLM write the subject line, I took the reply subject away from the model and derived it in code as Re: <original subject>. The model's subject only survived as a fallback for leads from the web form, where there's no email thread and no original subject to reuse.

The model, writing a subject for a first contact, decided it should look like a reply. So it wrote Re:, and the fallback passed it straight through.

The fix is one function call. When there's no thread, strip the prefixes from the fallback too:

export function threadReplySubject(original: string | null | undefined, fallback: string): string {
const stripped = original ? stripReplyPrefixes(original) : "";
return stripped ? `Re: ${stripped}` : stripReplyPrefixes(fallback) || fallback;
}

A Re: is only honest when the headers can back it up.

A From line with no name

The other thing in those headers was From: hello@biro.works. Just the address. Meanwhile, the body ended with a signature: a person's name and a title.

Real people's mail almost always has a display name. A bare role address on top of a personal signature reads like a mailing tool, and a mismatch between the From line and the signature is a small inconsistency that filters and people both notice. Now the From line names whoever signs the body:

From: "Marcus from biro.works" <hello@biro.works>

SES takes that format as-is in FromEmailAddress. Strip quotes, backslashes and newlines from the name before you interpolate it.

I won't pretend I can tell you which of the two pushed the score over the line. iCloud doesn't publish its weights. Both were cheap to fix, and both were wrong.

Quoting, where it's honest

My original hunch still held for real threads. Once a lead replies, our answers now quote their message underneath, the way every mail client does. The HTML uses Gmail's own markup, so Gmail folds it behind the "..." toggle instead of showing a wall of old text:

<div class="gmail_quote">
<div class="gmail_attr">On Wed, Sep 23, 2026, Jane Doe &lt;jane@example.com&gt; wrote:<br></div>
<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex">
...their message, HTML-escaped...
</blockquote>
</div>

The plain-text part gets the same attribution line and > prefixed lines. The quote is added at send time only. It never goes into the stored thread, so the model doesn't read its own conversation quoted back at it on every turn.

The contact-form reply is the one place I deliberately don't quote. Anyone can type any address into a form. If our reply echoes the form text, then a stranger gets to put whatever they like in an email from our domain, delivered to someone else's inbox. Those replies get one fixed line instead, below the signature: "Sent in reply to your message through the contact form at biro.works on September 23, 2026." It says why the email exists without repeating anything the sender typed.

References wants the whole chain

While I was in there, References turned out to be thinner than it should be. It only ever carried the direct parent's Message-ID. RFC 5322 says it should list the thread's ids, oldest first, so clients can still rebuild the conversation when one message in the middle goes missing. Now it does, trimmed to the first id plus the newest ones on long threads.

That surfaced an SES detail worth knowing. SendEmail returns a MessageId like 010101a0cfbe944a-...-000000, and that's what I'd been storing. The Message-ID header SES actually writes is:

Message-ID: <010101a0cfbe944a-...-000000@us-west-2.amazonses.com>

The id from the API isn't something a recipient ever sees. If you put it in References as-is, you're citing a message nobody has. Expand it to <id@region.amazonses.com> first. In us-east-1 the domain is email.amazonses.com instead.

Takeaway

Passing SPF, DKIM and DMARC just gets you considered. After that, the message is judged on whether it looks like mail from a real person, and every header claim has to hold up. A Re: needs a real thread behind it, and the From name needs to match the signature.

Whether these fixes get the next first reply into the inbox, I'll find out the next time I fill in my own contact form.


Your email landing in Junk even though DMARC passes? Send me the headers and I'll take a look.

Found this useful? Pass it on.

Follow me on X or LinkedIn for the next one.