All posts
Patterns·By Deepak··10 min read

Inbound Email Webhooks for AI Agents: Parse & Act

TL;DR

Inbound email webhooks turn raw MIME payloads into structured events your agent can reason about. The critical path is: verify the webhook signature, parse headers and body, classify intent, then dispatch to the right action handler — all idempotently, keyed on Message-ID.

TL;DR: Inbound email webhooks turn raw MIME payloads into structured events your agent can reason about. The critical path is: verify the webhook signature, parse headers and body, classify intent, then dispatch to the right action handler — all idempotently, keyed on Message-ID.

Inbound Email Webhooks for AI Agents: Parse & Act

Inbound email webhooks for AI agents are HTTP POST callbacks that deliver a structured representation of an arriving email — headers, parsed body, attachments — to an endpoint your agent controls. Getting this pipeline right determines whether your agent acts reliably on real-world email or drowns in malformed MIME, duplicate triggers, and misclassified intent.

This post goes deep on the mechanics: what the webhook payload actually contains, how to parse it safely, how to classify intent without hallucinating, and which action patterns hold up in production.

What the webhook payload actually looks like

Most inbound email providers POST a JSON envelope containing MIME-parsed fields. You do not receive raw MIME unless you specifically request it. The typical structure looks like this:

{
  "message_id": "<CA+abc123@mail.example.com>",
  "from": { "address": "alice@example.com", "name": "Alice" },
  "to": [{ "address": "agent+task42@yourdomain.ai", "name": "" }],
  "reply_to": "alice@example.com",
  "subject": "Re: Q3 report draft",
  "text": "Looks good. Please send the final PDF.",
  "html": "<div>Looks good...</div>",
  "headers": {
    "In-Reply-To": "<orig-msg-id@mail.example.com>",
    "References": "<orig-msg-id@mail.example.com>",
    "DKIM-Signature": "v=1; a=rsa-sha256; d=example.com; ..."
  },
  "attachments": [],
  "timestamp": "2026-08-30T10:22:00Z",
  "spf": "pass",
  "dkim": "pass"
}

Four fields drive almost all downstream logic: message_id (your idempotency key), In-Reply-To / References (thread continuity), the tagged to address (routing signal), and text (the content you'll classify).

Threading via headers

The In-Reply-To header contains the Message-ID of the email being replied to. References is a space-separated list of all ancestor Message-IDs in the thread. If your agent sent the original email with a known Message-ID, matching In-Reply-To against your outbox table recovers full context — no need to store entire thread bodies, just the IDs.

SELECT task_id, agent_state
FROM sent_messages
WHERE message_id = $1  -- value from In-Reply-To header

This is how you avoid passing full email history to the LLM on every reply. Retrieve the task record, pass only what's needed.

Verifying webhook authenticity

Before touching any payload field, verify the request came from your provider. Skip this and anyone who discovers your endpoint can inject arbitrary email events into your agent.

Most providers use HMAC-SHA256 over the raw request body with a shared secret. The signature arrives in a header — commonly X-Webhook-Signature or X-Mail-Signature.

import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhookSignature(
  rawBody: Buffer,
  signatureHeader: string,
  secret: string
): boolean {
  const expected = createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const received = signatureHeader.replace('sha256=', '');
  return timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(received, 'hex')
  );
}

Use timingSafeEqual — not ===. Timing attacks against HMAC verification are real, even over networks with variable latency, because an attacker can average many attempts.

Also verify that spf and dkim fields in the payload both read pass before trusting the from address for any privileged action. A message with dkim: fail may have a spoofed sender.

Idempotency: key on Message-ID

Webhook delivery is at-least-once. Your provider will retry on network errors or timeouts. If your endpoint takes 29 seconds to process a classification LLM call and the provider's timeout is 30 seconds, you will receive duplicates.

The fix is an idempotency table keyed on message_id:

CREATE TABLE processed_messages (
  message_id TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ DEFAULT now(),
  result JSONB
);

At handler entry:

const { message_id } = payload;

const existing = await db.query(
  'SELECT result FROM processed_messages WHERE message_id = $1',
  [message_id]
);
if (existing.rows.length > 0) {
  return res.status(200).json({ status: 'duplicate', skipped: true });
}

// ... process the message ...

await db.query(
  'INSERT INTO processed_messages (message_id, result) VALUES ($1, $2) ON CONFLICT DO NOTHING',
  [message_id, JSON.stringify(result)]
);

Return HTTP 200 on duplicates — not 4xx. If you return an error, the provider retries. ON CONFLICT DO NOTHING handles the race condition where two concurrent requests process the same message.

Classification pipeline

Classification answers: what does this email require the agent to do? The answer determines which handler runs.

A practical approach avoids sending the full email body to an LLM for simple cases. Build a tiered pipeline:

Tier 1: Rule-based (regex, subject keywords, tagged address)
  → catches ~60% of cases in under 1ms

Tier 2: Embedding similarity against intent templates
  → catches structured but variable messages in ~50ms

Tier 3: LLM classification with structured output
  → handles ambiguous, long-form, or multi-intent messages

Tier 1: tagged address routing

If your agent sends email from addresses like agent+taskid_42@yourdomain.ai, the to field on the reply tells you exactly which task context to load — no LLM needed.

function extractTaskId(toAddress: string): string | null {
  const match = toAddress.match(/\+taskid_(\w+)@/);
  return match ? match[1] : null;
}

This is the most reliable signal available and costs nothing to compute. Mails.ai's inbound parsing exposes the full to header including tagged suffixes, so this pattern works without any custom MIME parsing.

Tier 2: embedding similarity

Pre-embed a set of intent templates:

intents = [
  ("approve", "Yes, approved. Go ahead."),
  ("reject", "No, don't proceed. Cancel this."),
  ("request_info", "Can you send me more details about X?"),
  ("schedule", "Let's meet on Tuesday at 3pm."),
  ("escalate", "This needs a human to look at it."),
]

At classification time, embed the incoming text field and compute cosine similarity against each template vector. If the best match exceeds a threshold (0.85 works well empirically), route directly. Below threshold, fall to Tier 3.

Tier 3: LLM structured classification

For ambiguous cases, use a structured output schema so you get machine-readable intent without parsing freeform LLM responses:

const classificationSchema = z.object({
  intent: z.enum([
    'approve', 'reject', 'request_info',
    'schedule', 'escalate', 'unsubscribe', 'other'
  ]),
  confidence: z.number().min(0).max(1),
  extracted_entities: z.object({
    dates: z.array(z.string()).optional(),
    people: z.array(z.string()).optional(),
    action_items: z.array(z.string()).optional(),
  }),
  requires_human: z.boolean(),
});

Pass only the text field (not html) plus the subject. Stripping HTML before LLM classification avoids token waste and keeps CSS and tracking pixel noise out of the context window.

Action dispatch patterns

Once classified, you need deterministic action routing. An event-driven dispatcher is cleaner than a long if-else chain:

type ActionHandler = (payload: EmailPayload, entities: ExtractedEntities) => Promise<void>;

const handlers: Record<string, ActionHandler> = {
  approve: handleApproval,
  reject: handleRejection,
  request_info: handleInfoRequest,
  schedule: handleScheduling,
  escalate: escalateToHuman,
  unsubscribe: handleUnsubscribe,
};

async function dispatch(intent: string, payload: EmailPayload, entities: ExtractedEntities) {
  const handler = handlers[intent] ?? handlers['escalate'];
  await handler(payload, entities);
}

Default to escalate for unknown intents. An agent that silently drops unrecognized messages is harder to debug than one that surfaces unknowns to a human queue.

The approval pattern

Approval flows are the highest-stakes pattern — an agent acting on a spoofed or replayed approval has real consequences.

async function handleApproval(payload: EmailPayload, entities: ExtractedEntities) {
  // 1. Verify sender is authorized for this task
  const task = await getTaskByMessageId(payload.headers['In-Reply-To']);
  if (!task) throw new Error('No matching task for In-Reply-To');
  
  const authorized = task.authorized_approvers.includes(payload.from.address);
  if (!authorized) {
    await escalateToHuman(payload, entities);
    return;
  }
  
  // 2. Check DKIM passed (already verified above, but assert here)
  if (payload.dkim !== 'pass') {
    await escalateToHuman(payload, entities);
    return;
  }
  
  // 3. Execute the approved action
  await executeApprovedAction(task.id);
  
  // 4. Send confirmation reply
  await sendReply({
    to: payload.from.address,
    inReplyTo: payload.message_id,
    subject: `Re: ${payload.subject}`,
    text: `Confirmed. Task ${task.id} has been executed.`,
  });
}

Always verify the authorized approver list from your own records, not from the email itself. An attacker who can send email as a domain (SPF pass, DKIM fail) should not be able to approve actions.

The escalation pattern

Not everything should be automated. Your agent needs a reliable path to surface messages that require human judgment:

async function escalateToHuman(payload: EmailPayload, _entities: ExtractedEntities) {
  await createHumanTask({
    source_message_id: payload.message_id,
    from: payload.from.address,
    subject: payload.subject,
    snippet: payload.text.slice(0, 500),
    reason: 'agent_classification_uncertain',
    priority: 'normal',
  });
  
  // Optional: auto-acknowledge to avoid sender anxiety
  await sendReply({
    to: payload.from.address,
    inReplyTo: payload.message_id,
    text: 'Received. A team member will follow up within 24 hours.',
  });
}

Handling attachments

When attachments is non-empty, decide before calling an LLM whether the attachment content is relevant to the action. Fetching and decoding a 10MB PDF for an email that just says "approved" wastes tokens and latency.

if (intent !== 'request_info' && intent !== 'schedule') {
  // Don't fetch attachments for approval/rejection flows
  return;
}

for (const attachment of payload.attachments) {
  if (attachment.size > 5_000_000) {
    // Enqueue for async processing, don't block the webhook handler
    await enqueueAttachmentProcessing(attachment.url, task.id);
    continue;
  }
  const content = await fetchAttachment(attachment.url);
  // Extract text, embed, store
}

Process large attachments asynchronously. Your webhook handler must return HTTP 200 within the provider's timeout window (typically 10-30 seconds). Offload anything heavier.

Full pipeline architecture

sequenceDiagram
  participant MX as MX Server
  participant Provider as Email Provider
  participant Hook as Webhook Handler
  participant DB as Idempotency DB
  participant Classify as Classifier
  participant Agent as Action Handler

  MX->>Provider: SMTP delivery
  Provider->>Hook: POST JSON payload
  Hook->>Hook: Verify HMAC signature
  Hook->>DB: Check message_id exists
  DB-->>Hook: Not found
  Hook->>Classify: text plus subject plus tagged address
  Classify-->>Hook: intent plus entities
  Hook->>Agent: dispatch to handler
  Agent-->>Hook: result
  Hook->>DB: Insert message_id plus result
  Hook-->>Provider: 200 OK

Observability and debugging

Webhook pipelines fail silently if you don't instrument them. Log three things at minimum:

  1. Every incoming payload hash (SHA256 of message_id is enough — don't log PII in plaintext)
  2. Classification tier used and confidence — tells you when Tier 3 is being called more than expected
  3. Handler execution result — success, error, escalated, skipped

Track the distribution of intents over time. A sudden spike in other or escalate usually means a new email format is hitting your agent that your classifier hasn't seen before.

For the inbound email processing side, structured logging makes it possible to replay a failed webhook — you have the full payload in your logs, so you can re-POST it to a test endpoint after fixing a classification bug.

Frequently Asked Questions

How do I avoid acting on auto-replies and out-of-office messages?

Check for Auto-Submitted: auto-replied or Auto-Submitted: auto-generated in the headers field. Also watch for X-Autoreply: yes and a Precedence: bulk or Precedence: auto_reply header. Filter these before classification — they should never trigger agent actions.

function isAutoReply(headers: Record<string, string>): boolean {
  const autoSubmitted = headers['Auto-Submitted'] ?? '';
  return autoSubmitted !== '' && autoSubmitted !== 'no';
}

What's the right timeout for my webhook endpoint?

Return HTTP 200 within 10 seconds, unconditionally. Acknowledge receipt immediately, then process asynchronously via a queue or background job. LLM calls can easily blow past that window — a job queue (BullMQ, Inngest, etc.) between the webhook handler and the processing logic is the right architecture.

How do I handle multi-part MIME emails where html and text differ?

Always prefer the text field for classification and LLM input. HTML is for rendering. The text part contains what the human actually wrote; HTML often contains quoted history, signatures, tracking pixels, and layout markup that pollutes your context. If text is empty — rare, but it happens with some mobile clients — strip HTML tags from html. A simple strip_tags pass, not full DOM parsing.

Can I use the tagged to address as the only routing signal?

For internal workflows where your agent controls both sending and receiving, yes — tagged addresses are reliable and fast. For external-facing inboxes where humans send to a fixed address like support@yourdomain.ai, you need classification on top. Use tagged addresses for known reply chains, classification for open inboxes.

How should I test my webhook handler locally?

Use ngrok or cloudflared tunnel to expose your local port, configure it as the webhook destination in your provider, and send test emails. Separately, capture real payloads (sanitized of PII) as JSON fixtures and build a test suite that runs classification and handler logic against them directly — no HTTP involved. This keeps regression testing fast and avoids provider rate limits during development.

What's the difference between from and reply_to for routing?

reply_to is where you should send your agent's response. from is who actually sent the message. For classification and authorization, use from (and verify DKIM against it). For sending replies, use reply_to if present, then fall back to from. Some automated senders set reply_to to a monitoring address — always check both.


Building this pipeline correctly means your agent handles email reliably at scale — not just in the happy path. The email classification and routing layer is where most production systems break down, and the patterns above address the common failure modes: duplicates, spoofed approvals, LLM misclassification, and silent drops. Get these mechanisms right and the rest of the agent workflow follows naturally.

Start sending with Mails.ai

Live now

Ship agent email in ~6 lines.

Free tier, no card. Mint a key and drop the SDK into your agent.

Get your API key
Live now

Built for agents.
Self-serve in minutes.

The API is live and self-serve. Drop ~6 lines into your agent and ship.

npmpnpmbunnpx
$ npm install @mailsai/sdk
Live on npm today · @mailsai/sdk + @mailsai/mcp-server