
心橱的账号体系一开始已经有邮箱注册接口,但它更像一个“接口骨架”:后端能创建验证码挑战,测试里也能拿到验证码,但生产环境并不会真正发邮件。
这在早期开发阶段没问题。等到要走 TestFlight 和真实用户注册时,邮箱注册必须闭环:用户提交邮箱,后端生成验证码,邮件服务商投递,用户输入验证码,后端校验并创建账号。
这篇文章记录的是这条链路如何设计和落地。
目标
我希望邮箱验证码做到四件事:
- 客户端拿不到验证码,只拿到
challengeId。 - 服务端不保存明文验证码,只保存 hash、过期时间和尝试次数。
- 第三方邮件 provider 的 API key 只存在后端 secret 里。
- 邮件 provider 未配置或发送失败时,接口明确失败,不再假装“已发送”。
最终接口仍然很简单:
POST /v1/auth/email/start请求体:
{}响应里没有验证码:
{ "challengeId": "chal_xxx", "deliveryStatus": "accepted", "expiresAt": "2026-06-03T08:00:00Z"}验证时再提交邮箱、挑战 ID 和验证码:
POST /v1/auth/email/verify{ "challengeId": "chal_xxx", "code": "123456"}第一步:先拥有一个可发信域名
Resend 这类邮件服务不会允许你随便拿一个公共邮箱地址发信。你需要先证明自己拥有某个域名,然后把 SPF、DKIM 等 DNS 记录配置好。Resend 官方文档也明确要求使用自己拥有的域名,并建议使用子域名做发送身份;如果 DNS 在 Cloudflare,Resend 支持自动配置,也可以手动添加记录。
在心橱这个项目里,域名是 xinchu.app。推荐的发信地址可以是:
心橱 <[email protected]>也可以更保守一点,用专门的发送子域:
心橱 <[email protected]>我更倾向于后者。原因是发信信誉可以和主域隔离,后续如果有通知邮件、营销邮件、系统邮件,也能继续拆不同子域,不会把所有邮件都压在主域上。
大致配置路径是:
- 在 Resend 新增 domain,比如
mail.xinchu.app或xinchu.app。 - 在 Cloudflare DNS 里添加 Resend 给出的记录。
- 等 Resend 的 domain 状态变成
verified。 - 创建 Resend API key。
- 后端配置
EMAIL_FROM和RESEND_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} subject: 心橱登录验证码在 Cloudflare Worker 路径里,后端从 Worker 环境读取这些键:
RESEND_API_KEYEMAIL_FROMRESEND_BASE_URLEMAIL_SUBJECTEMAIL_PROVIDER其中真正敏感的是 RESEND_API_KEY,应该用 Worker secret:
npx wrangler secret put RESEND_API_KEYEMAIL_FROM 和 EMAIL_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 的行为是:
- 创建验证码 challenge。
- 从 app state 取
email_sender。 - 调用
send_verification_code。 - 发送失败时返回
503 EMAIL_DELIVERY_UNAVAILABLE。 - 发送成功才返回
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 保存这些字段:
idemail_hashcode_hashattempt_countcreated_atexpires_atconsumed_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_KEYEMAIL_FROMRESEND_BASE_URLEMAIL_SUBJECT这避免了“本地测试通过,上 Worker 之后注册发不了邮件”的断层。
第六步:测试要测真实边界
这类功能不适合在单元测试里真的发邮件。测试应该覆盖业务边界,而不是依赖第三方服务稳定性。
我用了三类测试:
CapturingEmailSender:捕获后端准备发送的邮箱和验证码,验证/email/start不返回验证码,但确实调用了 sender。- 验证码校验测试:错误验证码会增加尝试次数,正确验证码会创建 email 用户并返回登录 token。
- Worker 发送测试:mock
fetch,确认 Worker 向https://api.resend.com/emails发起请求,header 和 body 都符合预期。
这样就能确认业务链路正确,同时不把测试变成“真的打一封邮件到外网”。
上线清单
上线前我会按这个顺序检查:
- Resend domain 已 verified。
EMAIL_FROM使用 verified domain 下的地址。RESEND_API_KEY已通过 Worker secret 或 NAS config 配好。/v1/auth/email/start在未配置邮件 provider 时返回EMAIL_DELIVERY_UNAVAILABLE。- 生产环境发一封真实验证码邮件,确认收件箱、垃圾箱和发件人展示。
- 日志里不打印验证码、不打印 API key。
- 验证码过期、错误码、重复消费都有测试覆盖。
这次最大的取舍
我没有一开始就引入复杂的邮件模板系统、队列和投递状态回调。验证码邮件不是营销邮件,它的核心是及时、明确、可失败。
所以第一版只做纯文本邮件:
你的心橱验证码是:123456
验证码 10 分钟内有效。如非本人操作,可以忽略这封邮件。等用户量起来后,再考虑 HTML 模板、投递日志、bounce 处理和频率限制。现在先把注册链路闭环,才是最重要的。