Inkstone · blog

把邮箱注册补完整:从 Resend 域名配置到后端验证码发送

1,955 words 6 min read #Cloudflare#Resend#Email#Backend

邮箱验证码发送链路

心橱的账号体系一开始已经有邮箱注册接口,但它更像一个“接口骨架”:后端能创建验证码挑战,测试里也能拿到验证码,但生产环境并不会真正发邮件。

这在早期开发阶段没问题。等到要走 TestFlight 和真实用户注册时,邮箱注册必须闭环:用户提交邮箱,后端生成验证码,邮件服务商投递,用户输入验证码,后端校验并创建账号。

这篇文章记录的是这条链路如何设计和落地。

目标

我希望邮箱验证码做到四件事:

  1. 客户端拿不到验证码,只拿到 challengeId
  2. 服务端不保存明文验证码,只保存 hash、过期时间和尝试次数。
  3. 第三方邮件 provider 的 API key 只存在后端 secret 里。
  4. 邮件 provider 未配置或发送失败时,接口明确失败,不再假装“已发送”。

最终接口仍然很简单:

POST /v1/auth/email/start

请求体:

{
"email": "[email protected]"
}

响应里没有验证码:

{
"challengeId": "chal_xxx",
"deliveryStatus": "accepted",
"expiresAt": "2026-06-03T08:00:00Z"
}

验证时再提交邮箱、挑战 ID 和验证码:

POST /v1/auth/email/verify
{
"email": "[email protected]",
"challengeId": "chal_xxx",
"code": "123456"
}

第一步:先拥有一个可发信域名

Resend 这类邮件服务不会允许你随便拿一个公共邮箱地址发信。你需要先证明自己拥有某个域名,然后把 SPF、DKIM 等 DNS 记录配置好。Resend 官方文档也明确要求使用自己拥有的域名,并建议使用子域名做发送身份;如果 DNS 在 Cloudflare,Resend 支持自动配置,也可以手动添加记录。

在心橱这个项目里,域名是 xinchu.app。推荐的发信地址可以是:

也可以更保守一点,用专门的发送子域:

我更倾向于后者。原因是发信信誉可以和主域隔离,后续如果有通知邮件、营销邮件、系统邮件,也能继续拆不同子域,不会把所有邮件都压在主域上。

大致配置路径是:

  1. 在 Resend 新增 domain,比如 mail.xinchu.appxinchu.app
  2. 在 Cloudflare DNS 里添加 Resend 给出的记录。
  3. 等 Resend 的 domain 状态变成 verified
  4. 创建 Resend API key。
  5. 后端配置 EMAIL_FROMRESEND_API_KEY

如果使用 Cloudflare DNS,Resend 文档里的重点是:MX、SPF、DKIM 这些记录要按照 Resend 给出的值配置;手动添加时不要把完整域名重复粘贴到 Cloudflare 的 Name 字段里。

第二步:后端配置不要进代码仓库

在 NAS / FastAPI 部署路径里,配置放在 config.yaml

providers:
email:
provider: resend
base_url: https://api.resend.com/emails
api_key: ${RESEND_API_KEY}
from_email: 心橱 <[email protected]>
subject: 心橱登录验证码

在 Cloudflare Worker 路径里,后端从 Worker 环境读取这些键:

RESEND_API_KEY
EMAIL_FROM
RESEND_BASE_URL
EMAIL_SUBJECT
EMAIL_PROVIDER

其中真正敏感的是 RESEND_API_KEY,应该用 Worker secret:

Terminal window
npx wrangler secret put RESEND_API_KEY

EMAIL_FROMEMAIL_SUBJECT 本身不是密钥,可以放普通变量,也可以为了统一管理放 secret。Cloudflare 官方文档里也强调,敏感值应该用 secrets,而不是明文环境变量;Worker 运行时读取时,secret 和普通环境变量的用法没有区别。

对这个项目来说,配置层最后会汇总成:

class EmailProviderSection(BaseModel):
provider: str = "resend"
base_url: str = "https://api.resend.com/emails"
api_key: str | None = None
from_email: str | None = None
subject: str = "心橱登录验证码"

这样 NAS 和 Worker 可以共用同一套业务代码,只是在配置来源上不同。

第三步:把“发邮件”做成后端能力

我没有把 Resend 调用直接写进 auth route,而是抽了一层 EmailSender

class EmailSender(Protocol):
async def send_verification_code(self, email: str, code: str) -> None:
raise NotImplementedError

真实发送实现是 ResendEmailSender。它负责组装 HTTP 请求:

payload = {
"from": self.from_email,
"to": [email],
"subject": self.subject,
"text": verification_email_text(code),
}

然后带上 Resend API key:

headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}

如果 provider 没配好,就使用 UnconfiguredEmailSender。这点很重要:未配置时应该让接口返回明确错误,而不是继续返回 accepted

现在 /v1/auth/email/start 的行为是:

  1. 创建验证码 challenge。
  2. 从 app state 取 email_sender
  3. 调用 send_verification_code
  4. 发送失败时返回 503 EMAIL_DELIVERY_UNAVAILABLE
  5. 发送成功才返回 challengeId

这让注册流程的语义更真实:deliveryStatus: accepted 表示邮件发送请求已经被 provider 接受,而不是后端自说自话。

第四步:验证码只保存 hash

验证码本身是 6 位数字:

def generate_email_code() -> str:
return f"{randbelow(10**EMAIL_CODE_LENGTH):0{EMAIL_CODE_LENGTH}d}"

后端保存时不落明文,而是把邮箱 hash 和验证码组合后再 hash:

def email_code_hash(email_hash: str, code: str) -> str:
return hash_value(f"{email_hash}:{code.strip()}")

数据库里的 email_challenges 保存这些字段:

id
email_hash
code_hash
attempt_count
created_at
expires_at
consumed_at

当前规则是:

  • 验证码长度:6 位
  • 有效期:10 分钟
  • 最大尝试次数:5 次
  • 成功验证后写入 consumed_at
  • 过期、已消费、超过尝试次数都视为无效

这不是银行级认证,但对一个个人 App 的邮箱注册来说,边界清楚、实现简单,也能覆盖主要风险。

第五步:Worker 路径不要忘

心橱后端有两条运行路径:

  • 本地 / NAS:FastAPI + Python runtime
  • Cloudflare:Worker runtime + D1

邮件发送也必须覆盖两条路径。FastAPI 路径使用 ResendEmailSender;Worker 路径里有对应的 send_worker_verification_email,从 env 读取:

RESEND_API_KEY
EMAIL_FROM
RESEND_BASE_URL
EMAIL_SUBJECT

这避免了“本地测试通过,上 Worker 之后注册发不了邮件”的断层。

第六步:测试要测真实边界

这类功能不适合在单元测试里真的发邮件。测试应该覆盖业务边界,而不是依赖第三方服务稳定性。

我用了三类测试:

  1. CapturingEmailSender:捕获后端准备发送的邮箱和验证码,验证 /email/start 不返回验证码,但确实调用了 sender。
  2. 验证码校验测试:错误验证码会增加尝试次数,正确验证码会创建 email 用户并返回登录 token。
  3. Worker 发送测试:mock fetch,确认 Worker 向 https://api.resend.com/emails 发起请求,header 和 body 都符合预期。

这样就能确认业务链路正确,同时不把测试变成“真的打一封邮件到外网”。

上线清单

上线前我会按这个顺序检查:

  1. Resend domain 已 verified。
  2. EMAIL_FROM 使用 verified domain 下的地址。
  3. RESEND_API_KEY 已通过 Worker secret 或 NAS config 配好。
  4. /v1/auth/email/start 在未配置邮件 provider 时返回 EMAIL_DELIVERY_UNAVAILABLE
  5. 生产环境发一封真实验证码邮件,确认收件箱、垃圾箱和发件人展示。
  6. 日志里不打印验证码、不打印 API key。
  7. 验证码过期、错误码、重复消费都有测试覆盖。

这次最大的取舍

我没有一开始就引入复杂的邮件模板系统、队列和投递状态回调。验证码邮件不是营销邮件,它的核心是及时、明确、可失败。

所以第一版只做纯文本邮件:

你的心橱验证码是:123456
验证码 10 分钟内有效。如非本人操作,可以忽略这封邮件。

等用户量起来后,再考虑 HTML 模板、投递日志、bounce 处理和频率限制。现在先把注册链路闭环,才是最重要的。

参考