Back to Blog

How an AI agent receives email and replies to it

October 14, 2026

AI AgentsEmail InfrastructureGuide+1

Give the agent an inbox that pushes each new message to your code, then reply to the thread the message arrived in. On OpenMail that push is a message.received event, sent to your webhook or over a WebSocket. It carries the body text, the extracted text of any attachments and a thread_id. The agent reads the thread for context and calls POST /v1/inboxes/{id}/send with that threadId. OpenMail sets the In-Reply-To and References headers, so the person sees an ordinary reply in their mail client.

Homemade setups usually break at the same spots. The message lands somewhere the agent can't see, the content is one more API call away, or the reply opens a new conversation because nobody set the threading headers.

Webhook or WebSocket

Webhook and WebSocket delivery of inbound email compared.
FeatureWebhookWebSocket
How it worksOpenMail POSTs each event to an HTTPS URL you runYour process holds a connection to wss://api.openmail.sh/v1/ws
Needs a public URLYesNo
If you miss somethingOpenMail retries; deduplicate on event_idReconnect with last_event_id and up to 100 missed events are replayed
Good forAgents running on a serverAgents on a laptop, a VPS or behind a firewall

The event is the same either way, so you can start on a WebSocket locally and switch to a webhook in production without touching the agent.

Step 1: receive the event

Set the webhook URL account-wide in the console under Settings, or per inbox with webhookUrl when you create it. Every request carries X-Timestamp, X-Signature and X-Event-Id. Before trusting the body, compute HMAC-SHA256 over {timestamp}.{raw body} with your webhook secret and compare it to the signature. Also reject anything whose timestamp is more than five minutes old.

webhook.js
import crypto from "node:crypto";
import express from "express";

const app = express();

// express.raw, not express.json: the signature is over the exact bytes received
app.post("/webhooks/openmail", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("X-Timestamp");
  const signature = req.get("X-Signature") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.OPENMAIL_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body}`)
    .digest("hex");

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  queue.add(JSON.parse(req.body)); // run the agent outside the request
  res.sendStatus(200);             // within 15 seconds
});
verify.py
import hashlib, hmac

def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Answer with a 200 inside 15 seconds and do the agent's work in a queue. A model call can easily take longer than that, and a slow response gets retried. Keep the event_ids you've handled so a retry doesn't run the agent twice.

Here is what arrives:

message.received.json
{
  "event": "message.received",
  "event_id": "evt_abc123",
  "occurred_at": "2026-10-14T09:12:03.000Z",
  "delivered_at": "2026-10-14T09:12:04.000Z",
  "attempt": 1,
  "inbox_id": "inb_92ma4k",
  "thread_id": "thr_xyz",
  "message": {
    "id": "msg_456",
    "rfc_message_id": "<CAF7x2k@mail.example.com>",
    "from": "vendor@example.com",
    "to": "invoices@yourdomain.com",
    "cc": [],
    "subject": "Invoice 2847",
    "body_text": "Hi, the September invoice is attached.",
    "attachments": [{
      "filename": "invoice-2847.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 48211,
      "url": "https://api.openmail.sh/v1/attachments/msg_456/invoice-2847.pdf",
      "parsedText": "Invoice #2847\nDate: 30 September 2026\nAmount due: EUR 1,250.00",
      "extractionMethod": "pdf"
    }],
    "raw_url": "https://api.openmail.sh/v1/messages/msg_456/raw",
    "received_at": "2026-10-14T09:12:02.000Z"
  }
}

No endpoint? Open a WebSocket and subscribe instead.

websocket.js
import WebSocket from "ws";

let lastEventId = null; // store this somewhere durable in production

function connect(delay = 1000) {
  const ws = new WebSocket("wss://api.openmail.sh/v1/ws", {
    headers: { Authorization: `Bearer ${process.env.OPENMAIL_API_KEY}` },
  });

  ws.on("open", () => {
    delay = 1000;
    const subscribe = { type: "subscribe" };
    if (lastEventId) subscribe.last_event_id = lastEventId; // replay what was missed
    ws.send(JSON.stringify(subscribe));
  });

  ws.on("message", (data) => {
    const event = JSON.parse(data);
    if (event.event !== "message.received") return;
    lastEventId = event.event_id;
    handle(event);
  });

  ws.on("error", () => {}); // "close" follows and reconnects
  ws.on("close", () => setTimeout(() => connect(Math.min(delay * 2, 30000)), delay));
}

connect();

Step 2: give the agent the conversation

Before the agent answers, load the thread so it can see the earlier messages:

thread.sh
curl https://api.openmail.sh/v1/threads/thr_xyz/messages \
  -H "Authorization: Bearer $OPENMAIL_AGENT_KEY"

You get the thread's messages in order, each with direction (inbound or outbound), bodyText and its attachments, ready to go into the model's context.

New inbound threads start unread, and sending a reply marks the thread read. That gives you a safety net for anything the agent missed: GET /v1/inboxes/{id}/threads?isRead=false lists what nobody has answered yet. If the agent decides an email needs no reply, mark it read with PATCH /v1/threads/{id} and {"isRead": true}.

Threads are grouped the way mail clients do it. An incoming message joins a thread when its In-Reply-To or References header matches a message already there. Otherwise it starts a new one.

Step 3: reply in the thread

reply.sh
curl -X POST https://api.openmail.sh/v1/inboxes/inb_92ma4k/send \
  -H "Authorization: Bearer $OPENMAIL_AGENT_KEY" \
  -H "Idempotency-Key: reply-evt_abc123" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "vendor@example.com",
    "threadId": "thr_xyz",
    "body": "Got it, thanks. Payment goes out on Friday.",
    "includeQuote": false
  }'

With a threadId you can leave out the subject; OpenMail uses the thread's subject with "Re:". By default the previous message is quoted under the reply, like a person's mail client would do. includeQuote: false sends only the agent's text. Building the Idempotency-Key from the event ID means a retried event can't send the same reply twice. Keys are remembered for 24 hours.

Attachments

PDFs, scans and photos (through OCR), Word, Excel and PowerPoint files, CSVs and plain-text formats come with their text already extracted in parsedText. extractionMethod tells you how. Files over 10 MB are skipped, and text is capped at 50,000 characters per file. The original file is at the attachment's url. The details, and how to hand all of it to a model, are in Inbound email with attachment text for LLMs.

Treat every inbound email as untrusted

Anyone can email your agent, and whatever they write ends up in its context. Give each agent a key that only opens its own inbox (POST /v1/inboxes/{id}/api-keys), and keep the account key on your server. Mark email content as untrusted in the prompt. Require a person or a rule before the agent does anything you can't undo. Sender rules let an inbox accept mail only from addresses or domains you allow. Armandas covers the threat model in What email infrastructure for AI agents should look like.

Doing this on a transactional sender

Resend, Postmark and Mailgun can all receive mail on your domain, but most of the work comes after that. On Resend, for example, the email.received webhook carries only metadata, so the agent has to fetch the body, headers and attachments from the Received emails and Attachments APIs before it can read anything. To reply in thread you put the incoming message_id in In-Reply-To and keep appending earlier IDs to References yourself. That's fine for a one-off reply. For an agent holding many conversations, it means building and storing the threads yourself. The side-by-side is on OpenMail vs Resend.

Frequently asked questions

How does an AI agent receive email?

Through a push event. The provider delivers each inbound message to a webhook URL or over a WebSocket. On OpenMail the message.received event already contains the body, the extracted text of attachments and a thread ID, so the agent can act on it without another call.

How do I reply to an email from code so it stays in the same thread?

Set In-Reply-To to the Message-ID of the email you’re answering and References to the chain before it. On OpenMail you pass the threadId to the send call and the headers are set for you.

Should I use a webhook or a WebSocket for inbound email?

A webhook if the agent runs on a server with a public URL. A WebSocket if it runs locally or behind a firewall, or you’d rather not expose an endpoint. The events are identical, and the WebSocket replays up to 100 events you missed while disconnected.

Can an AI agent read email attachments?

Yes. OpenMail extracts text from PDFs, images, Office documents, CSVs and plain-text files and puts it in the event as parsedText, up to 50,000 characters per file.

Every AI agent deserves its own inbox.

Install the CLI, run setup, and you're sending email from your agent in minutes.

More posts

Developer

How to give your AI agent its own email address

Four ways to give an AI agent an email address, compared. Why Gmail and transactional APIs break, and how to create a dedicated agent inbox with an API call.

Sep 22, 2026
SUSPENDED
gmail
Analysis

The Gmail suspension wasn't the problem. The architecture was.

Gmail suspensions for AI agents aren't random. The automation patterns that cause them look identical to spam. Here's what you've actually lost, why recovery rarely works, and how to move to something built for agents.

July 2, 2026
Analysis

OpenMail vs Nylas

Nylas connects to your users' existing inboxes. If your AI agent needs its own address, here's how Nylas Agent Accounts and OpenMail compare on limits, pricing, and code.

June 29, 2026
Shared infrastructure: three agents with dedicated IPs, rate-limited pools, and circuit breakers
Analysis

What Email Infrastructure for AI Agents Should Look Like

If you had to design email from scratch for AI agents, you wouldn't build Gmail. Here's what purpose-built infrastructure looks like — inbox-as-API, real-time delivery, threading, MIME parsing and IP warming/routing.

June 5, 2026
01Postmarksend-only
02Resendsend-only
03OpenMailagent-native▶
04AgentMailagent-native
05Mailgunbuild it
06Amazon SESbuild it
Analysis

Best email API for AI agents in 2026

Eight email APIs compared on receiving, threading, per-agent inboxes, reputation isolation, EU data residency and price. We run one of them, so we put ourselves first and tell you why.

May 14, 2026
Analysis

OpenMail vs Mailgun vs Amazon SES

Mailgun sends. SES is cheap. Neither was built for agents that receive, thread, and parse attachments. Here’s what’s actually different between all three.

May 6, 2026
ASCII art render of the Creation of Adam — two hands reaching toward each other, one human, one digital
Analysis

Why Every Email Option Falls Short for AI Agents

Gmail bans agents. Proton has no API. Outlook is a maze. Resend can't receive. A look at why every email option falls short for AI agents and what the risks are.

Apr 13, 2026
OpenClaw email setup guide
Developer

How to Give OpenClaw Its Own Email Address

One ClawHub command gives your OpenClaw agent a dedicated inbox with real-time delivery. Choose a usage mode — tool, notify, or channel — and you're done.

Mar 29, 2026
eu
News

We're EU-Based. That's Not a Footnote — It's the Point.

OpenMail is built in the EU, runs in the EU, and every customer is covered by GDPR — not as a checkbox, but as a legal guarantee.

Mar 16, 2026
audience
Founder insights

This Is Not for Human Eyes

We noticed that AI agents were finding OpenMail before we'd fully built for them. Here's what we saw — and what we built next.

Mar 12, 2026