← 返回博客

邮箱验证链接(2026):不用验证码也能确认邮箱

用 POST /emails 发送一次性邮箱验证链接:应用侧生成并哈希令牌,模式与密码重置相同,和六位验证码互补;安全邮件建议关闭点击追踪,正文同时提供 html 与 text。

更新于 2026年11月4日
邮箱验证链接(2026):不用验证码也能确认邮箱

什么时候「点链接」比六位验证码更合适

几乎每个产品都会先发一封「证明你拥有这个邮箱」的事务邮件。Rovela 官方示例走的是 六位验证码:应用里生成数字、存哈希、用 POST /emails 发出,再校验用户输入。

但有些场景,点链接确认比在输入框里打数字更合适:

  • 桌面端注册——用户已经在另一个标签页打开邮箱,一点比抄验证码更快。
  • 更换邮箱确认——你需要的是「新地址确实是我的」这一持久信号,而不是弹窗里的短 OTP。
  • 列表 / 候补名单二次确认——动作是「确认订阅」,不是「证明我此刻坐在这台设备前」。
  • 无障碍或企业过滤环境——有时从通知栏抄码很别扭;纯文本里的完整 URL 仍可用。

Rovela 没有单独的「邮箱验证链接」产品或接口。投递通道与其他事务邮件相同:POST /emails。最接近的官方模式是 密码重置链接:不可猜测的 token、数据库里存哈希与短过期、明文只出现在邮件里、核销一次即失效。邮箱验证只是同一套模式换了落地页——写入 email_verified_at(或应用待更换地址),而不是改密码。

本文不是又一篇 OTP 教程。要发验证码,请看 验证码文档 以及博客里已有的 OTP 文章。这里讲的是用户点击链接。

你负责什么,Rovela 负责什么

应用侧必须自己做:

  • 用密码学安全的随机数生成 token,并做哈希
  • 持久化(token 哈希、用户或待验证邮箱、用途、过期、一次性核销)
  • 挂在你自己域名上的绝对 URL(如 https://app.example.com/verify-email?token=…)
  • 校验 token 并标记邮箱已验证的核销路由
  • 「重新发送验证邮件」的限流,以及只在服务端调用发信 API

Rovela 负责:

  • 接受带 Bearer re_… 的 POST https://api.rovela.dev/emails(生产发信用 sending_access 即可)
  • 入队、按已验证域名做 DKIM、重试、退信/投诉处理与消息日志
  • 可选模板、Webhook、抑制列表——与 OTP、重置、魔法登录、邀请同一套基础设施

不要把 API 密钥放进浏览器或移动客户端。额度与套餐见 定价页;本文不杜撰任何价格或配额数字。

生成并存储 token(32 字节、哈希、过期)

与 密码重置文档 同一套熵要求:

  1. 规范化邮箱(trim + 小写)。
  2. 创建或查找用户(或待更换邮箱的行)。
  3. 生成 32 字节随机数,编码为 base64url。
  4. 只存 token 的 SHA-256 十六进制哈希,绝不存明文。32 字节无法从哈希暴力还原,因此普通 SHA-256 足够(六位 OTP 不同:短数字需要带服务端 pepper 的 HMAC)。
  5. 设置过期——注册验证常见 24 小时;更换邮箱可更短(例如 1–2 小时)。
  6. 拼出 https://app.acme.com/verify-email?token= + 明文 token。明文只出现在邮件 URL 里。

示意性的 Node 写法(表名按你的 schema 调整):

import crypto from "node:crypto"

export async function issueEmailVerification(userId, email) {
  const token = crypto.randomBytes(32).toString("base64url")
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

  await db.emailVerifications.upsert({
    userId,
    email: email.trim().toLowerCase(),
    tokenHash,
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
  })

  return "https://app.acme.com/verify-email?token=" + token
}

对重发做限流(例如同一地址每分钟一封),避免按钮刷爆收件箱或消耗发信配额。若每次重发都作废旧 token,则只有最新链接有效——通常这正是你想要的。

用 POST /emails 发出链接

用已验证的子域发件地址,把认证类邮件与营销信誉隔开(例如 security@mail.acme.com)。同时提供 html 与 text。在纯文本里写出完整绝对 URL:部分企业过滤器会剥掉 HTML 链接,纯文本是仍能点开的兜底。

带上 Idempotency-Key(最长 256 字符)。若请求已被 Rovela 接受、只是你这边超时,用同一 key 重试会返回原结果,而不会发出两封不同链接的邮件。POST /emails 返回 200 表示消息已入队接受,不等于已进收件箱——送达情况看控制台消息页或 Webhook。

const link = await issueEmailVerification(user.id, user.email)
const tokenHashPrefix = /* 存库哈希前 32 个十六进制字符,或本次发送生成的 UUID */

await fetch("https://api.rovela.dev/emails", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.ROVELA_API_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": "email-verify-" + tokenHashPrefix,
  },
  body: JSON.stringify({
    from: "Acme <security@mail.acme.com>",
    to: user.email,
    subject: "请确认你的 Acme 邮箱",
    html:
      "<p>请确认此地址用于你的 Acme 账号。</p>" +
      '<p><a href="' + link + '">验证邮箱</a></p>' +
      "<p>此链接仅可使用一次,24 小时后失效。" +
      "若你并未注册,可忽略本邮件。</p>",
    text:
      "请确认此地址用于你的 Acme 账号。\n\n" +
      "验证邮箱: " + link + "\n\n" +
      "此链接仅可使用一次,24 小时后失效。",
  }),
})

等价的 curl 形态:

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: email-verify-8f3a2c71…" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "请确认你的 Acme 邮箱",
    "html": "<p>请确认此地址…</p><p><a href=\"https://app.acme.com/verify-email?token=…\">验证邮箱</a></p>",
    "text": "请确认此地址…\n\n验证邮箱: https://app.acme.com/verify-email?token=…"
  }'

一次性核销并标记已验证

在 GET /verify-email?token=…(或确认页的 POST)上:

  1. 用与存储时相同的方式对提交的 token 做哈希。
  2. 在一条语句里删除并返回未过期的匹配行,避免两次并行点击都成功。
  3. 若无行返回,展示通用的「链接无效或已过期」——不要区分未知 / 已用 / 过期到足以帮助攻击者的程度。
  4. 成功则写入 email_verified_at(或应用待更换邮箱),然后可选创建会话或跳转到「已验证,继续」。
export async function redeemEmailVerification(token) {
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex")
  const row = await db.query(
    `DELETE FROM email_verifications
     WHERE token_hash = $1 AND expires_at > NOW()
     RETURNING user_id, email`,
    [tokenHash]
  )
  if (row.rowCount === 0) return false

  await db.users.markEmailVerified(row.rows[0].user_id, row.rows[0].email)
  return true
}

更换邮箱时:把新地址存在验证行上,核销成功后再写回用户。链接发到新地址;核销成功前,安全通知仍用旧的已验证地址。

关闭点击追踪;在 text 里保留完整 URL

点击追踪会把链接改写到追踪域名。验证 URL 通常仍能用,但安全敏感的 token 应直达你的应用。验证、重置、魔法登录、邀请类邮件,优先使用关闭了点击追踪的发信域名(或子域)——与 密码重置示例 的建议一致。

务必发送带完整 URL 的 text。只有 HTML 的正文在激进过滤下很脆弱。

值得处理的失败模式

  • 400 且 recipient is suppressed——该地址曾硬退信或投诉。不要告诉用户「去查收件箱」;请对方换一个地址。
  • 400 指向你的 from 域名——该域名未在当前组织完成验证。去控制台修 DNS / 验证;这是配置问题,不是用户输错。
  • 429——速率限制或套餐配额;若有 Retry-After 请遵守。
  • 200 之后用户说「没收到」——接受 ≠ 送达。看消息时间线或 email.bounced Webhook,而不是在没有幂等策略的情况下盲目重发。

OTP 与验证链接:有意选择

更适合 六位 OTP 时…更适合 验证链接 时…
用户在手机上,要往你的 App 里打字用户在桌面,收件箱已打开
需要「证明此刻在场」(登录二次验证)需要持久的所有权证明(注册、换绑邮箱)
你已按 验证码 示例实现你已按 密码重置 链接示例实现

很多产品会两者都做:登录 / 敏感操作用 OTP,首次确认地址用验证链接。发信都走同一条 Rovela API——变的只是 token 形态和落地页。

相关模式

搭好 token 表、打通一次 POST /emails,安全邮件关掉追踪,你就有了「点链接确认邮箱」——无需发明专用邮件 API。