All posts
Architecture··9 min read·Mails.ai Team

RFC-Compliant Email Replies from AI Agents

RFC-Compliant Email Replies from AI Agents

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:

  1. Take the References header from the message you're replying to.
  2. Append that message's Message-ID.
  3. 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) your From address. 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 From header 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.

Closed beta

Built for agents.
Self-serve in minutes.

Public API opens Q3 2026. Drop ~6 lines into your agent and ship.

npmpnpmbunpip
$ npm install @mailsai/sdk
Packages publish with cohort 1 · Q3 2026