入站邮件 API(2026):用签名 Webhook 收信
在已验证域名上开启入站并发布 MX,订阅 email.received,校验签名后拉取正文与 HTML,无需自建整套收信栈也能处理 support 回复与 reply+ticket 寻址。
多数 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 的场景。
运维清单
- 先完成发信域名验证,再在实际收信主机名上开启入站。
- 对外公布
support@前决定 catch-all 还是白名单。 - Webhook 只订
email.received(若同时发信,可另加退信/投诉类型)。 - 校验签名;按
event.id去重;尽快返回2xx。 - 服务端用
full_access拉正文;消毒 HTML。 - 若重新开启入站,立刻更新 MTA 转发——旧
ingress_url立即失效。 - 事件不保证顺序;需要顺序时按
created_at排序(见 概览)。
下一步
- 文档:接收邮件 · Webhook 概览 · 安全 · 事件
- 姊妹篇:带邮箱与入站的 Resend 替代方案
- 签名深挖:校验 Webhook 签名
- 常与入站支持并列的发信场景:面向 AI 应用的事务邮件
一句话:Rovela 入站路径是开启 → MX(或 ingress 转发)→ email.received → 验签 → GET /inbound/:id。你得到带签名、可重试的投递,而不必自建整套收信栈——support 回复与工单地址,落在同一套已负责事务发信的产品里。