← 返回博客

入站邮件 API(2026):用签名 Webhook 收信

在已验证域名上开启入站并发布 MX,订阅 email.received,校验签名后拉取正文与 HTML,无需自建整套收信栈也能处理 support 回复与 reply+ticket 寻址。

更新于 2026年11月4日
入站邮件 API(2026):用签名 Webhook 收信

多数 SaaS 产品先做发信:验证码、重置密码、工单更新。下一步往往是 入站(inbound)——客户回复 support@yourdomain.com,或你为每个会话分配 reply+ticket-381@…,并希望几秒内把邮件送进应用。

你可以自建整套 MX 收信栈,也可以在已验证域名上开启入站,订阅带签名的 Webhook,需要时再拉取解析后的正文。本文按 Rovela 接收邮件 示例走通这条路径。若你在比较「发信 API + 域名邮箱 + 入站」的产品范围,可先读 Resend 替代方案(2026):事务邮件、邮箱与入站。

为什么用 Webhook 收信

「入站作为应用输入」和「有人偶尔看 Webmail」不是一回事。你的处理器通常需要:

  • 邮件被接受时有稳定事件(email.received)
  • 先拿到信封信息(from / to / subject),不必每次投递都带完整正文
  • 准备入库或展示时再拉取 text / html
  • 用密码学证明 POST 来自 Rovela,而不是猜中 URL 的陌生人

Rovela 会存储每封被接受的入站邮件,并用 email.received 通知你。事件里是信封元数据,不含正文。应用先校验签名,再 GET /inbound/:id 取内容。这样 Webhook 负载更小;若拉取失败可返回 500,让 Rovela 重试投递,消息不会丢。

海外常见形态:共享 support@ 开单、按会话的 reply+ticket-…@ 寻址,以及「把 PDF 发到我们邮箱」类流程——而不必自己运维 Postfix。

1. 开启入站并发布 MX

管理入站需要 full_access API 密钥(或控制台会话)。使用你已验证域名的 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 生效前,发件方仍会投到旧 MX——切流方式和换邮箱服务商一样需要计划。

ingress_url 用于从你已有的邮件服务器转发(见下文),内含密钥,按凭据保管。再次调用开启接口会 签发新 URL,旧地址立即失效。

调用 POST /emails 的进程用 sending_access;管理域名、Webhook、拉取入站正文的服务端用 full_access——不要放进浏览器或 App。见 身份认证。

2. Catch-all 与收件人白名单

catch_all 默认是 true:域名下任意本地部分都会被接受。这对 reply+…@ 很方便,但如果随机地址涌入,事件流会充满垃圾邮件。

设为 catch_all: 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。若做工单回复寻址:要么在专用入站子域开 catch-all,要么预先登记产品会用到的地址。

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

创建响应里的签名密钥只出现一次,立刻存好。生产环境请显式列出 events,不要用空数组(空数组等于订阅一切,包括以后新增的类型)。详见 Webhook 概览 与 事件目录。

典型 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: Order #1042 arrived damaged",
    "provider": "smtp-in",
    "provider_event_id": "..."
  }
}

用 data.to 做路由(support@ 与 reply+ticket-381@…)。按事件 id 去重——重试会带同一个 id。

4. 每次请求都校验签名

端点是公网 URL。行动前必须验证 x-resend-signature:

x-resend-signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>

用端点签名密钥对 `${timestamp}.${rawBody}` 做 HMAC-SHA256,结果为十六进制。必须用收到的 原始字节,不能先 JSON.parse 再 stringify。拒绝超出重放窗口的投递(文档示例为五分钟)。完整步骤见 Webhook 安全 与博客 校验 Webhook 签名。

一旦接管事件,尽快返回 2xx,重活放到自己的队列。状态码决定重试:

你的响应Rovela 行为
2xx投递成功
5xx、408、429重试(最多五次,退避 1s → 2s → 4s → 8s)
其他 4xx(含 400)永久失败——该事件不会再发

因此:签名错误返回 400 是对的(不要为伪造重试);数据库或入站拉取暂时失败返回 500 是对的(消耗一次重试)。不要因为「还不认识某个字段」就回 400——未知字段应入库或忽略;事件 字段集会增量扩展。

5. 用 GET /inbound/:id 拉取正文

读取入站内容同样需要 full_access:

curl https://api.rovela.dev/inbound/<inbound_message_id> \
  -H "Authorization: Bearer re_your_full_access_key"

响应包含与事件相同的信封字段,以及 text 与 html(发送方未提供时可能为 null)。

处理器骨架(签名校验省略,请用安全文档中的函数):

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

  // 按 event.id 去重,然后:
  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 -> ticket 381
  const ticket = /^reply\+ticket-(\d+)@/.exec(message.to)?.[1]

  // 持久化 message.from / subject / text / html
  return new Response(null, { status: 200 })
}

上线前必看的安全点:

  • 入站 HTML 来自陌生人,切勿直接插入你的页面。解析优先用 text;展示 HTML 请用沙箱 iframe 或先消毒。
  • from 只是发送方自称,不能当作身份证明。
  • 故障后可用 GET /inbound?limit=50&offset=0 回补(见 接收邮件)。

6. 已有邮件服务器?

可以不改 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: <your-stable-id>" \
  --data-binary @message.eml
  • X-Envelope-To 必填,并走与 MX 相同的收件人规则。
  • 请发送稳定的 X-Provider-Event-Id。相同 id 的重试转发会被识别为重复,不会再次触发 email.received。

适合公司 MX 必须留在现有 MTA,但工单回复仍需进入 SaaS 的场景。

运维清单

  1. 先完成发信域名验证,再在实际收信主机名上开启入站。
  2. 对外公布 support@ 前决定 catch-all 还是白名单。
  3. Webhook 只订 email.received(若同时发信,可另加退信/投诉类型)。
  4. 校验签名;按 event.id 去重;尽快返回 2xx。
  5. 服务端用 full_access 拉正文;消毒 HTML。
  6. 若重新开启入站,立刻更新 MTA 转发——旧 ingress_url 立即失效。
  7. 事件不保证顺序;需要顺序时按 created_at 排序(见 概览)。

下一步

  1. 文档:接收邮件 · Webhook 概览 · 安全 · 事件
  2. 姊妹篇:带邮箱与入站的 Resend 替代方案
  3. 签名深挖:校验 Webhook 签名
  4. 常与入站支持并列的发信场景:面向 AI 应用的事务邮件

一句话:Rovela 入站路径是开启 → MX(或 ingress 转发)→ email.received → 验签 → GET /inbound/:id。你得到带签名、可重试的投递,而不必自建整套收信栈——support 回复与工单地址,落在同一套已负责事务发信的产品里。