All posts
Architecture·By Deepak··8 min read

Thread-Aware Email Replies for AI Agents | One Thread

TL;DR

AI agents break email threads when they omit In-Reply-To and References headers. Proper thread continuity requires storing Message-IDs, setting these headers on every reply, and mirroring the recipient's subject line. Get this wrong and every agent reply opens a new thread, destroying conversation context for both humans and downstream systems.

TL;DR: AI agents break email threads when they omit In-Reply-To and References headers. Proper thread continuity requires storing Message-IDs, setting these headers on every reply, and mirroring the recipient's subject line. Get this wrong and every agent reply opens a new thread, destroying conversation context for both humans and downstream systems.

Thread-Aware Email Replies for AI Agents | One Thread

Thread-aware email replies aren't a nice-to-have — they're a prerequisite for any AI agent that participates in multi-turn email conversations. When an agent sends a reply without correct In-Reply-To and References headers, every response lands as a new, disconnected message in the recipient's inbox. The conversation breaks. Context is lost. The human on the other end sees chaos, and any downstream system trying to correlate messages by thread fails silently.

This post explains the exact mechanism email clients use to group messages, how to implement it correctly in agent code, and what happens when you get it wrong.

How email threading actually works

Email threading isn't a server-side feature — it's client-side logic driven by three headers defined in RFC 2822 and refined in RFC 5322. No single header by itself guarantees thread grouping in every client. You need all three working together.

Message-ID — Every email you send must have a globally unique identifier in this header. Format: <unique-string@your-domain.com>. Most SMTP libraries generate this automatically, but for agent-sent mail, you should generate and store it yourself so you can reference it later.

In-Reply-To — Set this to the Message-ID of the single message you're directly replying to. This tells clients which message your reply is responding to.

References — Contains the full ancestry of Message-IDs in the thread, space-separated, oldest first. When you reply, copy the sender's References header and append their Message-ID to the end.

Gmail threads by References and In-Reply-To. Outlook threads primarily by subject line (normalized, with Re:/Fwd: stripped) but also uses these headers. Apple Mail and Thunderbird respect the RFC headers. Skip References and Gmail may still thread correctly for a two-message conversation — but anything deeper breaks.

Subject line normalization

Subject threading in clients like Outlook strips Re:, Fwd:, AW:, Antwort:, and other locale-specific prefixes before comparing. Your agent should preserve the original subject exactly and prepend Re: once — not Re: Re: Re:. Strip existing Re: prefixes before adding your own:

function buildReplySubject(originalSubject: string): string {
  // Strip leading Re:/RE:/re: with optional whitespace
  const stripped = originalSubject.replace(/^(re:\s*)+/i, '').trim();
  return `Re: ${stripped}`;
}

Storing thread state in your agent

Thread-awareness requires state. Your agent must persist at minimum:

  • The Message-ID of every message it sends
  • The Message-ID of every inbound message it receives
  • The References chain of each inbound message
  • The thread identifier you use internally to group messages

A minimal schema (PostgreSQL):

CREATE TABLE email_messages (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  thread_id     UUID NOT NULL REFERENCES email_threads(id),
  message_id    TEXT NOT NULL UNIQUE,  -- the RFC 2822 Message-ID
  in_reply_to   TEXT,                  -- Message-ID this replies to
  references    TEXT[],               -- ordered ancestry array
  direction     TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
  subject       TEXT NOT NULL,
  from_address  TEXT NOT NULL,
  to_addresses  TEXT[] NOT NULL,
  received_at   TIMESTAMPTZ,
  sent_at       TIMESTAMPTZ
);

CREATE TABLE email_threads (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  external_ref    TEXT,  -- your domain's thread concept (ticket ID, task ID, etc.)
  created_at      TIMESTAMPTZ DEFAULT now()
);

Extract headers immediately when your inbound parser fires a webhook — before any LLM processing. Header extraction is cheap. Losing headers because you forgot to store them before async processing is painful to debug.

Parsing inbound headers for thread context

When you parse inbound email through a webhook, the raw headers contain everything you need. Here's how to extract thread context in TypeScript:

interface ThreadContext {
  messageId: string;
  inReplyTo: string | null;
  references: string[];
  subject: string;
}

function extractThreadContext(headers: Record<string, string>): ThreadContext {
  const messageId = headers['message-id']?.trim().replace(/[<>]/g, '') ?? '';
  const inReplyTo = headers['in-reply-to']?.trim().replace(/[<>]/g, '') ?? null;

  // References is a whitespace-separated list of Message-IDs
  const rawRefs = headers['references'] ?? '';
  const references = rawRefs
    .split(/\s+/)
    .map(r => r.replace(/[<>]/g, '').trim())
    .filter(Boolean);

  return {
    messageId,
    inReplyTo,
    references,
    subject: headers['subject'] ?? ''
  };
}

Header names are case-insensitive per the RFC but vary by SMTP implementation. Normalize to lowercase before lookup.

Building a correct reply

When your agent sends a reply, it must construct headers that continue the thread:

interface ReplyHeaders {
  'Message-ID': string;
  'In-Reply-To': string;
  'References': string;
  'Subject': string;
}

function buildReplyHeaders(
  agentDomain: string,
  inboundContext: ThreadContext
): ReplyHeaders {
  // Generate a fresh, unique Message-ID for this outbound message
  const newMessageId = `${crypto.randomUUID()}@${agentDomain}`;

  // References = inbound references + the inbound message's own ID
  const newReferences = [...inboundContext.references, inboundContext.messageId]
    .filter(Boolean)
    .join(' ');

  return {
     'Message-ID': `<${newMessageId}>`,
    'In-Reply-To': `<${inboundContext.messageId}>`,
    'References': newReferences.split(' ').map(r => `<${r}>`).join(' '),
    'Subject': buildReplySubject(inboundContext.subject)
  };
}

If you're using Nodemailer:

await transporter.sendMail({
  from: 'agent@yourdomain.com',
  to: recipientAddress,
  ...buildReplyHeaders('yourdomain.com', threadContext),
  text: agentResponseText,
  html: agentResponseHtml
});

If you're calling an HTTP API directly (Python example using requests):

import requests
import uuid

def build_reply_headers(agent_domain: str, inbound_context: dict) -> dict:
    new_message_id = f"{uuid.uuid4()}@{agent_domain}"
    inbound_refs = inbound_context.get('references', [])
    all_refs = inbound_refs + [inbound_context['message_id']]
    references_str = ' '.join(f'<{r}>' for r in all_refs if r)

    return {
        'Message-ID': f'<{new_message_id}>',
        'In-Reply-To': f'<{inbound_context["message_id"]}>' ,
        'References': references_str,
    }

headers = build_reply_headers('yourdomain.com', thread_context)

requests.post(
    'https://api.mails.ai/v1/send',
    headers={'Authorization': f'Bearer {API_KEY}'},
    json={
        'from': 'agent@yourdomain.com',
        'to': recipient,
        'subject': reply_subject,
        'text': agent_response,
        'headers': headers
    }
)

Thread reconstruction for multi-turn context

Agents that need to reason about conversation history must reconstruct the thread in order before passing it to the LLM. The naive approach — querying all messages with the same subject — breaks when subjects change mid-thread (it happens). Use the References chain instead:

async function getThreadMessages(db: Pool, messageId: string): Promise<EmailMessage[]> {
  // Walk the references chain to find the root thread_id
  const msg = await db.query(
    'SELECT thread_id FROM email_messages WHERE message_id = $1',
    [messageId]
  );
  
  if (!msg.rows[0]) throw new Error(`Message not found: ${messageId}`);

  const { thread_id } = msg.rows[0];

  // Fetch all messages in chronological order
  const result = await db.query(
    `SELECT * FROM email_messages 
     WHERE thread_id = $1 
     ORDER BY COALESCE(received_at, sent_at) ASC`,
    [thread_id]
  );

  return result.rows;
}

Pass this ordered list to your LLM as a conversation history — either as a formatted text block or as structured chat messages if your agent framework supports it.

Thread flow for a multi-turn agent conversation

sequenceDiagram
  participant H as Human
  participant I as Inbound Parser
  participant DB as Thread Store
  participant LLM as Agent LLM
  participant S as SMTP Send

  H->>I: Email with Message-ID M1
  I->>DB: Store M1 headers and thread_id T1
  I->>LLM: Dispatch with thread context
  LLM->>DB: Fetch ordered thread messages
  LLM->>S: Send reply with In-Reply-To M1 and References M1
  S->>H: Reply lands in same thread
  H->>I: Reply with Message-ID M2 References M1
  I->>DB: Store M2 linked to thread_id T1
  I->>LLM: Dispatch with updated thread context
  LLM->>S: Send reply In-Reply-To M2 References M1 M2
  S->>H: Continues in same thread

Common failure modes

Missing References on replies beyond turn 2. Most bugs only surface in three-or-more-turn conversations. Clients thread the first reply correctly — two messages, simple In-Reply-To is enough for Gmail — then the third message breaks the chain because References was never built up. Test every agent with at least a 4-turn exchange.

Generating new Message-IDs without storing them. If your SMTP library auto-generates Message-ID and you don't capture what it generated, you can't build References for future turns. Either generate it yourself before calling the API, or read the sent message's Message-ID back from the API response.

Re-encoding subjects. RFC 2047 encoded subjects (=?UTF-8?B?...?=) must be decoded before you strip Re: prefixes and re-encoded before sending. Processing raw encoded strings produces garbled subjects that break subject-based threading in Outlook.

Sending from a different address. If your agent sends from agent-a@yourdomain.com in turn 1 and agent-b@yourdomain.com in turn 2, some clients treat these as different threads regardless of headers. Keep the From address consistent within a thread, or use Reply-To to route replies to a stable address.

Deliverability considerations

Thread-aware replies have a secondary benefit: they look like legitimate conversational email to spam filters. An isolated outbound message with no In-Reply-To is statistically more likely to be flagged as bulk mail. A message that references a prior inbound message demonstrates a real prior interaction.

Spam filters like SpamAssassin score based on header consistency. A well-formed References chain with properly formatted Message-ID headers (angle-bracket-wrapped, @domain suffix) reduces suspicion. Malformed or absent Message-ID headers are a weak negative signal — but they add up alongside other factors.

For agents doing high-volume conversational email, sender reputation still depends on SPF, DKIM, and DMARC passing. Thread headers don't compensate for authentication failures. Both layers are required.

Platforms like Mails.ai built specifically for agent email handle custom header injection natively in their send API — you pass a headers object with your threading headers and they're included in the SMTP transaction without post-processing hacks.

Frequently Asked Questions

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

Gmail threads correctly for a 2-message exchange (original + one reply) using In-Reply-To alone. For longer threads, it falls back to subject-line matching if References is absent. That works until someone changes the subject. Outlook always prioritizes subject-based threading, so References provides a useful backup signal. Always set both.

Should I store the full References chain or just the immediate parent Message-ID?

Store the full chain. You'll need it to build References on subsequent replies without querying the full thread history every time. A single TEXT[] column holding the ordered list of ancestor Message-IDs is efficient and sufficient.

Can I create a thread proactively, before receiving any inbound message?

Yes — if your agent sends the first message, it creates the thread root. Store the outgoing Message-ID. When a human replies, their client will set In-Reply-To to your Message-ID automatically. Parse that from the inbound webhook, match it to your stored outgoing message, and you have the thread linkage.

How do I handle threads that span multiple agent instances or workers?

The thread state must live in shared persistent storage (database, Redis), not in-process memory. Each worker reads thread context from the store before processing and writes back after sending. Use optimistic locking or a queue per thread to prevent two workers from replying simultaneously to the same thread.

Do IMAP folders and labels affect threading?

No. Threading is a display-layer client feature driven by headers. IMAP folder/label assignment is orthogonal. Moving a message to a different folder doesn't change its Message-ID or References, so thread grouping remains intact in clients that support it.

How long should I retain thread state?

Retain thread headers indefinitely for active threads. A customer support thread that goes quiet for 6 months may resume — and the human's client still has the thread context even if your agent has garbage-collected it. Soft-delete or archive rather than hard-delete email thread records.

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