All solutions
Solutions

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.

An email parsing API takes a raw inbound message — MIME multiparts, HTML, encoded headers and all — and delivers a clean, structured JSON event to your endpoint instead: sender, subject, plain-text body, thread identifier, attachment metadata, and a prompt-injection score, ready for your code or your LLM to consume directly. Mails.ai does this as a managed service: you register a webhook or open the SSE stream, and parsed events arrive the moment mail lands.

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"}), 200

For 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 by
  • filename — the file’s name
  • content_type — e.g. application/pdf, image/png
  • size — bytes, before any base64 overhead
  • inline — true for an image embedded in the HTML body, which content_id names, so embedded images are told apart from attached files

Attachment content is fetched separately via GET /v1/messages/received/:id/attachments/:attachment_id, 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 /v1/messages/received/:id/attachments 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/stream holds the connection open and pushes events as they arrive. Best for long-running processes that want to avoid polling.
  • Polling — GET /v1/events with 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?
A library parses a message you already fetched — which means you still need IMAP polling, OAuth refresh, and MIME retrieval to get the raw bytes in the first place. A managed email parsing API receives the message for you (via MX record), parses it server-side, and pushes a structured event to your webhook. You write a POST handler, not an email stack.
What fields does the parsed event include?
Every event carries, at the top level, 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?
Three delivery modes: webhook (HTTPS POST, HMAC-signed, retried twice on failure, about 30 seconds and then two minutes later), polling (GET /v1/events with filters), and SSE streaming (GET /v1/events/stream — holds the connection open and pushes events as they arrive). All three produce the same parsed JSON payload.
What does the injection-score field protect against?
Every inbound message is scanned for prompt-injection patterns before the parsed event is delivered. The score (0.0 = clean, 1.0 = high-confidence injection) lets your code branch on a number rather than relying on the LLM to notice it is being attacked inside the same prompt as the attack. Hold logic: at 0.5 or more, or with no score (it was not scanned), hold the message for a person; mails.ai itself marks 0.95 and up quarantined.
Does the email parsing API work with Python as well as TypeScript?
Yes — the API is plain HTTPS + JSON, so any language that can POST to an endpoint consumes the parsed events. Python examples using the requests library (or httpx for async) work identically to the TypeScript SDK. For a complete Flask and FastAPI walkthrough with HMAC verification, see the inbound Python agent page.
How does thread reconstruction work across a multi-message conversation?
The email parsing API reads the Message-ID, In-Reply-To, and References headers of every message and assigns a stable 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