处理退信与投诉
退信和垃圾邮件投诉一发生就知道,并停止给这些地址发信。
给不存在的地址发信,或者给把你举报为垃圾邮件的人发信,正是让一个域名丢掉信誉的原因。Rovela 已经替你挡住了最糟的部分——投诉,以及「邮箱不存在」的退信,会把地址加进你的抑制列表,之后向它的发送会被拒绝。它做不到的是更新你的数据库。这个 webhook 就是干这个的。
1. 订阅
创建 webhook 需要 full_access 密钥,或者用控制台的 Webhooks 页面。
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.bounced", "email.complained", "suppression.added"]
}'响应里带着 signing_secret,只显示这一次。把它存为 ROVELA_WEBHOOK_SECRET。
2. 收到的是什么
{
"id": "evt_5a1c...",
"type": "email.bounced",
"created_at": "2026-10-05T08:12:40.118+00:00",
"data": {
"email_id": "9f1c2e40-...",
"recipient": "old-address@example.com",
"reason": "550 5.1.1 <old-address@example.com>: Recipient address rejected: User unknown"
}
}取决于退信是在哪里被发现的,地址在 data.recipient 或 data.to 里(发送时就被拒的情况下是数组)。两个都要读。suppression.added 带的是 {suppression_id, email, kind},kind 为 bounced 或 complained。
3. 处理函数
一个 Next.js 路由处理函数。签名校验就是 安全校验 里那一段。
import crypto from "node:crypto"
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"))
// 重试会带着同一个事件 id 再来。先记下它,处理函数跑两次也安全。
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 d = event.data
const addresses = [d.recipient ?? d.to ?? d.email].flat().filter(Boolean)
switch (event.type) {
case "email.bounced":
// 别再让用户「去查收邮件」了——请他换一个地址。
await db.query(
"UPDATE users SET email_status = 'bouncing', email_bounce_reason = $2 WHERE email = ANY($1)",
[addresses, d.reason]
)
break
case "email.complained":
// 对方把它当成了垃圾邮件。关掉给他的所有非必要邮件。
await db.query(
"UPDATE users SET marketing_opt_in = false WHERE email = ANY($1)",
[addresses]
)
break
case "suppression.added":
await db.query(
"UPDATE users SET email_suppressed = true WHERE email = ANY($1)",
[addresses]
)
break
}
return new Response(null, { status: 200 })
}
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
if (!parts.t || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const expected = crypto.createHmac("sha256", secret)
.update(parts.t + ".").update(rawBody).digest("hex")
const a = Buffer.from(expected), b = Buffer.from(parts.v1)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}数据库挂了就返回
500,不要返回 400。投递最多重试五次,但只在 5xx、408 或 429 之后;其他 4xx 等于告诉我们这个事件你不要,它会被丢弃。4. 抑制列表
这些接口同样需要 full_access 密钥。查一个地址——客服问「这个客户为什么什么都收不到」的时候很有用:
curl "https://api.rovela.dev/emails/suppressions?email=old-address@example.com" \
-H "Authorization: Bearer re_your_full_access_key"自己加一条,比如用户注销账号或要求你再也别给他发信时:
curl -X POST https://api.rovela.dev/emails/suppressions \
-H "Authorization: Bearer re_your_full_access_key" \
-H "Content-Type: application/json" \
-d '{"email": "leaving@example.com", "kind": "unsubscribed", "reason": "account deleted"}'移除抑制
DELETE /emails/suppressions/:id 只能删你自己加的条目(origin 为 manual)。由退信或投诉产生的会保留:这个地址确实退过信,这个人确实投诉过,悄悄地再给他们发信,就是域名被拉黑的方式。
哪些会被自动抑制
- 投诉——总是。
- 退信——只在退信报告说明邮箱不存在时。邮箱已满、策略拒收或临时失败仍然会以
email.bounced报告,但地址仍可发送,因为它多半是有效的。发送时就被拒的情况(带data.to的那种事件)也一样。
正因为有第二种情况,上面的处理函数记录每一次退信,而不是只依赖抑制列表:同一个地址反复软退信,也值得你采取行动。