Email Parsing API: Inbound Mail as Structured JSON, Zero MIME Handling
Every inbound message becomes structured JSON: sender, body, thread ID, injection score — no MIME, no polling, no hand-rolled parser.
From raw MIME to typed JSON in one hop
The typical path to parsed inbound email: configure MX records, set up an IMAP client, handle OAuth token refresh, fetch raw messages, load a MIME parser, strip HTML, decode quoted-printable, reconstruct threads from In-Reply-To headers. Mails.ai collapses that into a single webhook call:
// What your endpoint receives (trimmed) — no parsing needed on your side
{
"id": "evt_01jx8kq0v3n9ae",
"type": "reply.received" ,
"created_at": "2026-09-06T08:14:02Z",
"thread_id": "thrd_01jx4nbq8cde7w",
"source_message_id": "rcv_01jx8kpq2mfr4v",
"injection_score": 0.02,
"sender_reputation": 0.91,
"quarantined": false,
"data": {
"from": { "address": "alice@example.com" , "name": "Alice" },
"subject": "Re: Order #2241",
"body_text_excerpt": "Hi — still waiting on the tracking number for my order.",
"attachments": [],
"received_at": "2026-09-06T08:14:02Z",
"raw_url": "/api/v1/messages/rcv_01jx8kpq2mfr4v/raw"
}
}Thread reconstruction is automatic: every reply carries the same thread_id as the original message, so your agent or handler can retrieve the full conversation context in one query without parsing In-Reply-To headers yourself.
Register a webhook — parsing starts immediately
Give each inbox its own address (or bring a custom domain), register your endpoint, and every inbound message within your plan’s limits is parsed and POSTed to you:
import { createClient } from "@mailsai/sdk" ;
const mails = createClient({ apiKey: process. env. MAILS_API_KEY });
// One-time: register a webhook for parsed inbound events
await mails. webhooks. create({
url: "https://your-app.com/api/email" ,
event_types: [ "message.received" , "reply.received" ],
});
// Or skip the webhook entirely — use the SSE stream or poll GET /v1/events
mails. events. stream({
event_types: [ "message.received" , "reply.received" ],
onEvent: async ( event) => {
// data varies by event type: an inbound one has from as { address, name }, and
// body_text_excerpt already plain text — no HTML stripping, no MIME decoding
const from = event. data. from as { address: string; name: string | null };
// Hold for a person: quarantined, not scanned (no score), or 0.5 and up
if ( event. quarantined || typeof event. injection_score !== "number" || event. injection_score >= 0.5) {
return flagForReview( event);
}
// source_message_id is the received message to reply to
if (!event. source_message_id) return;
// Reply in the same thread — no Message-ID wrangling
await mails. messages. reply( event. source_message_id, {
body_text: `Hi ${ from. name ?? from. address} — on it, will follow up shortly.`,
});
},
});Python integration: consuming parsed events
The email parsing API is plain HTTPS + JSON — no SDK required for Python. Register your webhook once and handle parsed events in Flask or FastAPI:
import hmac, hashlib, os, time, requests
from flask import Flask, request, jsonify
app = Flask( __name__)
WEBHOOK_SECRET = os. environ[ "MAILS_WEBHOOK_SECRET"]
API = "https://api.mails.ai/v1"
HEADERS = { "Authorization": f"Bearer {os . environ[ 'MAILS_API_KEY']}"}
def verified( raw: bytes, header: str) -> bool:
# X-Mails-Signature: t=<unix>,v1=<hex> (a second v1 while a secret rotates)
parts = [ p. split( "=" , 1) for p in header. split( "," ) if "=" in p]
t = next(( v for k, v in parts if k == "t"), "")
if not t. isdigit() or abs( time. time() - int( t)) > 300:
return False # reject stale or replayed deliveries
expected = hmac. new(
WEBHOOK_SECRET. encode(), f"{t} .". encode() + raw, hashlib. sha256
). hexdigest()
return any( hmac. compare_digest( expected, v) for k, v in parts if k == "v1")
@app. post( "/api/email" )
def handle_parsed_email():
# Verify the signature before trusting the payload
if not verified(
request. get_data(), request. headers. get( "X-Mails-Signature", "")
):
return jsonify({ "error": "invalid signature"}), 401
event = request. get_json()
# All fields already parsed — no MIME wrangling in your handler
body_text = event[ "data"][ "body_text_excerpt"] # plain text
thread_id = event[ "thread_id"] # stable conversation ID
inj_score = event. get( "injection_score") # None when it was not scanned
if event. get( "quarantined") or inj_score is None or inj_score >= 0.5:
flag_for_review( event) # a person decides; a failed scan scores 0.5
return jsonify({ "status": "held"}), 200
reply = your_agent. respond( body_text, thread_id= thread_id)
requests. post( f"{API} /messages/ {event [ 'source_message_id']}/reply" ,
headers= HEADERS, json={ "body_text": reply})
return jsonify({ "status": "ok"}), 200For the async FastAPI variant with asyncio.create_task and HMAC verification, see the dedicated inbound email API webhooks for Python agents page.
Attachment and inline content parsing
The email parsing API surfaces attachments as typed metadata — you never touch a raw MIME boundary. The parsed event’s data.attachments lists each file you can download, and each one includes:
id— the attachment’s id (att_…), to fetch it byfilename— the file’s namecontent_type— e.g.application/pdf,image/pngsize— bytes, before any base64 overheadinline—truefor an image embedded in the HTML body, whichcontent_idnames, so embedded images are told apart from attached files
Attachment content is fetched separately via GET /, always as a download — the parsed event lists each file and stays lean, and your webhook handler is not forced to buffer large blobs. GET / lists the same files, and names under omitted, with the reason, any the message carried that cannot be downloaded.
Prompt-injection scanning built into every parsed event
Inbound email from arbitrary senders is untrusted text. Every message processed by the email parsing API is scanned across six injection categories before the event fires — boundary manipulation, system-prompt override, data exfiltration, role hijacking, tool invocation, and jailbreaks. The result is a numeric injection_score (0.0 = clean, 1.0 = high-confidence attack) on every parsed event:
// Recommended thresholds for AI agent handlers.
// Mail that was not scanned has no score, and a scan that could not run scores
// exactly 0.5: both go to a person.
const { injection_score, quarantined, thread_id } = event;
const text = event. data. body_text_excerpt;
if ( quarantined) {
await quarantine( event); // 0.95 or more: keep it from the agent entirely
} else if ( typeof injection_score !== "number" || injection_score >= 0.5) {
await flagForReview( event); // human review queue
} else if ( injection_score > 0.3) {
await processWithCaution( event); // log but still process
} else {
await yourAgent. handle( text, thread_id); // normal path
}Parsing is the product, not a feature. Unlike add-on inbound parsing from a transactional provider, the parsing layer here is first-class: multipart MIME is unwrapped, HTML is stripped to clean plain text, and Base64-encoded bodies are decoded before delivery. When you need the raw source for auditing, it is available separately — but your handler receives the clean form by default.
Delivery modes: webhook, SSE, and polling
The same parsed event is available in three delivery modes — choose what fits your deployment:
- Webhook — HTTPS POST to your endpoint, HMAC-signed, retried twice on failure (about 30 seconds and then two minutes later). Best for serverless and stateless handlers.
- SSE stream —
GET /v1/events/streamholds the connection open and pushes events as they arrive. Best for long-running processes that want to avoid polling. - Polling —
GET /v1/eventswith optional filters (event_type,agent_id,since). Best for environments where persistent connections are inconvenient (behind certain proxies or load balancers).
Optional intent and entity classification — enabled per agent on paid plans — adds intent, entities, and urgency fields to first-contact events, on paid plans at no extra charge. Useful when you want routing logic (high-urgency → escalate, refund-intent → billing agent) decided before your LLM runs rather than inside it.
For a deeper look at the inbound architecture — receipt → injection scan → parsing → structured event — see the inbound email parsing feature page.
Pricing
Every plan includes MIME handling, HTML stripping, thread reconstruction, and the injection-score scan on each parsed inbound event, and inbound has its own allowance, separate from emails. The free tier covers 3,000 inbound replies a month — no card required to start — Pro covers 50,000 for $20/month and Scale 500,000 for $99/month. Intent classification is opt-in on paid plans at no extra charge. For workloads that need a dedicated address with its own MX routing, see the inbound email API for AI agents page. For outbound from the same parsed inbox, AI email agent covers the full bidirectional flow. Building the webhook handler in Python? See Email API with Webhooks for Inbound Messages — Python AI Agents for Flask and FastAPI patterns with HMAC signature verification. For the general bidirectional email pattern for agents, see email for AI agents.
Frequently asked questions
Why use an email parsing API instead of a library like mailparser or postal-mime?
What fields does the parsed event include?
injection_score (0.0–1.0), sender_reputation, thread_id (stable across a full conversation) and source_message_id (the message to reply to). Its data object holds from (address + display name), subject, body_text_excerpt (the start of the plain-text body), received_at, the SPF and DKIM results, attachment metadata (filename, content type, size, inline vs. attached) and raw_url for the full message. Optional intent classification adds intent, entities, and urgency to first-contact mail when you enable it on a paid plan.Is webhook delivery the only option, or can I poll?
What does the injection-score field protect against?
Does the email parsing API work with Python as well as TypeScript?
How does thread reconstruction work across a multi-message conversation?
thread_id to all messages that belong to the same conversation. You never parse these headers yourself — query your data store by thread_id to retrieve full context before handing a message to your LLM.Explore the product
Give your first agent an inbox
Free covers 3,000 emails and 3,000 inbound replies a month, with no card. Upgrade when your agents get busy.
Get your API key