BlogEngineering

Threads are joined on Message-ID, never on subject. Here is the bug that rule prevents.

Two customers reply to "Re: Invoice" on the same afternoon. Subject matching puts them in one thread, and an agent reading it leaks one customer's context into a reply to the other. How SendRaven joins replies instead.

Daniel Sternlicht4 min read

A reply arrives. Which of the messages you sent does it answer? Every email API that offers inbound has to decide this, and the tempting shortcut is the subject line. This post is about why we refused it, and what we do instead.

The failure

Two customers reply to "Re: Invoice" on the same afternoon. A subject-matching threader puts both replies in one thread. Nothing looks wrong in a list view: one subject, two messages.

Now an agent opens that thread to answer the second customer. In front of it is the first customer's message: their name, their invoice number, whatever they wrote. The agent writes a reply with that context in it. One customer's information has just gone to another, and it happened inside a system that was working as designed.

That is not a display bug. It is a data leak, and the kind that ends up in a screenshot.

The join

Every message SendRaven sends goes out with a Message-ID header, and that exact string is recorded on the message. When someone replies, their mail client echoes it in In-Reply-To and appends it to References.

Resolution runs in this order:

  1. Collect the candidate ids: In-Reply-To first, then everything in References.
  2. Look for an outbound message of yours with one of those ids as its header. If found, the reply joins that message's thread.
  3. Otherwise look for an inbound message already threaded with one of those ids. This is a reply to a reply, and it joins the same thread.
  4. Otherwise start a new thread.

There is no step where the subject decides. If the headers are missing or match nothing, a new thread with a normalised subject is the right answer, because a wrong thread is worse than a short one. The integration suite has a test that sends two "Re: Invoice" conversations through this and asserts they stay apart.

Campaigns are deliberately excluded from threading. A newsletter to 200,000 people would create 200,000 single-message threads, and none of them is a conversation anyone will read.

The bug we shipped anyway

The rule was right and the first real reply still landed in its own thread. Here is how.

We stamped our own Message-ID on every outbound message, <{messageId}@{yourdomain}>, built from the message's database id, and matched replies against it. The tests passed, because the tests replied to the header we stamped. Real mail does not. The sending infrastructure underneath us discards any Message-ID you set and assigns its own, so the header the recipient's client saw, and echoed back, was one we had never recorded. Nothing matched, and the resolver did exactly what it is built to do when nothing matches: it started a new thread rather than guessing by subject.

The fix is two lines of thinking. The header that matters is the one the recipient actually received, so it is recorded on the message the moment the send is accepted, from the id the send call returns. And on the way in, the resolver also recognises that infrastructure's id inside any candidate header and matches it directly, which covers mail sent before the fix. The mirror-image bug went with it: an outbound reply's In-Reply-To used to name a header the recipient had never seen, so their client would not have threaded our answer with their message either.

The lesson is narrower than "test with real mail", though that too. The join key is not the header you wrote. It is the header that left the building.

What the person typed

The join answers which conversation. The next problem is what the message says. The raw body of a third reply is mostly the first two quoted back, plus a signature, plus a legal footer. A model reading that pays for every token of it and, worse, treats the quoted history as part of the message.

Stripping quoted text is a known problem with known heuristics: the "On Tuesday, X wrote:" line, the > prefix, the signature separator. Every heuristic is wrong sometimes. The rule that matters is which way it fails.

Ours fails open. Over-trimming silently loses what the person wrote, which is far worse than leaving a quoted line in. So if trimming would empty the body, the stripper hands back the original. And every inbound message carries both fields: text is the reply with quoted history and signature removed, raw_text is the whole body for when the trim gets it wrong, which it always does sometimes.

What the agent reads

Put together, a thread comes back as one chronological transcript, outbound and inbound merged, so an agent reads a conversation rather than reassembling two collections. Each inbound entry carries text, raw_text, sender_authenticated, and the SPF, DKIM, DMARC and spam verdicts the receiving layer computed, so a forged sender can be treated as untrusted input. (Updated 14 September 2026 to add sender_authenticated: SPF and DKIM verdicts on their own miss the usual forgery.)

And the question an agent actually asks, "what has someone said to me that I have not answered," is one call:

GET /v1/threads?awaiting_reply=true

A thread is awaiting a reply when its last message is inbound. Answer it with reply_to_message_id on the send and the threading headers are set for you, so the person's client shows one conversation too. For the reply that needs no answer, "thanks, all sorted", mark the thread handled instead; replying only to clear a flag is mailing a person for bookkeeping.

The rule, in one line

Join on the header the mail system already gives you. Never guess from the subject. Start a new thread rather than a wrong one. Everything else in inbound follows from that.

  • engineering
  • inbound
  • threads

Daniel Sternlicht

Co-founder, SendRaven

Try it

Give your agent an inbox, an outbox, and a seatbelt.

3,000 emails a month free, and an MCP server your agent can use today.

Start freeRead the docs