
Most AI agents sending email obsess over the payload — the text the model generates — and ignore the envelope entirely. That works fine until your reply threads collapse in Gmail, your quoted text gets mangled, or a message bounces from a malformed header. RFC compliance isn't bureaucratic overhead; it's the difference between email that behaves like email and email that behaves like a broken API call.
This post covers the exact headers, MIME structure, and quoting mechanics an AI agent needs to produce replies that thread correctly, render cleanly, and pass scrutiny from both mail clients and spam filters.
Why RFC compliance matters for agent-generated replies
RFC 5322 (and its predecessor RFC 2822) defines the message format every mail client relies on for threading, display, and routing. An AI agent that violates these rules produces replies that appear as new conversations, drop quoted context, or get flagged by content filters. Threading alone depends on three headers — In-Reply-To, References, and Message-ID — that most agent frameworks never set.
The stakes are higher for automated senders than for humans. A human sends one broken reply. An agent sends ten thousand. Systematic header errors compound into deliverability problems: elevated spam scores, broken conversation views, and support tickets impossible to trace. Getting this right at the code level is cheap. Fixing it in production is not.
The core threading headers
Threading in email is header-driven. Mail clients don't parse message bodies to infer relationships. They use three headers exclusively:
Message-ID
Every message must carry a globally unique Message-ID. The format is <local-part@domain>, where the domain should be your sending domain, not a random string. Generate the local part with enough entropy to be collision-resistant.
import uuid
import datetime
def generate_message_id(sending_domain: str) -> str:
timestamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
unique = uuid.uuid4().hex[:12]
return f"<{timestamp}.{unique}@{sending_domain}>"
# Example output: <20240315143022.a3f9b2c1d4e5@mail.yourapp.com>
Don't reuse Message-ID values. Don't omit them. Some providers generate these for you, but if you're constructing raw MIME, you own this.
In-Reply-To
In-Reply-To contains the Message-ID of the message you're directly replying to, one value, the immediate parent.
In-Reply-To: <20240314091532.x7k2m1@sender-domain.com>
You get this value from the inbound message. When parsing inbound email via webhook or IMAP, extract and store the Message-ID header before you discard the raw message. If your inbound email parsing pipeline doesn't surface raw headers, that's a gap worth fixing.
References
References is the full ancestry chain — the Message-ID of every message in the thread, space-separated, oldest first. To construct it for a reply:
- Take the
Referencesheader from the message you're replying to. - Append that message's
Message-ID. - Set that as your
References.
def build_references(parent_references: str | None, parent_message_id: str) -> str:
if parent_references:
# Strip whitespace, normalize, append parent
existing = " ".join(parent_references.split())
return f"{existing} {parent_message_id}"
else:
# First reply — references is just the parent
return parent_message_id
Gmail, Apple Mail, and Outlook all use References for conversation grouping. Missing or truncated References causes threads to split.
Subject line handling
The convention is Re: {original subject}. RFC 5322 doesn't mandate Re: — it's a client convention — but omitting it breaks thread recognition in some clients and confuses recipients.
The rule: if the subject already starts with Re: (case-insensitive), don't prepend another one.
import re
def build_reply_subject(original_subject: str) -> str:
if re.match(r'^re:\s*', original_subject, re.IGNORECASE):
return original_subject
return f"Re: {original_subject}"
For forwarded threads arriving with Fwd: prefixes, strip those before applying the Re: logic if you're constructing a genuine reply rather than re-forwarding.
MIME structure for replies
A well-formed reply should be multipart/alternative containing at minimum a text/plain part and a text/html part. Some clients render only plain text; others render HTML. Sending both ensures compatibility.
For replies specifically, include the quoted original message beneath the agent's response — standard email convention, and it helps recipients follow context without hunting through their inbox.
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="=_boundary_abc123"
--=_boundary_abc123
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
{agent response text}
On Mon, 14 Mar 2024 09:15:32 +0000, Alice <alice@example.com> wrote:
> I had a question about the API rate limits.
--=_boundary_abc123
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div>{agent response HTML}</div>
<blockquote style="border-left: 2px solid #ccc; padding-left: 8px; color: #555;">
<p>On Mon, 14 Mar 2024, Alice wrote:</p>
<p>I had a question about the API rate limits.</p>
</blockquote>
--=_boundary_abc123--
The boundary string must be unique per message and must not appear in the body content. Generate it with sufficient randomness:
import secrets
def generate_boundary() -> str:
return f"=_mails_{secrets.token_hex(16)}"
Quoted text conventions
Email quoting has two conventions: > prefix for plain text (from RFC 3676), and <blockquote> for HTML. Both matter.
Plain text quoting
For text/plain parts, prefix each quoted line with > . Nested quotes get >> , and so on. RFC 3676 specifies format=flowed to handle line wrapping — worth supporting if your plain text generation might produce long lines.
def quote_text(body: str, sender_name: str, date_str: str) -> str:
header = f"On {date_str}, {sender_name} wrote:"
quoted_lines = "\n".join(f"> {line}" for line in body.splitlines())
return f"{header}\n{quoted_lines}"
HTML quoting
For text/html, wrap the original in a <blockquote>. Avoid inline styles that override the client's theming. The minimal version:
<blockquote cite="mid:{original_message_id}"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex">
{original_html_body}
</blockquote>
The cite attribute on <blockquote> references the original message by its Message-ID using the mid: URI scheme — technically correct per RFC 2392, though few clients render it visually.
The complete header set
Here's the full header block an agent reply should emit, annotated:
From: Agent Name <agent@yourapp.com> # Must match authenticated domain
To: alice@example.com # Extracted from inbound From:
Cc: bob@example.com # Preserve original Cc if replying-all
Reply-To: agent@yourapp.com # Where humans should reply
Date: Mon, 15 Mar 2024 14:30:22 +0000 # RFC 2822 date format
Subject: Re: API rate limit question
Message-ID: <20240315143022.a3f9b2c1d4e5@yourapp.com>
In-Reply-To: <20240314091532.x7k2m1@sender-domain.com>
References: <20240313170000.abc@originator.com> <20240314091532.x7k2m1@sender-domain.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="=_mails_3f8a2b1c9d0e4f7a"
The Date header must be RFC 2822 format. Python's email.utils.formatdate(localtime=False) generates a correct value. Don't use ISO 8601 here — mail is not JSON.
from email.utils import formatdate
date_header = formatdate(localtime=False) # "Mon, 15 Mar 2024 14:30:22 -0000"
Reply-To vs. From
These serve different purposes, and agents routinely confuse them.
| Header | Purpose | Agent use case |
|---|---|---|
From |
Who sent the message — must match your authenticated domain | agent@yourapp.com or a customer-scoped address |
Reply-To |
Where human replies should go | A monitored inbox, a support queue, or the same agent address |
Sender |
Actual submitting entity when From differs | Used when sending on behalf of another address |
If your agent sends from notifications@yourapp.com but you want replies routed to support@yourapp.com, set Reply-To: support@yourapp.com. Don't set Reply-To to an unmonitored address — that's how agent-generated mail creates dead-end reply chains.
Handling reply-all correctly
When an inbound message has multiple recipients and the agent needs to reply-all, the addressing logic gets non-trivial:
def compute_reply_all_recipients(
inbound_from: str,
inbound_to: list[str],
inbound_cc: list[str],
agent_address: str
) -> dict:
# To: the original sender
to = [inbound_from]
# Cc: original To + Cc, minus the agent itself
cc_candidates = inbound_to + inbound_cc
cc = [
addr for addr in cc_candidates
if addr.lower() != agent_address.lower()
]
return {"to": to, "cc": cc}
This prevents the agent from accidentally including itself in Cc, which creates reply storms, and preserves the conversation participants humans expect to see.
A complete reply construction example
Putting it all together in Python using the standard library:
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, formataddr
import uuid, datetime
def compose_agent_reply(
inbound: dict, # parsed inbound message fields
agent_response: str, # LLM-generated response text
sending_domain: str
) -> str:
msg = MIMEMultipart("alternative")
# Threading headers
ts = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
msg["Message-ID"] = f"<{ts}.{uuid.uuid4().hex[:12]}@{sending_domain}>"
msg["In-Reply-To"] = inbound["message_id"]
msg["References"] = build_references(
inbound.get("references"),
inbound["message_id"]
)
# Addressing
msg["From"] = formataddr(("Support Agent", f"agent@{sending_domain}"))
msg["To"] = inbound["from"]
msg["Date"] = formatdate(localtime=False)
msg["Subject"] = build_reply_subject(inbound["subject"])
# Body parts
quoted = quote_text(
inbound["body_plain"],
inbound["from"],
inbound["date"]
)
plain = f"{agent_response}\n\n{quoted}"
html = f"<div>{agent_response.replace(chr(10), '<br>')}</div>" \
f"<blockquote style='border-left:2px solid #ccc;padding-left:8px'>" \
f"{inbound['body_html']}</blockquote>"
msg.attach(MIMEText(plain, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
return msg.as_string()
This produces a message string you can hand directly to an SMTP client or a sending API. The Mails.ai API accepts raw MIME if you need full control over message structure while still handling authentication and deliverability.
Authentication alignment
None of this threading work matters if the message fails DMARC. Your From header domain must align with your SPF and DKIM configuration. For replies specifically:
- DKIM: The
d=tag in your signature must match (or be an organizational domain of) yourFromaddress. Sign with a key registered under your sending domain. - SPF: The envelope sender (
MAIL FROM) must match a domain that has an SPF record authorizing your sending IP. - DMARC: At least one of DKIM or SPF must achieve alignment with the
Fromheader domain.
For agents sending at volume, sender reputation is a cumulative property. Threading errors don't directly cause DMARC failures, but they signal automated sending patterns that aggressive filters weight negatively alongside authentication signals.
flowchart LR
A[Inbound Message] --> B[Extract Headers]
B --> C[Message-ID]
B --> D[References]
B --> E[From and Subject]
C --> F[Build In-Reply-To]
D --> G[Build References Chain]
E --> H[Build To and Subject]
F --> I[Compose MIME Reply]
G --> I
H --> I
I --> J[Sign with DKIM]
J --> K[Send via SMTP]
Frequently Asked Questions
What happens if I omit the References header?
Gmail and most clients will treat the reply as a new conversation thread. Recipients see it as a separate email rather than a continuation of the original exchange. For support or transactional workflows, this breaks the conversation view and makes human handoff much harder. Always populate References from the inbound message's headers.
How do I get the original Message-ID from an inbound webhook?
Most inbound parsing services expose raw headers alongside the parsed body. Look for a headers object in the webhook payload containing message-id (lowercase). If your provider strips raw headers, you need a different provider — Message-ID is non-negotiable for threading. Inbound email parsing that surfaces full headers is a hard requirement for reply-capable agents.
Should the agent's reply use the same Message-ID as the original?
No. Every message must have its own unique Message-ID. The original's Message-ID goes into In-Reply-To and References. Reusing IDs causes undefined behavior in mail clients and can cause deduplication errors at the MTA level.
How do I handle HTML in the original message when quoting?
Don't nest raw HTML from the original directly into your reply's HTML part without sanitizing it first. Use a library like bleach (Python) or DOMPurify (JS) to strip dangerous tags and attributes. Alternatively, convert the inbound HTML to plain text for the quote and only use <blockquote> wrapping around the sanitized version.
Does the order of MIME parts matter?
Yes. For multipart/alternative, clients render the last part they can handle. Put text/plain first, text/html second. This way, clients that support HTML render the richer version; plain-text clients fall back correctly. Reversing the order causes HTML-capable clients to display raw plain text.
Can an AI agent manage the full reply loop without human review?
Technically yes, but the right answer depends on the failure modes you've designed for. The RFC compliance layer discussed here is necessary but not sufficient — you also need confidence thresholds, escalation paths, and audit logging on what the model generated and sent. The RFC layer handles the envelope; your application logic handles whether it should be sent at all.