接收邮件
在你自己的域名上收信,并把每一封信以 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.emlX-Envelope-To是必填的,并且和通过 MX 到达的邮件走同一套收件人规则。- 带上一个稳定的
X-Provider-Event-Id。同一个 id 的重复转发会被识别为重复,不会再触发一次email.received。