Receiving email

Accept mail at your own domain and hand each message to your app as a webhook.

Replies to a support address, documents mailed in by customers, a reply+ticket-381@ address per conversation: once inbound is on for a domain, every message it accepts is stored and announced with an email.received event. Your app fetches the parsed text and HTML when it needs them.

1. Turn inbound on

This needs a full_access key. Use the id of a domain you have already verified.

curl -X POST https://api.rovela.dev/domains/<domain_id>/inbound \
  -H "Authorization: Bearer re_your_full_access_key" \
  -H "Content-Type: application/json" \
  -d '{"catch_all": false}'
{
  "object": "domain_inbound",
  "domain_id": "<domain_id>",
  "domain": "mail.acme.com",
  "enabled": true,
  "catch_all": false,
  "ingress_url": "https://api.rovela.dev/inbound/<domain_id>/<token>",
  "mx": { "host": "<mx host>", "priority": 10 }
}

Publish the mx record exactly as returned, on the domain name itself, here mail.acme.com. Until it resolves, senders keep delivering wherever the old MX pointed. The ingress_url is for forwarding from a mail server you already run (below); it contains a secret, so treat it like a key. Calling this endpoint again issues a new one and the old one stops working.

Which addresses are accepted

With catch_all left at its default of true, anything at the domain is accepted. With false, only addresses you list are, which keeps a stream of spam to random local parts out of your app:

curl -X POST https://api.rovela.dev/domains/<domain_id>/inbound/recipients \
  -H "Authorization: Bearer re_your_full_access_key" \
  -H "Content-Type: application/json" \
  -d '{"address": "support@mail.acme.com"}'

The address must be at that domain; anything else is a 400. Mail to an address that is not listed is refused at the door rather than stored.

2. Subscribe to email.received

curl -X POST https://api.rovela.dev/webhooks \
  -H "Authorization: Bearer re_your_full_access_key" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "https://app.acme.com/webhooks/rovela", "events": ["email.received"]}'

The event carries the envelope and subject, not the body:

{
  "id": "evt_81d0...",
  "type": "email.received",
  "created_at": "2026-10-05T09:41:07.552+00:00",
  "data": {
    "inbound_message_id": "3b7e9a12-...",
    "domain_id": "<domain_id>",
    "to": "support@mail.acme.com",
    "from": "customer@example.com",
    "subject": "Re: Order #1042 arrived damaged",
    "provider": "smtp-in",
    "provider_event_id": "..."
  }
}

3. The handler

Verify the signature exactly as in Handling bounces, then fetch the message. Reading it needs a full_access key, so keep that key on the server.

// verify() is the function from "Handling bounces".
export async function POST(request) {
  const rawBody = Buffer.from(await request.arrayBuffer())
  const sig = request.headers.get("x-resend-signature") ?? ""
  if (!verify(process.env.ROVELA_WEBHOOK_SECRET, sig, rawBody)) {
    return new Response("invalid signature", { status: 400 })
  }

  const event = JSON.parse(rawBody.toString("utf8"))
  if (event.type !== "email.received") return new Response(null, { status: 200 })

  const fresh = await db.query(
    "INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
    [event.id]
  )
  if (fresh.rowCount === 0) return new Response(null, { status: 200 })

  const res = await fetch(
    "https://api.rovela.dev/inbound/" + event.data.inbound_message_id,
    { headers: { Authorization: "Bearer " + process.env.ROVELA_FULL_ACCESS_KEY } }
  )
  // A 500 makes us retry the delivery; the message is not lost.
  if (!res.ok) return new Response("fetch failed", { status: 500 })
  const message = await res.json()

  // reply+ticket-381@mail.acme.com -> ticket 381
  const ticket = /^reply\+ticket-(\d+)@/.exec(message.to)?.[1]

  await db.query(
    "INSERT INTO ticket_messages (ticket_id, sender, subject, body_text, body_html) VALUES ($1, $2, $3, $4, $5)",
    [ticket ?? null, message.from, message.subject, message.text, message.html]
  )
  return new Response(null, { status: 200 })
}

GET /inbound/:id returns the same fields as the event plus text and html, either of which can be null if the sender did not include that part.

Inbound HTML was written by a stranger. Never insert it into your own pages. Show it in an <iframe sandbox=""> or run it through an HTML sanitiser first, and prefer text for anything you parse. The from address is whatever the sender claimed; do not use it as proof of identity.

Listing what arrived

Useful for a backfill after an outage, or for checking that mail is landing at all. Newest first, up to 100 per page:

curl "https://api.rovela.dev/inbound?limit=50&offset=0" \
  -H "Authorization: Bearer re_your_full_access_key"

Already running a mail server?

Skip the MX change and have your server forward the raw message to the ingress_url. The body is the message exactly as received, with the envelope in headers. A request over 2 MB is refused with 413:

curl -X POST "$INGRESS_URL" \
  -H "X-Envelope-To: support@mail.acme.com" \
  -H "X-Envelope-From: customer@example.com" \
  -H "X-Provider-Event-Id: <your queue id>" \
  --data-binary @message.eml
  • X-Envelope-To is required and goes through the same recipient rules as mail arriving by MX.
  • Send a stable X-Provider-Event-Id. A retried forward with the same id is recognised as a duplicate and does not fire email.received a second time.