All posts
Architecture·By Deepak··9 min read

Composing RFC-Compliant Replies from an AI Agent

TL;DR

Composing RFC-compliant replies from an AI agent requires correctly setting In-Reply-To, References, and Message-ID headers, preserving threading in MIME structure, and quoting original content per RFC 5322 conventions. Get these wrong and your agent's replies break threading in Gmail, Outlook, and every standards-compliant MUA.

TL;DR: Composing RFC-compliant replies from an AI agent requires correctly setting In-Reply-To, References, and Message-ID headers, preserving threading in MIME structure, and quoting original content per RFC 5322 conventions. Get these wrong and your agent's replies break threading in Gmail, Outlook, and every standards-compliant MUA.

Composing RFC-Compliant Replies from an AI Agent

Composing RFC-compliant replies from an AI agent is harder than it looks. Sending a plain email is trivial. Sending a reply that threads correctly in Gmail, doesn't confuse Outlook's conversation grouping, carries the right quoting structure, and satisfies every header requirement laid out in RFC 5322 and RFC 2822 — that takes deliberate engineering.

Most agent email implementations get about 60% of this right. They set In-Reply-To. They forget References. They strip the quoted text. They generate a Message-ID that isn't globally unique. Then someone complains that the agent's emails show up as new threads, or that replies land in wrong folders. This post covers the full picture.

Why threading headers matter

Email clients don't thread by subject line alone — that's a fallback. The primary mechanism is header-based: In-Reply-To points to the immediate parent message, and References carries the entire ancestor chain. Gmail, Outlook, Apple Mail, and Thunderbird all use this chain to group messages into conversations.

Without correct threading headers, your agent's reply renders as a new conversation in the recipient's inbox. For an automated system sending hundreds of replies, that's not a minor UX issue. It actively destroys the conversational context that makes email useful for async collaboration between humans and agents.

The three headers you must set

Message-ID — Every outbound message needs a globally unique identifier in the format <local-part@domain>. RFC 5322 §3.6.4 specifies this. Your local part should include enough entropy that collisions are impossible at scale. A UUID works; a timestamp alone does not.

Message-ID: <550e8400-e29b-41d4-a716-446655440000@mail.youragent.ai>

In-Reply-To — Set this to the exact Message-ID value of the message you're replying to. Copy it character-for-character, angle brackets included.

In-Reply-To: <CABcd1234xyz@mail.gmail.com>

References — This is where most implementations fail. References should contain the full ordered list of Message-ID values from the thread ancestor chain, space-separated, ending with the parent's Message-ID. If the inbound message you received already had a References header, append its Message-ID to that list.

References: <first-msg@example.com> <second-msg@example.com> <CABcd1234xyz@mail.gmail.com>

The algorithm in code:

function buildReferencesHeader(
  inboundReferences: string | undefined,
  inboundMessageId: string
): string {
  const existing = inboundReferences
    ? inboundReferences.trim().split(/\s+/)
    : [];
  return [...existing, inboundMessageId].join(' ');
}

Don't deduplicate unless you see actual duplicates — the order matters and some clients validate it.

Parsing the inbound message correctly

Before your agent can reply, it needs to extract the threading headers from the inbound message. This is where the specifics of your email infrastructure matter.

If you're using a webhook-based inbound pipeline (see Mails.ai's inbound email parsing), the parsed headers arrive as a structured object. But raw MIME parsing still requires care. Libraries vary in how they surface multi-value headers and whether they decode RFC 2047 encoded-words in header values.

In Node.js with mailparser:

import { simpleParser } from 'mailparser';

async function extractThreadingContext(rawMime: Buffer) {
  const parsed = await simpleParser(rawMime);
  return {
    messageId: parsed.messageId,          // already stripped of angle brackets by mailparser
    inReplyTo: parsed.inReplyTo,
    references: parsed.references,         // string[] or undefined
    subject: parsed.subject,
    from: parsed.from?.value,
    replyTo: parsed.replyTo?.value,
    textBody: parsed.text,
    htmlBody: parsed.html,
  };
}

Note that mailparser strips angle brackets from messageId. When you set In-Reply-To in your reply, you need to re-add them:

const inReplyTo = `<${parsed.messageId}>`;

For Python, use email.parser.BytesParser from the standard library:

import email
from email import policy

def extract_threading_context(raw_bytes: bytes) -> dict:
    msg = email.parser.BytesParser(policy=policy.default).parsebytes(raw_bytes)
    return {
        'message_id': msg['Message-ID'],
        'in_reply_to': msg['In-Reply-To'],
        'references': msg['References'],
        'subject': msg['Subject'],
        'from': msg['From'],
        'text_body': msg.get_body(preferencelist=('plain',)),
        'html_body': msg.get_body(preferencelist=('html',)),
    }

Constructing the reply subject

The reply subject should be the original subject prefixed with Re: — exactly that string, capital R, lowercase e, colon, single space. RFC 5322 §3.6.5 specifies this. Don't add multiple Re: prefixes if the original already has one.

function buildReplySubject(originalSubject: string): string {
  if (/^Re:\s/i.test(originalSubject)) {
    return originalSubject; // already prefixed
  }
  return `Re: ${originalSubject}`;
}

Clients that thread by subject as a secondary fallback rely on this normalization. Outlook in particular will break its conversation view if your subject doesn't match.

Reply quoting: the often-skipped part

RFC 5322 doesn't mandate a specific quoting style, but the de-facto internet standard — established by decades of MUA behavior — is to prefix each line of quoted text with > and include an attribution line above it.

For a plain-text reply:

Your agent's reply goes here.

On Mon, 17 Aug 2026 at 14:23, Alice <alice@example.com> wrote:

> Original message line one.
> Original message line two.
> Original message line three.

For an HTML reply, wrap the quoted content in a blockquote:

<div>Your agent's reply goes here.</div>
<br>
<div class="gmail_quote">
  <div>On Mon, 17 Aug 2026 at 14:23, Alice &lt;alice@example.com&gt; wrote:</div>
  <blockquote style="margin:0 0 0 0.8ex;border-left:1px #ccc solid;padding-left:1ex">
    Original message content here.
  </blockquote>
</div>

The border-left style on the blockquote is what renders as the visual quote bar in webmail clients that don't support the <blockquote> element natively.

Generate the attribution line programmatically from the inbound message's Date header and sender:

function buildAttributionLine(date: Date, fromName: string, fromEmail: string): string {
  const formatted = date.toLocaleString('en-US', {
    weekday: 'short',
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    timeZoneName: 'short',
  });
  return `On ${formatted}, ${fromName} <${fromEmail}> wrote:`;
}

MIME structure for a multipart reply

A proper reply that includes both plain text and HTML should be multipart/alternative. Add attachments and it becomes multipart/mixed wrapping a multipart/alternative.

Content-Type: multipart/alternative; boundary="boundary_abc123"

--boundary_abc123
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: quoted-printable

[plain text reply with > quoted text]

--boundary_abc123
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable

[html reply with blockquote]

--boundary_abc123--

The plain-text part is not optional. Mail clients, spam filters, and accessibility tools all depend on it. An agent that sends HTML-only replies will see higher spam scores and accessibility complaints.

The full header block for an RFC-compliant reply:

From: Agent Name <agent@youragent.ai>
To: alice@example.com
Subject: Re: Your original subject
Message-ID: <550e8400-e29b-41d4-a716-446655440000@mail.youragent.ai>
In-Reply-To: <CABcd1234xyz@mail.gmail.com>
References: <first-msg@example.com> <CABcd1234xyz@mail.gmail.com>
Date: Mon, 17 Aug 2026 14:30:00 +0000
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="boundary_abc123"

Threading flow in practice

sequenceDiagram
  participant H as Human MUA
  participant I as Inbound Parser
  participant A as AI Agent
  participant S as SMTP Sender

  H->>I: Email with Message-ID and References
  I->>A: Parsed headers plus body via webhook
  A->>A: Extract Message-ID In-Reply-To References
  A->>A: Generate reply body with LLM
  A->>A: Build new Message-ID
  A->>A: Set In-Reply-To and append to References
  A->>S: Send multipart reply with full header block
  S->>H: Threaded reply arrives in correct conversation

The Reply-To vs From distinction

When replying, your To address should be the inbound sender's Reply-To header if present, falling back to From. Many automated senders set Reply-To to a monitoring address that differs from the envelope From. Ignoring this and always replying to From sends replies to unmonitored mailboxes.

function resolveReplyToAddress(parsed: ParsedMail): string {
  const replyTo = parsed.replyTo?.value?.[0]?.address;
  const from = parsed.from?.value?.[0]?.address;
  return replyTo ?? from ?? '';
}

For agent-to-agent communication, where your agent is replying to another agent, this matters even more. The email infrastructure for agents needs to handle this routing correctly at both the sending and receiving ends.

Avoiding common failure modes

Duplicate Message-IDs. Using Date.now() as your local part will collide under concurrent sends. Use a UUID v4 or a cryptographically random 128-bit value encoded as hex.

Truncated References chain. If you only carry the immediate parent's Message-ID in References instead of the full ancestor chain, clients with more than two messages in a thread will break conversation grouping for messages 3 and beyond.

Encoding issues in quoted text. When quoting the original body, properly decode any quoted-printable or base64 content-transfer-encoding before inserting it into your reply. Inserting raw encoded bytes produces garbage.

Wrong Date header timezone. The Date header must be RFC 2822 compliant: Mon, 17 Aug 2026 14:30:00 +0000. JavaScript's Date.toString() gives you a human-readable string, not RFC 2822 format. Use a library or format manually.

function toRFC2822Date(date: Date): string {
  return date.toUTCString().replace('GMT', '+0000');
}

Replying to the wrong From when CC is involved. A true "reply-all" also CCs the original CC recipients, minus your own sending address. If your agent is doing reply-all, filter your own domain from the CC list to prevent loops.

Sender reputation and deliverability

A correctly threaded reply also benefits sender reputation. Replies in an existing thread inherit some trust from the conversation history — Gmail's filtering is known to treat replies differently from cold outbound. But only if the threading headers are correct. A reply Gmail doesn't recognize as part of an existing thread gets evaluated as a fresh cold email.

For agents sending at volume, pairing correct threading with proper authentication (SPF, DKIM, DMARC aligned to your sending domain) is baseline. Mails.ai handles the authentication infrastructure automatically, so your agent just needs to get the headers right.

Frequently Asked Questions

What happens if I omit the References header?

The message threads correctly in clients that only use In-Reply-To (many do), but breaks in clients that validate the full ancestry chain. Thunderbird in particular uses References for its full thread tree view. For threads longer than two messages, missing References will cause later replies to appear as orphaned children or new threads.

Should my agent generate a new Message-ID for every reply?

Yes, always. Every distinct email message — whether original or reply — must have its own unique Message-ID. Never reuse the parent's Message-ID. The In-Reply-To header is how you express the relationship; Message-ID identifies the current message.

How do I handle HTML emails that include complex CSS when quoting?

Strip inline styles from the quoted portion and let your blockquote styling control the presentation. Most email clients will apply their own rendering to blockquote content anyway. For safety, run the HTML through a sanitizer that preserves structural elements (<p>, <br>, <ul>, <li>) but removes <style> blocks and complex inline styles that could break your layout.

Do spam filters penalize agent-generated reply content?

Spam filters analyze reply content the same as any other email. The quoting structure actually helps — a properly quoted reply with attribution looks like a human-style reply to filters. What hurts is templated language, excessive links, or mismatched From display names. Keep your agent's reply prose natural and minimize links to only what's necessary.

How do I prevent reply loops between two agents?

Set an X-Agent-ID or similar custom header on every outbound reply, and check for it on every inbound message before triggering a reply. If the inbound message already carries your agent's header, skip it. Also check Auto-Submitted: auto-generated in inbound headers — RFC 3834 defines this for automated senders and well-behaved agents should both emit it and respect it. See the Mails.ai inbound email parsing docs for how to access custom headers from the parsed webhook payload.

What's the right Content-Transfer-Encoding for reply bodies?

For text/plain: quoted-printable if the content is mostly ASCII with some Unicode; base64 for heavily non-ASCII content. For text/html: quoted-printable is standard. Base64 on HTML bodies sometimes triggers spam filters because it obscures content. Always explicitly declare the encoding — don't omit the header and assume 7-bit ASCII.

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