How to have an AI agent reply to inbound email on your own domain

Point a subdomain's MX record at Unitpost and subscribe a webhook to email.received. For each delivery, verify the signature, dedupe on the received email's id, fetch the message, and let the agent draft. Your code checks the draft, then sends it with in_reply_to set to the inbound id, which threads it, and an idempotency key derived from that id, so each inbound message gets one reply at most.

Unitpost ·

An agent that answers email needs a domain that receives, a webhook handler, the agent that drafts, a gate that checks the draft, and a send that threads. Unitpost gives you the receiving and the send. It does not give each agent a mailbox: receiving is catch-all on a domain, mail arrives as a webhook and in the dashboard inbox, and there is no IMAP.

How do you receive mail on a subdomain?

Use a subdomain such as reply.yourdomain.com. Our MX record must have the lowest priority number on its name, so on a root domain that runs Google Workspace or Microsoft 365 it would take mail away from those inboxes.

  1. Verify the subdomain for sending first. Receiving can only be turned on for a verified domain; anything else answers 409.
  2. Turn receiving on in the domain's Receiving tab, or with PATCH /email/domains/{id} and { "capabilities": { "receiving": "enabled" } } (MCP email_domains_update).
  3. Publish the MX record the API returns as receiving.record. Receiving is live once receiving.status is verified.
  4. Create a webhook subscribed to email.received. The URL must be public HTTPS, so develop behind a tunnel.

Every address at the subdomain arrives with no per-address setup: support@, billing@ and reply+4821@ land on the same webhook, and your handler routes on to, which is also how you split work between several agents.

Verify the signature, then dedupe

Check the signature before you read anything else. Each delivery carries svix-id, svix-timestamp and svix-signature, an HMAC-SHA256 over the id, the timestamp and the raw body. Pass the raw body: re-serialized JSON has different bytes. In Node.js:

cURL
import { verifyWebhook } from "unitpost";

// Pass the RAW request body: re-serializing breaks the signature.
const event = verifyWebhook({
  payload: rawBody,
  secret: process.env.UNITPOST_WEBHOOK_SECRET, // whsec_…
  headers: {
    "svix-id": headers["svix-id"],
    "svix-timestamp": headers["svix-timestamp"],
    "svix-signature": headers["svix-signature"],
  },
});

verifyWebhook throws on a bad signature, so answer 401. It also rejects a timestamp more than five minutes from now (toleranceSeconds, default 300), so a captured delivery can't be replayed later.

A delivery that times out or gets a 5xx is retried, so the same event can reach you twice. The envelope's event_id stays the same across deliveries even when svix-id differs, and data.email_id (inb_…) names the received message. Insert one into a column with a unique constraint before doing any work; if the insert fails, return 2xx and stop. Otherwise return 2xx at once and run the agent off the request path, so a slow model doesn't become a retry.

Give the agent the message, not the inbox

The webhook holds metadata only: sender, recipients, subject, message_id and attachment descriptors. Fetch the rest with GET /email/received/{id} (unitpost.email.receivedGet in Node.js, which needs emails:read). It returns the text and html bodies (inlined when small; larger ones through the /raw link), reply_to, thread_id, the in-reply-to and references headers, and the SPF, DKIM and DMARC verdicts.

For the history, GET /email/threads/{id} returns the conversation oldest first: received messages with their bodies, and what you sent as metadata only. The API never returns the body of mail you sent, so if the agent needs its own earlier replies as context, keep its drafts on your side.

Do these reads in your handler with a read key, and pass the model only this message and its thread, as data written by a stranger. How to protect an AI email agent from prompt injection covers which verdicts to require.

How do you thread the reply?

Mail clients group a conversation by the In-Reply-To and References headers. You don't have to build them: set in_reply_to to the inbound id on POST /email. Unitpost looks up the stored Message-ID and References chain, fills both headers, and adds Re: to the subject if it isn't there. Headers you set yourself always win. An unknown id is a 404; a received message with no Message-ID is a 422.

Send to the message's reply_to addresses when it has them, else to from. A contact form's relay address is not the person who wrote in.

POST /email/threads/{id}/reply also threads, quotes the original and marks the conversation read, but it needs emails:read as well as emails:send, and the same call can forward. For an agent, POST /email with in_reply_to keeps the sending key send-only.

Draft, gate, then send once

The model writes; your code decides. Before anything goes out, check that:

  • the recipient is the one your code picked, not one the model suggested
  • the sender's dmarc_verdict is PASS, or the reply says nothing account-specific
  • the draft has no links or attachments the agent made up
  • the thread is under your cap of agent replies
  • from isn't your own address: a copy of your own send that comes back to your receiving domain arrives as received mail, and answering it starts a loop

Then send with an idempotency key built from the inbound id. Here inbound is the message receivedGet returned and draft is the text that passed your checks:

cURL
await unitpost.email.send(
  {
    from: "Acme Support <support@reply.acme.com>",
    to: inbound.reply_to.length ? inbound.reply_to : inbound.from,
    subject: inbound.subject,
    text: draft,
    in_reply_to: inbound.id, // inb_…
  },
  { idempotencyKey: `reply-${inbound.id}` },
);

The key names the unit of work: one inbound message, one reply. A retry with the same key within 24 hours returns the first result, marked Idempotent-Replayed: true, instead of sending again. If a worker dies after the send and a rerun makes the model write a different draft, the same key with a different body is refused with 409 invalid_idempotent_request, and nothing new goes out. Treat that 409 as "already answered", never as a reason for a fresh key. 409 concurrent_idempotent_requests means the first attempt is still in flight: wait and retry. The window is 24 hours, so keep your own "replied" flag as well (what is an idempotency key).

The platform's gate still runs underneath: the from domain must be verified in the key's workspace, and suppressed addresses are skipped.

When should the agent hand off to a person?

Some messages should never get an automatic reply. Stop and hand off when:

  • the sender's dmarc_verdict or spam_verdict isn't PASS
  • they ask for a person, or the thread is about money, refunds, cancellations or anything legal
  • the agent isn't confident, or the thread has reached your cap of agent replies
  • the message looks automated, such as an out-of-office or a bounce notice, which is how two bots start a loop

A handoff is mostly not sending. Every received message is already in the dashboard inbox under Emails, Received, threaded with what you sent. Notify your team with the thread and the reason the agent stopped; a person replies from there, from the same domain and thread, and the shared read state shows it's been handled.

Frequently asked questions

Does each AI agent get its own mailbox?

No. Receiving is catch-all on a domain: every address at it arrives as an email.received event, and you route on the to field. Send support@ to one agent, billing@ to another, or give each conversation a reply+ address. There is no IMAP or POP access and no per-address setup.

Is the email body in the email.received webhook?

No. The webhook carries metadata: sender, recipients, subject, Message-ID and attachment descriptors. Fetch the body with GET /email/received/{id} using a key with emails:read. The message lists attachments as metadata; get each one's bytes from GET /email/received/{id}/attachments/{attachmentId}, which returns a signed URL that expires after about five minutes.

What if the same webhook arrives twice?

Expect it: a delivery that times out or gets a 5xx is retried. Dedupe on event_id or data.email_id in a unique column, and send with an idempotency key derived from the inbound id, so even a missed dedupe can't produce a second reply within 24 hours.

Send email your users actually receive

5,000 emails a month free — API, SMTP, templates, tracking. No credit card.