处理退信与投诉

退信和垃圾邮件投诉一发生就知道,并停止给这些地址发信。

给不存在的地址发信,或者给把你举报为垃圾邮件的人发信,正是让一个域名丢掉信誉的原因。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 的那种事件)也一样。

正因为有第二种情况,上面的处理函数记录每一次退信,而不是只依赖抑制列表:同一个地址反复软退信,也值得你采取行动。