Transactional email APIs with inbound parsing cover two distinct infrastructure problems that most providers solve unevenly. Outbound is well-understood: authenticate your domain, call an API, track delivery. Inbound is messier — raw SMTP traffic, multipart MIME, header chains you don't control, and webhooks that arrive out of order. This guide covers both halves in depth: the mechanisms, the failure modes, and how to build systems that handle them reliably.
How inbound email parsing actually works
When a message arrives at a provider's inbound pipeline, it goes through roughly five stages before your webhook fires: MX resolution, SMTP reception, spam/auth checks, MIME parsing, and HTTP delivery. Understanding each stage explains most of the bugs you'll hit.
MX resolution. Your domain needs an MX record pointing to the provider's inbound servers. For a custom domain like mail.yourdomain.com, that's a single DNS change. The provider's MTAs then accept SMTP connections on port 25 for that domain.
SMTP reception. The receiving MTA accepts the DATA stream and writes the raw RFC 5322 message — headers plus body — to a queue. At this point you have the full wire format, including all Received headers tracing the hop path.
Authentication checks. The MTA validates SPF (did this IP have permission to send for the From domain?), DKIM (is the signature over the headers valid?), and DMARC (does the policy allow this alignment?). These results get added as Authentication-Results headers on the incoming message. Your webhook payload should include them — if your provider doesn't expose them, you're blind to spoofing attempts.
MIME parsing. Most real-world email is multipart/mixed or multipart/alternative. The parser walks the MIME tree, extracts text/plain and text/html parts, identifies Content-Type: application/octet-stream attachments, and decodes base64 or quoted-printable content. Headers like Content-Disposition: attachment; filename="report.pdf" tell the parser what to do with each part.
HTTP delivery. The parsed payload gets POSTed to your webhook URL. This is where ordering breaks down — if your handler returns a 5xx, most providers retry with exponential backoff, so the same message can arrive multiple times. Your handler must be idempotent.
The webhook payload structure
Webhook payloads vary by provider, but the useful fields cluster around a few groups. Here's a normalized view of what you need and where it comes from:
| Field | Source | Notes |
|---|---|---|
message_id |
Message-ID header |
RFC 5322; globally unique, angle-bracket wrapped |
in_reply_to |
In-Reply-To header |
Single Message-ID; presence means it's a reply |
references |
References header |
Space-separated chain of all prior Message-IDs in thread |
from |
From header |
May differ from SMTP envelope sender (Return-Path) |
to, cc |
To, Cc headers |
Display name + address pairs |
subject |
Subject header |
May be encoded: =?UTF-8?B?...?= |
body_text |
text/plain MIME part |
Decoded, usually quoted-reply-stripped |
body_html |
text/html MIME part |
May contain tracking pixels |
attachments |
MIME attachments | Filename, content-type, size, sometimes a URL |
spf, dkim, dmarc |
Authentication-Results |
pass/fail/none; critical for security |
timestamp |
SMTP Date header or receipt time |
Use receipt time for ordering; Date is sender-controlled |
The Message-ID, In-Reply-To, and References headers are the threading primitives. Gmail's threading algorithm, documented in its developer docs, uses these headers to group messages. If your outbound emails don't set Message-ID, or your replies don't set In-Reply-To and References, clients will fragment the conversation into separate threads.
Webhook verification
Any URL that accepts POST requests from the public internet is a target. Providers sign their webhook payloads so you can reject forgeries before processing them.
The standard pattern is HMAC-SHA256 over the raw request body:
import { createHmac } from "crypto";
function verifyWebhook(
rawBody: Buffer,
signatureHeader: string,
secret: string
): boolean {
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// Use timing-safe comparison to prevent timing attacks
return timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signatureHeader, "hex")
);
}
Two things to get right here: use timingSafeEqual (not ===) to prevent timing oracle attacks, and compute the HMAC over the raw bytes before any JSON parsing. If you parse first, whitespace normalization can change the byte sequence and break verification.
For Mails.ai's inbound pipeline, the signature arrives in the X-Mails-Signature header and the SDK handles verification:
import { mails } from "@mailsai/sdk";
const event = mails.webhooks.verify(rawBody, req.headers["x-mails-signature"], secret);
if (!event) return res.status(401).end();
This returns the typed event object or null on failure — no exception to catch.
Threading: Message-ID chains
Getting threading right is the most commonly skipped part of an inbound integration. When your system sends email and later receives a reply, you need to know which sent message the reply belongs to.
The protocol-level answer is the References header. When you send a message, your MTA generates a Message-ID like <abc123@yourdomain.com>. When someone replies, their client sets:
In-Reply-To: <abc123@yourdomain.com>
References: <abc123@yourdomain.com>
If the thread continues, References grows: each reply appends the new Message-ID to the chain. The full chain in References lets you reconstruct the conversation tree without a database lookup.
In practice, your system needs to:
- Store the
Message-IDof every outbound email against the domain entity it relates to (order, ticket, user session). - On inbound, extract
In-Reply-To, look it up in your store, and route accordingly. - Fall back to subject-line matching only as a last resort — it's unreliable.
// On send: store the Message-ID
const result = await hello.send({
to: "user@example.com",
subject: "Your order #1042 shipped",
body_text: "It's on the way."
});
await db.orders.update(orderId, { last_message_id: result.message_id });
// On receive: resolve the thread
const inReplyTo = event.data.headers["in-reply-to"];
const order = await db.orders.findBy({ last_message_id: inReplyTo });
if (order) {
await routeToOrderHandler(order, event);
}
The reply endpoint on most modern email APIs handles the threading headers for you — it sets In-Reply-To and References based on the original message ID, so you don't have to manage header construction manually.
Routing inbound email to the right handler
Once you've parsed and threaded the message, you need to decide what to do with it. Three common routing patterns:
flowchart LR
A[Inbound SMTP] --> B[MX Record]
B --> C[Provider Inbound Pipeline]
C --> D[Webhook POST]
D --> E[Verify Signature]
E --> F[Extract Headers]
F --> G{Routing Logic}
G --> H[Thread Match via In-Reply-To]
G --> I[Address Match via recipient plus]
G --> J[Classifier]
H --> K[Order Handler]
I --> L[Ticket Handler]
J --> M[Intent Router]
Address-based routing uses the recipient address to encode context. The + subaddress extension (RFC 5233) is the standard mechanism: support+ticket-4819@yourdomain.com routes to ticket 4819 without any lookup. The local part before + is your base address; everything after is your routing key. Most MTAs preserve subaddresses through the SMTP envelope.
Thread-based routing uses the In-Reply-To / References chain described above. This works when your system sent the original message and stored its Message-ID.
Classification-based routing applies when you can't use the other two — the message is a first contact with no prior context. A classifier reads the subject and body and assigns an intent: schedule_demo, ask_question, request_info, etc. The key constraint: classification is only meaningful on first-contact messages. Replies in an existing thread already have context; classifying them again is redundant and can mislead the handler.
Handling MIME complexity
Real email is rarely clean text/plain. You'll encounter:
Multipart/alternative: text/plain and text/html versions of the same content. Always prefer text/plain for processing — HTML requires sanitization before you pass it to an LLM or store it.
Quoted replies: Most clients append the original message below a > quote block. For agent use cases, strip this before processing. The standard heuristic is to split on \n> or \n\nOn [date], [sender] wrote: patterns. Libraries like email-reply-parser (Node) or Python's mailparser handle most cases.
Encoded subjects: Non-ASCII subjects are encoded as =?charset?encoding?encoded_text?=. A subject like =?UTF-8?B?UmU6IFRlc3Q=?= decodes to Re: Test. Your parser must handle both B (base64) and Q (quoted-printable) encoding.
Attachment size: Inbound parsers typically inline small attachments as base64 in the webhook body and provide URLs for large ones. Check your provider's size threshold — it's usually somewhere between 1MB and 25MB per attachment.
Idempotency in inbound processing
Webhook delivery is at-least-once. Any provider retry strategy means your handler can see the same Message-ID more than once. The fix is idempotency keyed on Message-ID:
async function handleInbound(event: InboundEvent) {
const messageId = event.data.message_id;
// Check for duplicate processing
const existing = await db.processed_messages.findOne({ message_id: messageId });
if (existing) {
return { status: "duplicate", skipped: true };
}
// Mark as processing (optimistic lock)
await db.processed_messages.insert({
message_id: messageId,
processed_at: new Date()
});
// Process the message
await routeMessage(event);
}
Return 200 even for duplicates — returning 4xx or 5xx on a duplicate causes the provider to retry indefinitely.
SPF, DKIM, DMARC on inbound
Authentication headers on inbound email tell you whether to trust the From address. The Authentication-Results header added by the receiving MTA looks like:
Authentication-Results: inbound.provider.com;
spf=pass smtp.mailfrom=sender.com;
dkim=pass header.d=sender.com;
dmarc=pass action=none header.from=sender.com
For agent systems, treat messages with dmarc=fail as suspicious. Don't reject them outright — some legitimate senders have misconfigured DMARC — but don't act on them without additional verification. Prompt injection attacks often arrive via spoofed or lookalike addresses.
The injection_score field in modern agent-email platforms quantifies this risk. A high score means the message body contains patterns consistent with prompt injection attempts — instructions embedded in email text designed to hijack an agent's behavior. See email classification and routing for how this integrates with intent detection.
Deliverability for the outbound half
Inbound parsing is only half the picture. If your transactional email lands in spam, users never reply and your inbound pipeline starves.
The core requirements for sender reputation:
- SPF: A
TXTrecord at your domain listing authorized sending IPs.v=spf1 include:provider.com ~allfor most setups. - DKIM: A
TXTrecord atselector._domainkey.yourdomain.comcontaining the public key. Your provider generates the key pair; you publish the public half. - DMARC: A
TXTrecord at_dmarc.yourdomain.com. Start withp=noneto collect reports, then move top=quarantineonce you've verified all your sending sources are authenticated. - Reverse DNS: The IP your mail originates from should have a PTR record matching the sending hostname.
For automated senders with high volume or irregular patterns, a dedicated IP separates your reputation from other senders. Shared IPs mean a bad actor on the same IP can affect your deliverability. The tradeoff: dedicated IPs require volume to maintain reputation — low-volume senders on a cold dedicated IP perform worse than on a warm shared pool.
Choosing an API: what to actually evaluate
When you're evaluating transactional email APIs for a system that needs inbound parsing, the outbound API quality is table stakes. The real differentiators are on the inbound side:
| Capability | What to ask |
|---|---|
| Webhook payload completeness | Do they expose raw headers, Authentication-Results, MIME structure? |
| Threading support | Is Message-ID / In-Reply-To / References in the payload? |
| Signature verification | HMAC-SHA256 with timing-safe comparison? |
| Retry behavior | What's the backoff schedule? How many retries? |
| Attachment handling | Inline or URL? Size limits? Retention period? |
| Subaddress routing | Is + extension preserved through the pipeline? |
| Reply endpoint | Does the API set threading headers automatically on reply? |
| Security scanning | Prompt injection detection for agent use cases? |
For teams building AI agents specifically, Mails.ai adds agent-specific primitives on top of the standard inbound pipeline: per-agent inboxes, prompt-injection scanning on every inbound message, and first-contact classification with structured intent extraction — without requiring you to build a separate classification layer.
Frequently Asked Questions
What's the difference between the SMTP envelope sender and the From header?
The SMTP envelope sender (MAIL FROM / Return-Path) is set during the SMTP handshake and controls where bounces go. The From header is part of the RFC 5322 message and is what users see in their email client. They can differ — this is how spoofing works. DMARC alignment checks whether the domain in From matches the domain authenticated by SPF or DKIM. If they don't align, DMARC fails.
Why do some replies not include In-Reply-To or References headers?
Some email clients, particularly older mobile clients and certain corporate gateways, strip or don't set threading headers. Others rewrite Message-ID values. When threading headers are absent, fall back to subject normalization: strip Re:, Fwd:, RE:, etc. and match on the base subject. This is less reliable but catches most cases. Thread-based routing in your application should treat missing In-Reply-To as an ambiguous case, not a guaranteed new thread.
How should I handle email loops between automated systems?
Email loops happen when your system auto-replies to a message that itself was an auto-reply. Detect them by checking: (1) Auto-Submitted header — any value other than no or absent means it's automated; (2) X-Auto-Response-Suppress header — presence means suppress automated responses; (3) Precedence: bulk or Precedence: list — treat as low-priority automated mail. Never auto-reply to a message that has any of these headers set.
What's the right approach for high-volume inbound processing?
For more than a few hundred inbound messages per minute, decouple webhook receipt from processing. The webhook handler should do minimal work: verify the signature, write the raw payload to a queue (SQS, Redis Streams, Kafka), and return 200 immediately. A separate consumer pool reads from the queue and handles the actual routing and processing. This prevents webhook timeouts (most providers cut off at 30 seconds) and gives you backpressure control.
How do I handle attachments without storing them indefinitely?
Most providers expose attachments as temporary URLs with a TTL (commonly 24-72 hours). On receipt, download the attachment to your own storage immediately — don't rely on the provider URL being valid when you process the message later. For LLM ingestion, extract text from PDFs and Office documents at this point rather than passing binary data downstream. Libraries like pdfplumber (Python) or pdf-parse (Node) handle most PDF cases; Apache Tika handles a wider range of formats.
Can I use subaddress routing and thread-based routing together?
Yes, and it's the most robust setup. Use subaddressing for first-contact routing (support+user-1234@yourdomain.com routes to user 1234's context). Once the conversation is established, thread-based routing via In-Reply-To takes over. The subaddress acts as a fallback for clients that strip threading headers on the first reply. Store both the Message-ID and the subaddress key against the same entity so either mechanism can route correctly.
Related guides: Agent Email Address: Why Every AI Agent Needs One, Thread Email Replies Programmatically: Complete Guide, Composing RFC-Compliant Replies from an AI Agent and AI Email Automation for Developers: Core Concepts.
Explore the product: Inbound email parsing, Sender reputation, Email classification, All features and Pricing.
