Turn Inbound Email into Structured, Actionable Events

Stop hand-rolling mail parsers. MultiMail delivers inbound messages as thread-aware API objects so your AI agent can classify, route, and respond without rebuilding email infrastructure.


Why this matters

Raw inbound email is one of the hardest inputs to automate reliably. Messages arrive with inconsistent formatting, nested reply chains, forwarded attachments, and ambiguous intent. Teams that try to wire email into automations end up maintaining brittle MIME parsers, managing IMAP connections, and writing one-off classification logic for every new message type. When a thread spans five replies, context is scattered across quoted blocks. When an attachment matters, it has to be decoded separately. And none of it is structured in a way an AI model can reason about without preprocessing. The result: automation that works in demos and breaks in production whenever a sender formats something differently.


How MultiMail solves this

MultiMail receives inbound email on your behalf and converts each message into a structured API object before your agent ever sees it. Webhook events fire immediately on delivery with a normalized payload: sender identity, subject, plain-text and HTML body, attachments as typed metadata, and a thread_id that links every reply in the conversation. Your agent calls check_inbox to poll, or handles the webhook push directly, then uses read_email and get_thread to fetch full context. From there it classifies intent, calls tag_email to annotate the message, and either drafts a reply via reply_email or triggers an external workflow. Under monitored oversight mode, every action your agent takes is visible to your team in real time — no approval gate slows the loop, but nothing happens silently.

1

Receive the Webhook Event

MultiMail sends a POST to your registered webhook URL the moment an inbound message arrives. The payload includes the message ID, thread ID, sender, subject, and a normalized body — no MIME parsing required. Register your endpoint in the dashboard under Settings → Webhooks, selecting the inbound_email event type.

2

Fetch Full Thread Context

Use get_thread with the thread_id from the webhook payload to retrieve the complete conversation history as an ordered array of message objects. Each message includes body text, sender metadata, and attachment references. Passing the full thread to your AI model gives it the context it needs to understand replies, escalations, and prior commitments.

3

Classify Intent

Pass the thread to your model and ask it to classify the message into categories meaningful to your workflow — support request, billing inquiry, feature request, spam, escalation required, and so on. MultiMail does not impose a classification schema; you define the taxonomy that matches your routing logic.

4

Tag and Route

Call tag_email to annotate the message with the classification result and any extracted entities (account ID, order number, urgency level). Tags are queryable, so downstream systems can filter the inbox by classification without re-running the model. Use the classification to trigger the appropriate workflow — open a support ticket, update a CRM record, or queue a human review.

5

Draft and Send or Escalate

For messages the agent can handle autonomously, call reply_email with the drafted response. Under monitored oversight mode, the reply is sent immediately and your team receives a notification. For messages that require human judgment, call decide_email with action set to escalate, which moves the message into the pending queue and notifies a reviewer without blocking the rest of the inbox.

6

Observe and Improve

Under monitored mode, every agent action — reads, tags, replies, escalations — is logged with timestamps and the model's reasoning payload if you pass it. Use list_pending to audit messages the agent escalated, close the loop on reviewer decisions, and feed corrections back into your classification prompt over time.


Implementation

Webhook Handler: Receive and Verify Inbound Events
typescript
import express from 'express';
import crypto from 'crypto';

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.MULTIMAIL_WEBHOOK_SECRET!;

app.post('/webhooks/inbound', async (req, res) => {
  const sig = req.headers['x-multimail-signature'] as string;
  const body = JSON.stringify(req.body);
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const { event, data } = req.body;
  if (event !== 'inbound_email') return res.sendStatus(204);

  const { message_id, thread_id, from, subject } = data;
  console.log(`Inbound: ${message_id} thread=${thread_id} from=${from.email} subject="${subject}"`);

  "cm">// Hand off to agent pipeline asynchronously
  processInbound(message_id, thread_id).catch(console.error);

  res.sendStatus(202);
});

async function processInbound(messageId: string, threadId: string) {
  "cm">// Fetch full thread, classify, route — see next samples
}

app.listen(3000);

An Express handler that validates the MultiMail webhook signature and extracts the message and thread IDs for downstream processing.

Fetch Thread, Classify Intent, and Tag
python
import os
import anthropic
import httpx

MM_API_KEY = os.environ["MULTIMAIL_API_KEY"]
MM_BASE = "https://api.multimail.dev"
headers = {"Authorization": f"Bearer {MM_API_KEY}"}

client = anthropic.Anthropic()

def classify_and_tag(message_id: str, thread_id: str):
    "cm"># Fetch full thread
    thread_resp = httpx.get(
        f"{MM_BASE}/get_thread",
        params={"thread_id": thread_id},
        headers=headers,
    )
    thread_resp.raise_for_status()
    thread = thread_resp.json()

    "cm"># Build context from thread messages
    thread_text = "\n---\n".join(
        f"From: {m[&"cm">#039;from']['email']}\nDate: {m['date']}\n\n{m['body_plain']}"
        for m in thread["messages"]
    )

    # Classify with Claude
    resp = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=256,
        system=(
            "Classify this email thread into exactly one category: "
            "support_request, billing_inquiry, feature_request, spam, or escalation_required. "
            "Reply with only the category label."
        ),
        messages=[{"role": "user", "content": thread_text}],
    )
    intent = resp.content[0].text.strip()

    # Write classification back to MultiMail
    tag_resp = httpx.post(
        f"{MM_BASE}/tag_email",
        headers=headers,
        json={"message_id": message_id, "tags": [intent, "ai-classified"]},
    )
    tag_resp.raise_for_status()
    print(f"Tagged {message_id} as {intent}")
    return intent

Retrieves the full conversation thread, sends it to a model for classification, and writes the result back to MultiMail using tag_email.

Draft Reply or Escalate Based on Classification
python
def respond_or_escalate(message_id: str, thread_id: str, intent: str):
    AUTONOMOUS_INTENTS = {"support_request", "billing_inquiry"}
    ESCALATE_INTENTS = {"escalation_required"}

    if intent in ESCALATE_INTENTS:
        resp = httpx.post(
            f"{MM_BASE}/decide_email",
            headers=headers,
            json={
                "message_id": message_id,
                "action": "escalate",
                "reason": f"Classified as {intent} — routing to human reviewer",
            },
        )
        resp.raise_for_status()
        print(f"Escalated {message_id}")
        return

    if intent not in AUTONOMOUS_INTENTS:
        print(f"No action configured for intent &"cm">#039;{intent}', skipping")
        return

    # Fetch thread for reply context
    thread = httpx.get(
        f"{MM_BASE}/get_thread",
        params={"thread_id": thread_id},
        headers=headers,
    ).json()

    latest = thread["messages"][-1]
    thread_text = "\n---\n".join(m["body_plain"] for m in thread["messages"])

    draft_resp = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=512,
        system="Draft a concise, helpful email reply. Do not include a subject line.",
        messages=[{"role": "user", "content": thread_text}],
    )
    reply_body = draft_resp.content[0].text.strip()

    send_resp = httpx.post(
        f"{MM_BASE}/reply_email",
        headers=headers,
        json={
            "message_id": message_id,
            "body": reply_body,
            "from_mailbox": "[email protected]",
        },
    )
    send_resp.raise_for_status()
    print(f"Replied to {message_id}")

Sends an AI-drafted reply for routine messages and escalates to a human reviewer for anything requiring judgment, using reply_email and decide_email.

Poll Inbox for Unprocessed Messages
python
import time

def poll_and_process(mailbox: str, interval_seconds: int = 30):
    """
    Fallback poller for environments where webhook delivery is unreliable.
    Prefer webhooks in production — polling introduces latency.
    """
    seen: set[str] = set()

    while True:
        resp = httpx.get(
            f"{MM_BASE}/check_inbox",
            headers=headers,
            params={
                "mailbox": mailbox,
                "unread_only": True,
                "limit": 50,
            },
        )
        resp.raise_for_status()
        messages = resp.json().get("messages", [])

        for msg in messages:
            if msg["message_id"] in seen:
                continue
            seen.add(msg["message_id"])

            intent = classify_and_tag(msg["message_id"], msg["thread_id"])
            respond_or_escalate(msg["message_id"], msg["thread_id"], intent)

        time.sleep(interval_seconds)

Uses check_inbox to pull unread messages when webhooks are unavailable, then feeds each through the classify-and-respond pipeline.


What you get

No Mail Parsing Infrastructure to Build or Maintain

MultiMail normalizes MIME, decodes base64 attachments, strips quoted reply chains, and resolves thread order before your agent ever sees the message. Your code operates on structured JSON, not raw email.

Thread-Aware Context for Every Reply

get_thread returns every message in a conversation as an ordered, structured array. Pass it directly to your model. No manual extraction of quoted text, no guessing at reply order across mail clients.

Escalation Without Building a Queue

decide_email with action: escalate moves a message into MultiMail's built-in pending queue and notifies your team. You get a human-in-the-loop path without building a separate ticketing integration.

Durable Tags for Downstream Filtering

Classification results written via tag_email are stored on the message and queryable via check_inbox filters. CRM integrations, reporting dashboards, and routing rules can all read from the same source of truth without re-running inference.

Monitored Mode Gives Visibility Without Friction

Under monitored oversight, your agent replies and routes autonomously at full speed. Every action is logged and your team receives notifications. You see what the agent did and why, without approving each step.


Recommended oversight mode

Recommended
monitored
High-volume inbound processing cannot afford a human approval gate on every message — the queue would back up immediately. Monitored mode lets the agent classify and reply at full speed while giving your team real-time visibility into every action taken. Reserve gated_send for initial rollout on a new mailbox while you validate classification accuracy, then graduate to monitored once error rates are acceptable. Escalation paths (decide_email with escalate) handle the cases that genuinely need human judgment, so monitored mode does not mean unreviewed — it means the agent self-selects which messages to surface.

Common questions

How does MultiMail receive inbound email on my domain?
You point an MX record at MultiMail's inbound mail servers. Messages sent to any address at that domain are delivered to MultiMail, which triggers your registered webhook and makes the message available via the API. You can also use @multimail.dev subaddresses without any DNS configuration.
What happens if my webhook endpoint is down when a message arrives?
MultiMail queues the event and retries with exponential backoff over 24 hours. Messages are always accessible via check_inbox regardless of webhook delivery status, so a polling fallback can recover any events your webhook handler missed.
Can I route different senders or subjects to different agents?
Yes. check_inbox accepts filters for sender domain, tags, date range, and read status. You can run separate agents polling filtered views of the same mailbox, or use tag_email in your webhook handler to annotate messages before routing them to downstream processors.
How are attachments handled?
The message object returned by read_email includes an attachments array with filename, MIME type, size, and a pre-signed URL. Your agent fetches attachments on demand — they are not included inline in the webhook payload, which keeps event sizes predictable regardless of attachment size.
Does MultiMail store my inbound email indefinitely?
Retention is configurable per mailbox. The default is 90 days. For GDPR compliance, you can configure shorter retention windows or call the deletion endpoint to remove individual messages on demand. MultiMail does not index message content for advertising or train models on your email data.
What is the latency from message delivery to webhook firing?
Median webhook delivery is under 2 seconds from SMTP acceptance. The API object is available for polling immediately after delivery. End-to-end latency from a sender hitting Send to your agent receiving the webhook is typically under 5 seconds for standard SMTP paths.

Explore more use cases

The only agent email with a verifiable sender

Email infrastructure built for AI agents. Verifiable identity, graduated oversight, and a 50-tool MCP server. Formally verified in Lean 4.