邮箱验证链接(2026):不用验证码也能确认邮箱
用 POST /emails 发送一次性邮箱验证链接:应用侧生成并哈希令牌,模式与密码重置相同,和六位验证码互补;安全邮件建议关闭点击追踪,正文同时提供 html 与 text。
什么时候「点链接」比六位验证码更合适
几乎每个产品都会先发一封「证明你拥有这个邮箱」的事务邮件。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 字节、哈希、过期)
与 密码重置文档 同一套熵要求:
- 规范化邮箱(
trim+ 小写)。 - 创建或查找用户(或待更换邮箱的行)。
- 生成 32 字节随机数,编码为
base64url。 - 只存 token 的 SHA-256 十六进制哈希,绝不存明文。32 字节无法从哈希暴力还原,因此普通 SHA-256 足够(六位 OTP 不同:短数字需要带服务端 pepper 的 HMAC)。
- 设置过期——注册验证常见 24 小时;更换邮箱可更短(例如 1–2 小时)。
- 拼出
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)上:
- 用与存储时相同的方式对提交的 token 做哈希。
- 在一条语句里删除并返回未过期的匹配行,避免两次并行点击都成功。
- 若无行返回,展示通用的「链接无效或已过期」——不要区分未知 / 已用 / 过期到足以帮助攻击者的程度。
- 成功则写入
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.bouncedWebhook,而不是在没有幂等策略的情况下盲目重发。
OTP 与验证链接:有意选择
| 更适合 六位 OTP 时… | 更适合 验证链接 时… |
|---|---|
| 用户在手机上,要往你的 App 里打字 | 用户在桌面,收件箱已打开 |
| 需要「证明此刻在场」(登录二次验证) | 需要持久的所有权证明(注册、换绑邮箱) |
| 你已按 验证码 示例实现 | 你已按 密码重置 链接示例实现 |
很多产品会两者都做:登录 / 敏感操作用 OTP,首次确认地址用验证链接。发信都走同一条 Rovela API——变的只是 token 形态和落地页。
相关模式
- 密码重置链接——本文所遵循的官方链接 token 配方
- 邮箱验证码——官方 OTP 配方(互补,不是替代)
- 发送邮件 API——字段、幂等、
200= 已入队 - 姊妹文:魔法登录邮件链接、团队邀请邮件链接
搭好 token 表、打通一次 POST /emails,安全邮件关掉追踪,你就有了「点链接确认邮箱」——无需发明专用邮件 API。