Blog

Message threading: how email threads work in code

Message threading in email, explained for developers: Message-ID, In-Reply-To and References, why subject joins break, and how to build it.

SendRaven8 min read

Message threading groups messages into one conversation. In email, that means linking each reply to the message it answers. The reliable link is the Message-ID header, carried back in In-Reply-To and References. The subject line is not a reliable link.

A thread needs three things: a stable ID per message, a reply that points at it, and a join that never guesses. Below you get the mechanism, then the same mechanism working through SendRaven's endpoints and field names. Chat apps thread by parent message ID too. This article is about email.

Message threading, in short

The short version fits in a list:

  • Every email gets a Message-ID when it is sent.
  • A reply echoes that ID in In-Reply-To and adds it to References.
  • Your code joins the reply to the original by looking up that exact string.
  • Nothing in the join depends on what the subject says.

If you receive replies by webhook, support inbox or agent, this is the whole job: take a raw inbound message and find the message it answers. It is a lookup, not a guess.

How email threading works: Message-ID, In-Reply-To and References

Three headers do the work. This is standard email behavior, not anything specific to one provider.

HeaderWhat it holds
Message-IDA unique identifier for this one message, assigned when it is sent.
In-Reply-ToThe Message-ID of the message being answered.
ReferencesThe chain of earlier Message-IDs in the conversation. A reply appends to it.

Here is a minimal example. The values are illustrative.

--- original message (illustrative) ---
From: Acme <billing@mail.acme.com>
To: ana@example.com
Subject: Your invoice for September
Message-ID: <a1b2c3@mail.acme.com>

--- Ana's reply (illustrative) ---
From: ana@example.com
To: Acme <billing@mail.acme.com>
Subject: Re: Your invoice for September
Message-ID: <x9y8z7@example.com>
In-Reply-To: <a1b2c3@mail.acme.com>
References: <a1b2c3@mail.acme.com>

If Acme answers Ana, that message sets In-Reply-To: <x9y8z7@example.com> and References grows to hold both earlier IDs. A thread is a chain of IDs. It is not a shared subject.

When a reply comes in, the join runs in a fixed order: try In-Reply-To first, then everything in References. The first ID you recognize from your own sent mail tells you which conversation the reply belongs to. That is why you need to record the Message-ID of every message you send. You cannot look up what you did not keep.

Why joining on subject breaks

Subjects get edited. They get prefixed with Re: and Fwd:. They get reused. Joining on them goes wrong in two directions:

  • Merged threads. Two unrelated conversations share a subject, and their replies land in one pile. "Re: Invoice" from two different customers becomes one thread.
  • Split threads. One real conversation changes its subject halfway through, and your code now sees two.

For a human skimming an inbox, that is an annoyance. For software that acts on a thread, it is a bug with consequences. An agent reading a merged thread can leak one customer's context into a reply to the other.

That is why SendRaven joins on Message-ID and never on subject. Besides, a subject like "Quick question" has been sent by roughly everyone, about roughly everything. It identifies nothing.

For the SendRaven-specific walkthrough of how that resolution works, see email threads joined on Message-ID, not subject.

Threading outbound mail in code: reply_to_message_id

Sending the first message is a plain REST call. The response gives you an id and a thread_id.

curl -X POST https://api.sendraven.ai/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: 6f1c..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <billing@mail.acme.com>",
    "to": ["ana@example.com"],
    "subject": "Your invoice for September",
    "text": "Hi Ana, your invoice is attached."
  }'
{ "id": "3f6c...", "status": "queued", "thread_id": "a7d2..." }

The Idempotency-Key means a retry with the same key delivers once. Nothing to retry, in other words.

To answer a message in the same conversation, send again with reply_to_message_id set to the message you are replying to:

curl -X POST https://api.sendraven.ai/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <billing@mail.acme.com>",
    "to": ["ana@example.com"],
    "text": "Added. Your VAT number is on the revised invoice.",
    "reply_to_message_id": "d4e5..."
  }'

SendRaven sets In-Reply-To and References for you and puts the new message on the same thread. Ana's mail client shows one conversation, not a pile of unrelated messages. (The payloads above are abbreviated. Check the OpenAPI document at https://api.sendraven.ai/openapi.json for the full field list.)

Over MCP, the same step is the reply_to_message tool. The first send is send_email.

Threading inbound replies: from raw webhook to conversation

Here is the problem as it usually shows up. An inbound reply arrives as a raw webhook, with quoted history stacked underneath and no link to the message it answers. To make it useful you parse In-Reply-To and References, look them up in your own table of sent IDs, strip the quoted text, and update per-conversation state on every send and every event. That is a state machine, and it is yours to maintain.

SendRaven does the join for you. Replies to your messages arrive as threads you read with GET /v1/threads/:id. One chronological transcript, outbound and inbound merged:

GET /v1/threads/a7d2...

{
  "awaiting_reply": true,
  "messages": [
    { "direction": "outbound", ... },
    {
      "direction": "inbound",
      "text": "Can you add our VAT number?",
      "sender_authenticated": true
    }
  ]
}

Three fields matter most here:

  • text has quoted history and signature stripped, so a person or a model reads what the sender actually wrote. raw_text is the fallback when you want the full body.
  • direction says whether a message is outbound or inbound.
  • sender_authenticated reflects DMARC or aligned DKIM. In plain English: did the mail really come from the domain it claims? SendRaven exposes the flag. Your code decides how far to trust a reply before acting on it.

Inbound attachments are kept for 30 days. Replies are free and don't count toward usage, so a busy inbox does not move your meter.

For more on how this fits the product, see how SendRaven handles replies and the send and receive email API with reply threads.

Finding the conversations waiting on you

The question software asks, over and over, is: what has someone said to me that I have not answered? With raw webhooks you answer it by storing every inbound event, marking each one when you reply, and querying for the unmarked ones. With threads it is one call:

GET /v1/threads?awaiting_reply=true

It returns the conversations where the other person wrote last. Out-of-office replies and bounce reports are recognized from their headers first and never set awaiting_reply, so the list holds mail a person wrote.

When you have answered in place, or decided to let it go, clear the flag:

POST /v1/threads/a7d2.../handled

Replying with reply_to_message_id flips awaiting_reply back, since you now wrote last. handled is for the case where no reply is coming.

The MCP equivalents are list_threads, get_thread, reply_to_message and mark_thread_handled.

An agent that runs this loop with an API key works under that key's limits: a daily send limit (429 daily_limit), a recipient allowlist (403 recipient_not_allowed) and requires_approval, which returns pending_approval with an approval_id for a person to release. OAuth sessions carry scopes but none of those limits. A held send is not a failure. For the agent side, read what it means for an agent to read the reply and the email API for AI agents page.

Threading checklist for your own build

This works with any stack. Use it if you build the join yourself.

  1. Store the Message-ID of every message you send, and the thread it belongs to.
  2. Match replies on In-Reply-To and References, in that order. Never on subject.
  3. Strip quoted history before any model or rule reads the reply.
  4. Check sender authentication (DMARC or aligned DKIM) before acting on content.
  5. Track an "awaiting reply" state explicitly, and give yourself a way to mark a thread handled.
  6. Set In-Reply-To and References on your own replies, so the other person's client shows one conversation.

Or skip most of the build. SendRaven records the Message-ID of every message it sends (step 1), joins replies on Message-ID (step 2), strips quoted history into text (step 3), tracks awaiting_reply with a handled endpoint (step 5) and sets the threading headers when you send with reply_to_message_id (step 6). For step 4, it gives you sender_authenticated. What your code does with it is up to you.

FAQ

What is message threading?

Message threading groups messages into one conversation. In email, it links each reply to the message it answers, using the Message-ID, In-Reply-To and References headers.

How does email threading work?

Every message carries a Message-ID. A reply sets In-Reply-To to the ID it answers and appends to References. Software then looks those IDs up to place the reply in the right conversation.

What are the Message-ID, In-Reply-To and References headers?

Message-ID identifies one message. In-Reply-To holds the ID of the message being answered. References holds the chain of earlier IDs in the conversation.

Why shouldn't you thread email by subject line?

Subjects get edited, prefixed and reused. Two unrelated conversations can share one, and one real conversation can split in two. Joining on Message-ID avoids both.

How do you thread a reply in code?

With SendRaven, send POST /v1/emails with reply_to_message_id set to the message you are answering. SendRaven sets In-Reply-To and References and puts the message on the same thread. Over MCP, use reply_to_message.

How do you strip quoted history from an inbound reply?

You can parse it out yourself, or read the text field on a SendRaven thread message. text has quoted history and signature stripped, and raw_text is the fallback.

How do you find conversations that are still waiting for a reply?

Call GET /v1/threads?awaiting_reply=true. It lists conversations where the other person wrote last. Clear one with POST /v1/threads/:id/handled.

Do inbound replies count toward my email usage?

No. Replies are free and don't count toward usage. SendRaven's one meter counts outbound emails sent.

Can an AI agent reply inside the same thread?

Yes. An agent can call reply_to_message over MCP, or POST /v1/emails with reply_to_message_id over REST. If it uses an API key, that key's guardrails apply: daily send limit, recipient allowlist and requires_approval. OAuth sessions carry scopes but none of those limits.

Start free

Sending is table stakes. The conversation is the job. Try the loop: send a message, reply in thread with reply_to_message_id, and watch the conversation appear at GET /v1/threads.

Start free. The Free plan is $0/month with 3,000 emails/month, and inbound is included. A verified payment method is required before any sending, including on Free. For a working loop, the runnable examples cover Node, Python, Claude over MCP and OpenAI Agents.

  • message threading
  • threading messages
  • email threading
  • email message threading
  • Message-ID In-Reply-To References

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.