接收邮件

在你自己的域名上收信,并把每一封信以 webhook 的形式交给你的应用。

客服地址收到的回信、客户发来的文件、每个会话一个 reply+ticket-381@ 地址:给一个域名开启收信后,它接受的每封邮件都会被保存,并通过 email.received 事件通知你。你的应用在需要时再去取解析好的正文和 HTML。

1. 开启收信

需要 full_access 密钥。用一个已经验证过的域名的 id。

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 }
}

把 mx 记录原样发布在这个域名本身上,这里是 mail.acme.com。在它生效之前,发件方仍会投递到旧 MX 指向的地方。ingress_url 用于从你已有的邮件服务器转发(见下文);它里面含有密钥,请像对待 API 密钥一样保管。再次调用这个接口会签发一个新的地址,旧的随即失效。

接受哪些地址

catch_all 保持默认的 true 时,这个域名下的任何地址都会被接受。设为 false 时,只接受你列出的地址——这样发往随机前缀的垃圾邮件就进不了你的应用:

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"}'

地址必须属于这个域名,否则返回 400。发往未列出地址的邮件会在入口处被拒绝,不会被保存。

2. 订阅 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"]}'

事件里带的是信封和主题,不含正文:

{
  "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: 订单 #1042 到货有损坏",
    "provider": "smtp-in",
    "provider_event_id": "..."
  }
}

3. 处理函数

签名校验和 处理退信与投诉 里完全一样,然后去取这封信。读取需要 full_access 密钥,所以这个密钥只能放在服务端。

// verify() 就是「处理退信与投诉」里的那个函数。
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 } }
  )
  // 返回 500 会让我们重试这次投递,邮件不会丢。
  if (!res.ok) return new Response("fetch failed", { status: 500 })
  const message = await res.json()

  // reply+ticket-381@mail.acme.com -> 工单 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 返回和事件相同的字段,外加 text 和 html;如果发件人没有包含对应的部分,它们可能是 null。

收到的 HTML 是陌生人写的。永远不要把它直接插进你自己的页面。放进 <iframe sandbox=""> 里显示,或者先过一遍 HTML 净化器;需要解析内容时优先用 text。from 只是发件人自称的地址,不能当作身份证明。

列出收到的邮件

适合故障恢复后补数据,或者确认邮件到底有没有进来。按时间倒序,每页最多 100 条:

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

已经有自己的邮件服务器?

不必改 MX,让你的服务器把原始邮件转发到 ingress_url。请求体就是收到的原始邮件,信封信息放在请求头里。超过 2 MB 的请求会被拒绝,返回 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: <你的队列 id>" \
  --data-binary @message.eml
  • X-Envelope-To 是必填的,并且和通过 MX 到达的邮件走同一套收件人规则。
  • 带上一个稳定的 X-Provider-Event-Id。同一个 id 的重复转发会被识别为重复,不会再触发一次 email.received。