All posts
Architecture·By Deepak··9 min read

Thread Email Replies Programmatically: Complete Guide

TL;DR

Email threading relies on three RFC 5322 headers: Message-ID, In-Reply-To, and References. To attach a reply to an existing thread, copy the parent's Message-ID into In-Reply-To and append it to References. Mail clients use these headers — not Subject — to group messages into conversations.

TL;DR: Email threading relies on three RFC 5322 headers: Message-ID, In-Reply-To, and References. To attach a reply to an existing thread, copy the parent's Message-ID into In-Reply-To and append it to References. Mail clients use these headers — not Subject — to group messages into conversations.

Thread Email Replies Programmatically: Complete Guide

Threading email replies programmatically means setting the right RFC 5322 headers so that mail clients and servers group your outbound messages into the correct conversation. The mechanism is straightforward: three headers (Message-ID, In-Reply-To, References) carry all the information a mail client needs to reconstruct a thread. Get them right and Gmail, Outlook, Apple Mail, and every RFC-compliant client will display your automated replies inside the original conversation. Get them wrong and every reply shows up as an orphaned message.

How email threading actually works

Every email client that displays threaded conversations — Gmail, Outlook, Thunderbird — relies entirely on message headers, not Subject line matching. The RFC 5322 specification defines three headers that carry threading information.

Message-ID is a globally unique identifier assigned to each message. It looks like <uuid@domain.tld>. Your sending infrastructure (or you, manually) assigns this when a message is first sent.

In-Reply-To contains the Message-ID of the single message being replied to, the direct parent.

References contains the full ancestry chain: every Message-ID from the root of the thread to the immediate parent, space-separated. This is what lets clients reconstruct deep conversation trees even when intermediate messages are missing from a mailbox.

Gmail's threading algorithm uses References and In-Reply-To as the primary signal. When both are missing, it falls back to normalized Subject matching — stripping Re:, Fwd:, and whitespace — but Subject-based threading is unreliable across clients and breaks the moment anyone edits the subject.

Message-ID: <original-message@yourdomain.com>
Subject: Invoice #1042

When you reply:

Message-ID: <reply-1@yourdomain.com>
In-Reply-To: <original-message@yourdomain.com>
References: <original-message@yourdomain.com>
Subject: Re: Invoice #1042

If there's a third message replying to the second:

Message-ID: <reply-2@yourdomain.com>
In-Reply-To: <reply-1@yourdomain.com>
References: <original-message@yourdomain.com> <reply-1@yourdomain.com>
Subject: Re: Invoice #1042

The References chain grows with each hop. Clients that implement RFC 5322 threading walk this chain to build the tree.

Generating compliant Message-IDs

A Message-ID must be globally unique and syntactically valid: <local-part@domain>. The domain part should be a real domain you control — using a domain you don't own causes some spam filters to flag messages.

Python:

import uuid

def make_message_id(domain: str) -> str:
    return f"<{uuid.uuid4()}@{domain}>"

msg_id = make_message_id("yourapp.com")

TypeScript:

import { randomUUID } from 'crypto';

function makeMessageId(domain: string): string {
  return `<${randomUUID()}@${domain}>`;
}

Some teams encode structured data into the local part — a base64 thread ID or a hash of the originating entity — so they can route inbound replies without a database lookup. That's valid as long as the whole thing stays under 998 characters and uses only printable ASCII. For example:

import base64

def make_routable_message_id(thread_id: str, domain: str) -> str:
    encoded = base64.urlsafe_b64encode(thread_id.encode()).decode().rstrip('=')
    return f"<tid.{encoded}.{uuid.uuid4().hex[:8]}@{domain}>"

This pattern is particularly useful for inbound email parsing. When a user replies, the inbound webhook delivers the In-Reply-To header containing your structured ID, so you can immediately associate the reply with the correct internal thread — no full message-ID-to-thread-ID lookup needed.

Storing threading state

To thread replies correctly, you need to persist at least two values per thread:

  1. The Message-ID of the most recent message sent (for In-Reply-To)
  2. The full References chain accumulated so far

A minimal schema:

CREATE TABLE email_threads (
  thread_id        UUID PRIMARY KEY,
  subject          TEXT NOT NULL,
  root_message_id  TEXT NOT NULL,       -- first Message-ID in chain
  last_message_id  TEXT NOT NULL,       -- for In-Reply-To on next send
  references_chain TEXT NOT NULL,       -- space-separated Message-IDs
  updated_at       TIMESTAMPTZ DEFAULT now()
);

When you send a new message in a thread:

def send_threaded_reply(thread_id: str, to: str, body: str, smtp_client):
    thread = db.get("email_threads", thread_id)

    new_msg_id = make_message_id("yourapp.com")
    new_references = f"{thread['references_chain']} {thread['last_message_id']}".strip()

    headers = {
        "Message-ID": new_msg_id,
        "In-Reply-To": thread["last_message_id"],
        "References": new_references,
        "Subject": f"Re: {thread['subject']}",
    }

    smtp_client.send(to=to, headers=headers, body=body)

    db.update("email_threads", thread_id, {
        "last_message_id": new_msg_id,
        "references_chain": new_references,
    })

Keep the update and the send close together — ideally in a transaction or with idempotency handling — to avoid a race condition where two concurrent sends both read the same last_message_id.

Handling inbound replies

When a human replies to your automated message, their mail client sets In-Reply-To to your outbound Message-ID. Your inbound webhook receives that header. You extract it, look up the thread, and continue the conversation in context.

# Webhook handler (Flask/FastAPI style)
@app.post("/webhooks/inbound")
def handle_inbound(payload: dict):
    in_reply_to = payload["headers"].get("in-reply-to", "").strip()
    references = payload["headers"].get("references", "").strip()

    # Prefer References chain root for lookup, fall back to In-Reply-To
    thread = (
        db.find_thread_by_any_message_id(references.split())
        or db.find_thread_by_message_id(in_reply_to)
    )

    if not thread:
        # New thread — create it
        thread = db.create_thread(
            root_message_id=payload["message_id"],
            subject=strip_re_prefix(payload["subject"]),
        )

    db.append_inbound_message(thread["thread_id"], payload)
    dispatch_to_agent(thread, payload)

Note the fallback: search the full References chain, not just In-Reply-To. Some mail clients — certain corporate Exchange setups in particular — truncate or reorder headers. Querying all known Message-IDs in your thread against the incoming References values is more resilient.

Sending threaded replies via SMTP

If you're constructing raw MIME messages:

from email.message import EmailMessage
import smtplib

def build_reply(thread: dict, to: str, body: str) -> EmailMessage:
    new_msg_id = make_message_id("yourapp.com")
    new_refs = f"{thread['references_chain']} {thread['last_message_id']}".strip()

    msg = EmailMessage()
    msg["From"] = "agent@yourapp.com"
    msg["To"] = to
    msg["Subject"] = f"Re: {thread['subject']}"
    msg["Message-ID"] = new_msg_id
    msg["In-Reply-To"] = thread["last_message_id"]
    msg["References"] = new_refs
    msg.set_content(body)
    return msg

with smtplib.SMTP_SSL("smtp.yourapp.com", 465) as smtp:
    smtp.login(user, password)
    smtp.send_message(build_reply(thread, "user@example.com", "Your invoice is attached."))

The email.message.EmailMessage API in Python 3.6+ handles MIME encoding cleanly. Always set Message-ID explicitly — don't let your MTA generate one, because then you have no record of it and can't construct the next References chain.

Threading via HTTP email APIs

Most modern email APIs accept custom headers as a dictionary. With the Mails.ai email API:

import Mails from '@mailsai/sdk';

const client = new Mails({ apiKey: process.env.MAILS_API_KEY });

async function sendThreadedReply(thread: Thread, to: string, body: string) {
  const newMsgId = makeMessageId('yourapp.com');
  const newRefs = [thread.referencesChain, thread.lastMessageId]
    .filter(Boolean)
    .join(' ')
    .trim();

  await client.emails.send({
    from: 'agent@yourapp.com',
    to,
    subject: `Re: ${thread.subject}`,
    text: body,
    headers: {
      'Message-ID': newMsgId,
      'In-Reply-To': thread.lastMessageId,
      'References': newRefs,
    },
  });

  await db.updateThread(thread.id, {
    lastMessageId: newMsgId,
    referencesChain: newRefs,
  });
}

Common mistakes that break threading

┌────────────────────────────────────────────────────────────────┐
│ Mistake                  │ Consequence                         │
├────────────────────────────────────────────────────────────────┤
│ No Message-ID on outbound│ Can't build References on next send │
│ In-Reply-To only, no Ref │ Deep threads break in some clients  │
│ Reusing Message-IDs      │ Clients may de-duplicate/drop msgs  │
│ MTA-generated Message-ID │ No record → can't construct chain   │
│ Subject-only threading   │ Breaks when subject changes         │
│ Angle brackets omitted   │ Some parsers reject bare IDs        │
└────────────────────────────────────────────────────────────────┘

Angle brackets are mandatory. <uuid@domain> not uuid@domain. RFC 5322 §3.6.4 is explicit: msg-id = "<" id-left "@" id-right ">". Parsers in some clients — particularly older Outlook versions — reject bracket-free IDs and silently discard the header.

Never reuse a Message-ID. If a message is retried after a send failure, generate a new one. Deduplicate at the application layer using your own idempotency key, not the message ID.

Don't let References grow unbounded either. RFC 5322 technically allows unlimited length, but some MTAs truncate headers over 998 bytes. For long threads (50+ messages), truncate from the front while always keeping the root and recent messages:

def truncate_references(chain: str, new_id: str, max_ids: int = 20) -> str:
    ids = chain.split()
    ids.append(new_id)
    if len(ids) > max_ids:
        # Keep root + last (max_ids - 1) entries
        ids = [ids[0]] + ids[-(max_ids - 1):]
    return " ".join(ids)

Threading across multiple senders

If your system sends from multiple addresses (e.g., agent-123@yourapp.com vs support@yourapp.com), threading still works — clients use headers, not envelope From. But the Reply-To header controls where human replies land, so set it deliberately:

msg["Reply-To"] = "agent-123@yourapp.com"

For agent systems where each thread needs a unique reply address, the subaddressing pattern works well: agent+threadid@yourapp.com. Your inbound email parsing infrastructure extracts the thread ID from the To address on delivery, giving you two independent routing signals, the In-Reply-To header and the envelope address.

sequenceDiagram
    participant A as Agent
    participant MTA as MTA
    participant U as User
    A->>MTA: Send msg with Message-ID and Reply-To agent+tid123
    MTA->>U: Delivered
    U->>MTA: Reply sets In-Reply-To and To agent+tid123
    MTA->>A: Webhook delivers reply
    A->>A: Extract thread from In-Reply-To OR address tag

Verifying your threading works

Three quick checks:

  1. Send a thread of 3+ messages from your system to a Gmail account. All should appear under one conversation toggle. Separate threads means your headers are wrong.

  2. Inspect raw headers in Gmail (three-dot menu -> "Show original"). Confirm Message-ID, In-Reply-To, and References are present and syntactically correct.

  3. Reply manually from Gmail to one of your messages, then verify your inbound webhook receives the in-reply-to header with exactly the Message-ID you sent. This confirms the round-trip works.

For automated testing, tools like MailHog or Mailpit run a local SMTP server and expose a web UI where you can inspect raw headers without sending real email.

Frequently Asked Questions

Do all mail clients thread by headers, or do some use Subject only?

All major clients use headers first. Gmail, Outlook (2013+), Apple Mail, Thunderbird, and Fastmail all implement RFC 5322 threading via In-Reply-To and References. Gmail additionally groups by Subject as a fallback when headers are absent, but it's unreliable. Mobile clients vary more — some older Android clients use Subject-only grouping. For any automated system, setting headers correctly is non-negotiable.

What happens if I only set In-Reply-To and omit References?

Two-message threads will still work — most clients treat In-Reply-To as sufficient for a single-level reply. But in conversations with three or more messages, clients that build the full tree from References will see gaps. Thunderbird and Apple Mail use References to reconstruct trees; omitting it means they can only show linear chains, not branching conversations.

Can I thread messages sent from different domains?

Yes. The threading headers are evaluated by the receiving mail client, which doesn't care whether your Message-ID domain matches your From domain. However, some spam filters score messages where the Message-ID domain is completely unrelated to the sending IP or From domain. Use a domain you control for Message-ID to avoid this.

How should I handle threading when a user forwards a message into a new conversation?

Forwarded messages lose the threading context — they create a new Message-ID with no In-Reply-To. Treat any inbound message without a matching In-Reply-To as a new thread. If your system needs to correlate it to an existing ticket (e.g., the user forwarded a message with a ticket ID in the body), extract that identifier from the body or subject, not from headers.

Is there a maximum length for the References header?

RFC 5322 sets a soft limit of 998 characters per header line (excluding CRLF), but long headers can be folded across multiple lines with whitespace. In practice, some MTAs and spam filters struggle with very long headers. Truncating References to the root plus the most recent 15-20 message IDs is a safe heuristic for long-running threads.

How do I test threading without sending real email?

Run Mailpit locally — it's a drop-in SMTP server with a web UI that displays threads and raw headers. Point your SMTP config at localhost:1025 during development and verify threading visually before sending to real addresses. For integration tests, assert that outbound messages contain the correct In-Reply-To value by capturing SMTP traffic in your test suite.

Start sending with Mails.ai

Live now

Ship agent email in ~6 lines.

Free tier, no card. Mint a key and drop the SDK into your agent.

Get your API key
Live now

Built for agents.
Self-serve in minutes.

The API is live and self-serve. Drop ~6 lines into your agent and ship.

npmpnpmbunnpx
$ npm install @mailsai/sdk
Live on npm today · @mailsai/sdk + @mailsai/mcp-server