
Most guides on AI agents and email focus on sending — templating, deliverability, SPF/DKIM. The inbound side gets less attention, but it's architecturally more complex. Receiving mail means operating an MX record, accepting SMTP connections, parsing raw MIME, and reliably triggering agent logic — all without losing a message or processing one twice.
This post covers the full stack: DNS configuration, the SMTP-to-HTTP translation layer, MIME parsing, and the webhook contract your agent code actually consumes.
The inbound email stack, layer by layer
An agent inbox isn't a single component — it's a pipeline with four distinct layers: DNS, SMTP reception, parsing, and delivery to your code. Each layer can fail independently, and each has quirks that bite you in production.
Layer 1: MX records and DNS routing
When someone sends email to agent@yourdomain.com, their mail server performs an MX lookup on yourdomain.com. The MX record returns one or more hostnames, each with a priority value. Lower numbers win. The sending server opens an SMTP connection to the highest-priority MX host and delivers the message.
If you're routing a subdomain (e.g., inbox.yourdomain.com) to an inbound parsing service, your DNS looks like:
inbox.yourdomain.com. 300 IN MX 10 inbound.yourprovider.com.
The TTL of 300 seconds is deliberate — short enough to cut over quickly if you need to change providers, long enough to avoid hammering resolvers. For production systems, 3600 is more typical once you've validated routing.
One critical detail: if you're using a catch-all address (*@inbox.yourdomain.com), the MX record covers the whole subdomain. Any unrecognized address gets routed to your parser. That's powerful for multi-agent setups where each agent gets a unique address dynamically, but it means you need address validation logic in your webhook handler — never assume the To field is an address you issued.
Layer 2: SMTP reception and spam filtering
The inbound parsing service accepts the SMTP connection, performs basic validation (HELO/EHLO, MAIL FROM, RCPT TO), and receives the raw message body. Most services apply spam scoring at this layer using tools like SpamAssassin or proprietary classifiers. Messages that score above a threshold are either rejected (SMTP 550) or flagged in the webhook payload.
One mechanism worth understanding: sender authentication checks. When the parsing service receives a message, it can verify:
- SPF: Does the sending IP appear in the SPF record of the envelope sender's domain? A
passincreases trust. - DKIM: Is there a valid DKIM signature over the message headers and body? Checking
DKIM-Signatureheaders against the domain's DNS TXT records confirms the message wasn't tampered with in transit. - DMARC: Does the From header domain align with the SPF or DKIM result? A
passmeans the From address genuinely represents the sending domain.
Good inbound parsers surface these as structured fields in the webhook payload (spf: "pass", dkim: "pass", dmarc: "pass"). Your agent should check these signals before acting on message content, especially if it takes consequential actions like executing commands, making API calls, or moving money.
Layer 3: MIME parsing
Raw email is a MIME document. The structure can be flat or deeply nested, and the same semantic content might appear in multiple representations.
A minimal plaintext email has a single body part. Most real email, especially from other agents or automated systems, is multipart/alternative, containing both text/plain and text/html. Emails with attachments are multipart/mixed, with each attachment as a separate MIME part carrying a Content-Disposition: attachment header and a Content-Transfer-Encoding (usually base64).
Here's what a minimal MIME structure looks like in raw form:
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
This is the plain text body.
--boundary_abc123
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: base64
PGh0bWw+PHBvZHk+VGhpcyBpcyB0aGUgSFRNTCBib2R5LjwvcD48L2JvZHk+PC9odG1sPg==
--boundary_abc123--
A parser needs to recursively walk MIME parts, decode each body (quoted-printable or base64), identify the preferred text representation, and extract attachments into separate payloads. For agent use, you almost always want the text/plain part — it's easier to pass to an LLM without injecting HTML structure. If there's no plain part (some calendar invites, for example), strip the HTML tags before passing to the model.
Thread context lives in headers, not body content:
Message-ID: <unique-id@domain>— the canonical identifier for this specific messageIn-Reply-To: <parent-message-id@domain>— the Message-ID of the message being replied toReferences: <grandparent-id@domain> <parent-id@domain>— the full ancestor chain
If your agent needs to understand conversation context — which agent sent what, and when — it must index Message-ID and resolve In-Reply-To against that index. Don't rely on subject-line threading; it breaks the moment someone changes the subject.
Layer 4: Webhook delivery to your agent
This is where the inbound pipeline meets your code. The parsing service POSTs a JSON payload to a URL you configure. Your agent lives behind that URL.
A well-designed inbound parsing webhook payload looks roughly like this:
{
"event": "inbound.received",
"message_id": "<abc123@mail.example.com>",
"timestamp": "2025-09-15T14:32:01Z",
"envelope": {
"from": "sender@external.com",
"to": ["agent@inbox.yourdomain.com"]
},
"headers": {
"from": "Alice <sender@external.com>",
"to": "agent@inbox.yourdomain.com",
"subject": "Re: Order #8821 status",
"message_id": "<abc123@mail.example.com>",
"in_reply_to": "<xyz789@yourdomain.com>",
"references": ["<def456@yourdomain.com>", "<xyz789@yourdomain.com>"]
},
"authentication": {
"spf": "pass",
"dkim": "pass",
"dmarc": "pass"
},
"body": {
"plain": "Can you check the shipping status for order 8821?",
"html": "<p>Can you check the shipping status for order 8821?</p>"
},
"attachments": []
}
Key fields to validate in your handler:
message_id— use this as your idempotency key. Inbound parsers can deliver the same message more than once (network retries, temporary 5xx from your endpoint). Store processedmessage_idvalues and skip duplicates.envelope.to— verify this is an address your system issued. Reject anything unexpected.authentication— check SPF/DKIM/DMARC before acting on content.timestamp— if your agent processes asynchronously, check whether the message is stale before acting.
Designing the webhook handler
Your webhook handler has one job: acknowledge receipt fast, then process asynchronously.
from fastapi import FastAPI, Request, BackgroundTasks
from redis import Redis
import json
app = FastAPI()
redis = Redis(host="localhost", port=6379, db=0)
@app.post("/webhooks/inbound")
async def handle_inbound(request: Request, background_tasks: BackgroundTasks):
payload = await request.json()
message_id = payload.get("message_id")
# Idempotency check
key = f"processed:{message_id}"
if redis.exists(key):
return {"status": "duplicate", "message_id": message_id}
# Mark as received (TTL: 7 days)
redis.setex(key, 604800, "1")
# Enqueue for agent processing
background_tasks.add_task(enqueue_for_agent, payload)
return {"status": "accepted", "message_id": message_id}
async def enqueue_for_agent(payload: dict):
# Push to your task queue (Celery, RQ, SQS, etc.)
redis.lpush("agent:inbound", json.dumps(payload))
Return 200 immediately. If your handler does synchronous LLM calls inside the HTTP request cycle, you will hit timeout issues and the parsing service will retry — causing your agent to process the same message multiple times.
The idempotency pattern here uses Redis with a 7-day TTL on the message_id. Adjust based on your retry window. Some services retry for up to 72 hours, so size your TTL accordingly.
Routing inbound email to the right agent
If you have multiple agents — one for support, one for billing, one for scheduling — you need a routing layer between the webhook handler and agent dispatch.
Three common patterns:
Address-based routing: Issue each agent a unique address (support@inbox.yourdomain.com, billing@inbox.yourdomain.com). Route based on envelope.to. Simple, explicit, but requires pre-provisioning addresses.
Subject/keyword routing: Parse the subject line or body for signals. Fragile — senders don't follow conventions. Use only as a fallback.
Classification-based routing: Run a fast classifier on the message body to determine intent, then dispatch. More flexible but adds latency. This is where an email classification layer adds real value, especially when the set of possible intents is large and evolving.
For most production systems, address-based routing handles 80% of cases. Add classification for the remainder.
def route_message(payload: dict) -> str:
to_addresses = payload["envelope"]["to"]
routing_table = {
"support@inbox.yourdomain.com": "support-agent",
"billing@inbox.yourdomain.com": "billing-agent",
"schedule@inbox.yourdomain.com": "scheduling-agent",
}
for addr in to_addresses:
if addr in routing_table:
return routing_table[addr]
# Fall back to classification
return classify_by_content(payload["body"]["plain"])
What your agent receives: a clean payload contract
The goal of the entire inbound stack is to hand your agent something it can reason about without wrestling with MIME or SMTP. Here's a complete flow:
sequenceDiagram
participant S as Sender MTA
participant MX as MX Host and Parser
participant WH as Webhook Handler
participant Q as Task Queue
participant A as Agent
S->>MX: SMTP MAIL FROM RCPT TO DATA
MX->>MX: Parse MIME validate SPF DKIM DMARC
MX->>WH: POST JSON payload
WH->>WH: Idempotency check
WH-->>MX: 200 accepted
WH->>Q: Enqueue payload
Q->>A: Dequeue and process
A->>A: Extract intent and act
By the time the agent receives the payload, it should have:
- A clean plaintext body (no HTML, no MIME boundary markers)
- Structured metadata (sender, subject, thread IDs)
- Authentication results it can trust
- A unique, stable
message_idfor correlation
Mails.ai's inbound parsing is built around this exact contract — the webhook payload is normalized so agent code receives structured fields rather than raw RFC 5322 content.
Handling edge cases in production
Empty bodies: Some automated senders send emails with only an HTML part. Your parser should strip tags and return at minimum an empty string, never null. Null body fields cause agent crashes at the worst time.
Oversized messages: Email has no practical size limit at the protocol level, though most MTAs enforce limits of 25-50MB. Attachments can push messages well past what you want to load into memory synchronously. Stream large attachments to object storage and pass the URL to your agent instead of the raw bytes.
Encoding issues: Legacy systems send latin-1 or windows-1252 encoded bodies without declaring the charset, or they declare one charset and use another. Detect encoding with a library like chardet before passing to your LLM — garbled text produces garbled reasoning.
Webhook failures: If your endpoint returns a 5xx, the parsing service will retry. That's correct behavior, but it means your idempotency check must happen before any side effects, not after. The pattern above handles this: check the Redis key before enqueuing.
Security considerations
An agent inbox is an unauthenticated input channel. Anyone can email your agent. Build defenses in layers:
Authenticate the webhook itself: Use HMAC signature verification on the webhook payload. The parsing service should sign each request with a secret you share at configuration time. Reject any request that doesn't verify.
Validate sender authentication: Treat SPF/DKIM/DMARC failures as a signal, not a blocker. Legitimate mail sometimes fails SPF (forwarded messages). But a DMARC failure on a message claiming to be from your own domain is a red flag.
Sanitize before passing to the LLM: Don't pass raw HTML to your model. Strip it. Prompt injection via email body is a real attack vector — a malicious sender can craft a message body designed to override your agent's system prompt.
Rate limit by sender: An agent inbox with no rate limiting can be DoS'd by a single sender flooding it with messages. Track message counts by envelope sender and apply per-sender limits.
For deeper guidance on sender reputation and authentication infrastructure, the Mails.ai architecture overview covers how authentication checks integrate with the broader email pipeline.
Frequently Asked Questions
What's the difference between the envelope sender and the From header?
The envelope sender (MAIL FROM in SMTP) is used for bounces and SPF checking. The From header is what recipients see in their mail client. They're often the same, but not always — mailing lists and forwarding services routinely differ. Always check both. For DMARC, alignment is between the From header domain and the SPF/DKIM domain.
Can one webhook endpoint handle email for multiple agents?
Yes, and it's the recommended pattern. A single endpoint receives all inbound mail, applies your routing logic, and dispatches to the appropriate agent worker. This keeps your DNS configuration simple (one MX record per domain) while supporting arbitrary agent topologies behind it.
How do I handle replies vs. new threads?
Check the In-Reply-To header. If it contains a Message-ID you previously sent, it's a reply to an existing thread. Look up the thread context using that ID and pass it to your agent along with the new message. If In-Reply-To is absent or doesn't match any known ID, treat it as a new conversation.
What's a safe TTL for my idempotency store?
Match your retry window plus a safety margin. If your inbound parser retries for up to 72 hours, use a 7-day TTL (604800 seconds in Redis SETEX). Shorter TTLs risk processing duplicates after the store entry expires but before retries stop.
Do I need to parse the raw MIME myself?
Not if you're using an inbound parsing service — they handle MIME parsing and give you structured JSON. If you're receiving raw SMTP directly (running your own MTA), you'll need a MIME library. In Python, email.parser.BytesParser from the standard library is solid. In Node.js, mailparser from Nodemailer handles most cases.
How do I provision a unique email address per user or session dynamically?
Use a catch-all address pattern: configure your MX to route all mail for inbox.yourdomain.com to your parser, then generate addresses like session-{uuid}@inbox.yourdomain.com at runtime. Your webhook handler extracts the UUID from the To field and looks up the associated agent session. No DNS changes needed per address — just MX at the subdomain level.