Inbound email parsing for AI agents is more than splitting a raw message at Content-Type boundaries. The full pipeline runs from SMTP reception through MIME decoding, text extraction, structured event construction, webhook delivery with replay protection, and — for first-contact messages — intent classification. Each layer has real failure modes that break agent workflows if you don't account for them at design time.
This post walks the entire stack mechanically, so you understand what your infrastructure is actually doing when an email arrives.
What happens between SMTP and your webhook
Before your agent handler ever sees a message.received event, the infrastructure has already done substantial work. Understanding each stage tells you where data loss can happen and why some fields are missing or malformed in production.
Stage 1: SMTP reception and envelope capture
The receiving MTA accepts the message over SMTP and records the envelope — MAIL FROM and RCPT TO — before it ever reads the DATA section. The envelope addresses are separate from the From: and To: headers inside the message body. Most parsing bugs trace back to conflating them.
The envelope MAIL FROM is what SPF checks. The header From: is what users see in their mail client. An agent that trusts the header From: for sender identity without cross-checking the envelope is making a security mistake.
After the DATA command, the MTA receives the raw RFC 5322 message: headers, then a blank line, then the body. The entire thing is stored verbatim before any processing happens.
Stage 2: MIME parsing
RFC 2822 messages can nest arbitrarily. A typical multipart message looks like:
Content-Type: multipart/mixed; boundary="outer"
--outer
Content-Type: multipart/alternative; boundary="inner"
--inner
Content-Type: text/plain; charset=utf-8
--inner
Content-Type: text/html; charset=utf-8
--inner--
--outer
Content-Type: application/pdf; name="report.pdf"
Content-Transfer-Encoding: base64
[base64 data]
--outer--
A correct MIME parser must handle multipart/alternative (same content in multiple formats — pick the best), multipart/mixed (different attachments, process all), multipart/related (HTML with inline images referenced by Content-ID), and message/rfc822 (forwarded messages as attachments). Parsers that only look one level deep silently drop content.
Charset handling is another common failure point. Headers are ASCII; bodies can be anything. The charset parameter on text/plain or text/html tells you the encoding, but malformed messages omit it or lie about it. A solid parser tries the declared charset, falls back to UTF-8, then Latin-1.
Stage 3: Content extraction
For LLM-driven agents, the key output from MIME parsing is extracted_text — a clean plaintext representation of the message body. Getting this right requires:
- Preferring
text/plainovertext/htmlinmultipart/alternative. HTML contains the same content plus noise (style tags, tracking pixels,<div>soup). - Stripping quoted reply history when you want just the new content. The
>prefix convention is standard but not universal; some clients useOn [date], [person] wrote:patterns. - Respecting
Content-Transfer-Encoding:quoted-printableandbase64both need decoding before the text is human-readable.
If you're feeding extracted text to an LLM, also strip HTML entities ( , &, etc.) and collapse whitespace. A message that arrives as 3,200 bytes of HTML often reduces to 280 bytes of actual content.
Stage 4: Threading header extraction
Email threading uses three headers: Message-ID, In-Reply-To, and References.
Message-IDis assigned by the sender's MTA. Format:<unique-id@domain>. It's the primary key of a message.In-Reply-Tocontains theMessage-IDof the message being replied to. Set by the replying client.Referencescontains the full chain ofMessage-IDvalues in the thread, space-separated, oldest first.
To correctly thread a reply to an existing conversation, your outbound system must set both In-Reply-To (the immediate parent) and References (the full chain). Missing either causes Gmail, Outlook, and most email clients to break the thread into separate conversations.
For agent email specifically, this means storing the original Message-ID when you first send a message, then referencing it in every subsequent reply in that thread.
sequenceDiagram
participant S as SMTP Server
participant P as MIME Parser
participant E as Event Builder
participant W as Webhook Dispatcher
participant A as Agent Handler
S->>P: Raw RFC5322 message
P->>P: Decode MIME parts
P->>P: Extract text and attachments
P->>E: Structured message data
E->>E: Resolve thread via References
E->>E: Run injection scan
E->>W: POST with HMAC signature
W->>A: Signed webhook event
A->>A: Verify signature then act
Webhook delivery: the protocol details
Once the parsing pipeline produces a structured event, it needs to reach your agent handler. Webhooks are the standard mechanism, but the details matter for reliability and security.
Event schema shape
A message.received event carries the parsed message data in a predictable structure. Understanding the field layout lets you write handler code that doesn't break when optional fields are absent:
import { mails } from "@mailsai/sdk";
const hello = mails.agent("hello");
hello.onMessage(async (event) => {
// Top-level event fields
const { id, type, agent_id, thread_id, injection_score, quarantined } = event;
// Message data is inside event.data
const sender = event.data.from.address;
const subject = event.data.subject;
const text = event.data.extracted_text;
// Classification fields — present on first-contact messages only
const { intent, entities, urgency } = event;
if (quarantined) {
// injection_score >= 0.95 — log and skip rather than act
console.warn(`Quarantined message from ${sender}, score: ${injection_score}`);
return;
}
// Act on intent
if (intent === "schedule_demo") {
await bookDemo(sender, entities);
}
});
The quarantined flag deserves attention. Prompt injection via email is a real attack vector — an adversary sends an email whose body contains instructions designed to hijack the agent's subsequent actions. The injection_score is a 0–1 float; a score of 0.95 or above sets quarantined: true. The message still arrives at your handler. Your code decides what to do with it. The safe default is to skip execution and log it for human review.
Signature verification
Webhooks without signature verification are unauthenticated HTTP endpoints. Anyone who knows your handler URL can POST fake events.
The standard pattern uses HMAC-SHA256: the provider signs the raw request body with a shared secret and sends the signature in a header. Your handler recomputes the signature and rejects any request where they don't match.
import { mails } from "@mailsai/sdk";
import type { Request, Response } from "express";
export async function webhookHandler(req: Request, res: Response) {
const signature = req.headers["x-mails-signature"] as string;
const secret = process.env.WEBHOOK_SECRET!;
// Pass the RAW body bytes, not parsed JSON
const event = mails.webhooks.verify(req.body, signature, secret);
if (!event) {
res.status(401).send("Invalid signature");
return;
}
// Respond 200 immediately — do async work after
res.status(200).send("ok");
await processEvent(event);
}
Two implementation details that bite people:
- Pass raw bytes to the verification function, not the JSON-parsed body. Express's
express.json()middleware re-serializes the body, which changes whitespace and key ordering, breaking the HMAC comparison. Useexpress.raw({ type: "application/json" })for webhook routes. - Respond 200 before doing async work. Webhook dispatchers have timeouts (commonly 5–30 seconds). If your handler times out, the dispatcher retries, and you process the same event multiple times. Acknowledge immediately, then process.
Retry semantics and idempotency
Webhook delivery is at-least-once by design. Network failures, handler timeouts, and 5xx responses all trigger retries. Your handler will receive duplicates.
Protect against this with idempotency keying on the event id field:
async function processEvent(event: TypedEvent) {
const alreadyProcessed = await db.events.exists(event.id);
if (alreadyProcessed) return;
await db.events.insert({ id: event.id, processed_at: new Date() });
// ... actual business logic
}
The id field is stable across retries of the same event. Store it in a processed-events table with a unique constraint, and duplicate deliveries become no-ops.
Classification: first-contact only
Not every inbound email needs the same processing path. A message asking to schedule a demo requires different agent behavior than a general follow-up or an unsubscribe request. Classification maps the message content to an intent label so your routing logic doesn't need to run an LLM call itself.
The important constraint: classification applies only to first-contact messages — the message.received event for emails that initiate a new thread. Replies in an existing thread arrive as reply.received events and carry intent: general_reply unconditionally. The rationale is sound: by the time a thread is ongoing, the agent already has context; re-classifying every reply creates noise and false routing.
Classification on Mails.ai's inbound parsing is opt-in per agent (on Pro and Scale plans) and adds structured fields to the event:
intent: one ofschedule_demo,ask_question,decline,schedule_meeting,request_info,follow_upentities: extracted structured data from the message — names, dates, organizations, amountsurgency: a 1–10 integer signaling how time-sensitive the message appears
These fields let you write routing logic without an LLM in the critical path:
hello.onMessage(async (event) => {
if (quarantined) return;
switch (event.intent) {
case "schedule_demo":
await routeToCalendly(event.data.from.address, event.entities);
break;
case "ask_question":
if (event.urgency >= 8) {
await alertHumanSupport(event);
} else {
await generateAndSendAnswer(event);
}
break;
case "decline":
await markLeadDeclined(event.data.from.address);
break;
default:
await queueForReview(event);
}
});
This keeps latency low for common intents while reserving LLM calls for messages that genuinely need reasoning.
Structuring your handler for production
A production inbound handler needs more than just the happy path. Here's the full structure:
import { mails, type TypedEvent } from "@mailsai/sdk";
const hello = mails.agent("hello");
hello.onMessage(async (event) => {
// 1. Injection check
if (event.quarantined) {
await logSecurity(event);
return;
}
// 2. Idempotency
if (await db.events.exists(event.id)) return;
await db.events.insert({ id: event.id });
// 3. Route by intent
await dispatch(event);
});
hello.onReply(async (event) => {
// Replies carry intent: general_reply — route on thread context instead
const thread = await db.threads.get(event.thread_id);
await continueConversation(thread, event.data.extracted_text);
});
async function dispatch(event: TypedEvent) {
const { intent, urgency, entities } = event;
const from = event.data.from.address;
const text = event.data.extracted_text;
// Route without LLM for known intents
if (intent === "schedule_demo" || intent === "schedule_meeting") {
return handleSchedulingRequest(from, entities, text);
}
// Fall through to LLM reasoning for ambiguous cases
return handleWithLLM(event);
}
For email classification and routing at scale, separate the onMessage handler from the business logic entirely. The handler's only job is idempotency, injection filtering, and dispatching to a queue. Everything downstream runs asynchronously.
Common parsing failure modes
Empty extracted_text: The message contains only HTML with no text/plain alternative, and the HTML parser fails or is too conservative. Fix: always implement an HTML-to-text fallback that at minimum strips tags.
Thread ID mismatch on replies: Your agent sends a reply but the responding mail client ignores In-Reply-To and starts a new thread. You get a message.received instead of reply.received. Fix: key your thread lookup on the References header chain, not just the event type.
Duplicate events from retry storms: Your handler returns 500 due to a downstream database timeout, triggering dozens of retries before you fix the db issue. Fix: idempotency table with a unique constraint on event.id. Inserts after the first fail fast with a constraint violation — catch it and return 200.
Attachment metadata without content: Some parsing pipelines extract attachment metadata (filename, MIME type, size) but don't deliver the binary content inline — you have to fetch it separately via a signed URL. If your agent needs attachment content synchronously in the handler, verify your provider's behavior before building on that assumption. See the full guide on parsing email attachments for LLM ingestion for the byte-level mechanics.
Charset mojibake: A message declares charset=iso-8859-1 but the actual content is Windows-1252 (a common confusion). Japanese and Korean messages have similar issues with JIS variants. The symptom is garbled text with < replacement characters. Fix: use a charset detection library as fallback when declared encoding produces replacement characters.
Frequently Asked Questions
What's the difference between the envelope From and the header From?
The envelope MAIL FROM is the address given to the SMTP server in the MAIL FROM: command — it's what SPF validates and what bounce notifications go to. The header From: is what appears in the message body and what email clients display to users. An attacker can send a message with a legitimate-looking header From: while using their own domain in the envelope, bypassing the visual check while still failing SPF. Always authenticate using the envelope address and SPF/DKIM results, not the header From: alone.
Why does classification only apply to first-contact messages?
Because intent classification on a reply is rarely actionable. By the time a reply arrives, the agent already has thread context — it knows who the person is, what was previously discussed, and what state the conversation is in. Re-classifying the reply produces noisy labels that conflict with thread context. The general_reply intent signals your handler to use thread state for routing rather than the message content in isolation.
How should I handle messages where extracted_text is very short or empty?
Three causes: the message is image-only (a scanned document), the HTML-to-text extraction failed, or the message intentionally has no text (e.g., a read-receipt). For image-only messages, you need OCR in the pipeline — most inbound parsing services don't do this by default. For extraction failures, store the raw HTML as a fallback and run your own stripping logic. For empty messages that aren't read receipts, treat them as unprocessable and route to a human review queue rather than letting your agent attempt to reason about nothing.
What's the right way to verify a webhook signature without breaking it?
Three common mistakes break HMAC verification: (1) using a JSON-parsed body instead of raw bytes — JSON serialization changes whitespace; (2) comparing the computed signature to the header value using === instead of a constant-time comparison — timing attacks on string comparison are real; (3) not checking a timestamp header if one is provided — without it, replayed requests from days ago will still pass signature verification. The correct order is: capture raw bytes before any middleware touches the body, verify HMAC with constant-time comparison, check timestamp is within your acceptable window (±5 minutes is standard).
Can an agent safely act on every inbound message, or does it need filtering?
No agent should act on every inbound message without filtering. The minimum checks are: (1) injection scan — skip or log quarantined messages; (2) idempotency — don't process the same event twice due to webhook retries; (3) sender validation — verify the envelope sender domain against your expected senders if the agent operates in a closed system. Mails.ai's email infrastructure for agents handles the injection scan at the platform level, but idempotency and sender validation belong in your handler code.
How do Message-ID, In-Reply-To, and References interact for threading?
Message-ID uniquely identifies a single message. In-Reply-To contains the Message-ID of the direct parent. References contains the full ancestry chain (oldest first), including In-Reply-To. To correctly thread a reply, set both In-Reply-To (parent's ID) and References (append parent's ID to existing References chain). Mail clients use References for grouping and In-Reply-To for the parent pointer. Setting only one causes inconsistent threading across Gmail, Outlook, and Apple Mail. Calling a proper reply method (like client.messages.reply(messageId, { body_text })) handles this automatically by reading the original message's headers and constructing both fields correctly.
Related guides: Email OAuth Token Management for AI Agents, Model Context Protocol for Email Tools: Dev Guide, Transactional Email APIs With Inbound Parsing: Dev Guide and Agent Email Address: Why Every AI Agent Needs One.
Explore the product: Inbound email API, Sender reputation, Email classification, All features and Pricing.
