Go 发送邮箱验证码(2026):用 HTTP API 发 OTP
在 Go 里做注册/登录邮箱验证码:crypto/rand、HMAC 落 sync.Map/Redis/SQL、限流、用 net/http 或 chi 调 POST /emails,以及 Idempotency-Key——优先 HTTP 而非 SMTP;Rovela 只负责投递。
为什么用 Go + 邮箱验证码(而不是魔法链接 SDK)
Go 本来就适合做注册/登录:net/http(或 chi)handler、限流中间件,以及服务端存储(sync.Map、Redis 或 SQL)。你不需要 Rovela 的 Go SDK、「OTP 产品」接口,或把邮件 API 当成身份提供商的中间件。
你需要的是文档里的配方——邮箱验证码:应用用 crypto/rand 生成六位码,只存 HMAC(永不落明文),设过期与尝试次数,再用标准库 net/http 客户端调 POST /emails,同时带上 html 与 text。Rovela 负责入队、DKIM、重试与事件。它不会生成验证码、校验验证码,也不会建会话。
Rovela 不提供 OTP 功能。 Go 服务负责密码学、存储条目、重发节流、核销逻辑以及 session cookie / JWT。Rovela 只负责投递。
本文面向 Go(1.22+ handler 友好,chi / Echo / Gin 同样适用):「发码」与「验码」handler、薄封装的 Rovela 客户端、内存或 Redis 的 HMAC 存储,以及 Idempotency-Key,避免超时重试入队两封不一致的码。这不是魔法链接教程——魔法链接见 Next.js、NestJS、Rails、FastAPI。同款 OTP 姊妹篇:Express.js、Django、Laravel。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP。通用 Go 发送(非 OTP)见 Go 发邮件——本文不重复 SMTP dialer、MIME 拼装或「hello world」发送。
你负责什么,Rovela 发什么
Go 服务负责:
crypto/rand→ 六位、左侧补零(不要用math/rand)- 用
crypto/hmac+crypto/sha256对email + ":" + code做 HMAC-SHA256,密钥是服务端 pepper(CODE_PEPPER) - 过期(文档示例约 10 分钟)、尝试计数、以及「同一地址一分钟只发一次」的重发闸
- 按规范化邮箱 upsert,新发会使旧码失效(
sync.Map、Redis 或 SQL) - 核销时用
hmac.Equal常量时间比较;成功后 delete / 失效 - 是否暴露账号存在性由产品决定,但公开响应宜保持一致
- 对
/auth/otp/send与/auth/otp/verify做限流
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+sending_access密钥(环境变量里的ROVELA_API_KEY——勿提交、勿放进公开配置、勿打印到日志)- 入队、签名、重试、退信/投诉处理
发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。
配置与薄封装客户端(net/http)
// config.go
package otp
import (
"os"
"strconv"
"time"
)
type Config struct {
APIKey string
CodePepper []byte
From string
TTL time.Duration
ResendCooldown time.Duration
MaxAttempts int
}
func LoadConfig() Config {
ttlMin, _ := strconv.Atoi(envOr("OTP_TTL_MINUTES", "10"))
coolSec, _ := strconv.Atoi(envOr("OTP_RESEND_COOLDOWN_SEC", "60"))
maxAtt, _ := strconv.Atoi(envOr("OTP_MAX_ATTEMPTS", "5"))
return Config{
APIKey: mustEnv("ROVELA_API_KEY"), // sending_access
CodePepper: []byte(mustEnv("CODE_PEPPER")),
From: envOr("OTP_FROM", "Acme <security@mail.acme.com>"),
TTL: time.Duration(ttlMin) * time.Minute,
ResendCooldown: time.Duration(coolSec) * time.Second,
MaxAttempts: maxAtt,
}
}
func mustEnv(k string) string {
v := os.Getenv(k)
if v == "" {
panic("missing env " + k)
}
return v
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// rovela.go
package otp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type MailClient struct {
HTTP *http.Client
APIKey string
From string
}
type SendInput struct {
To string
Subject string
HTML string
Text string
IdempotencyKey string
}
func (c *MailClient) Send(ctx context.Context, in SendInput) (map[string]any, error) {
body, _ := json.Marshal(map[string]string{
"from": c.From,
"to": in.To,
"subject": in.Subject,
"html": in.HTML,
"text": in.Text,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.rovela.dev/emails", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", in.IdempotencyKey)
req.Header.Set("Accept", "application/json")
client := c.HTTP
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode >= 300 {
return nil, fmt.Errorf("rovela send failed: %d %s", res.StatusCode, string(raw))
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return out, nil
}
ROVELA_API_KEY 与 CODE_PEPPER 按普通服务端凭证接入(Authentication、Quick Start)。生产发 OTP 用 sending_access 密钥即可。
落库哈希:crypto/rand + HMAC,不要裸 SHA-256
六位码只有一百万种可能。泄露的裸哈希表几秒内就能反推。按文档:用永不离开服务器的 pepper 做 HMAC。
// crypto.go
package otp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
)
func NormalizeEmail(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func MintCode() (string, error) {
// CSPRNG — math/rand 不适合密钥材料
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
func HashCode(pepper []byte, email, code string) string {
mac := hmac.New(sha256.New, pepper)
mac.Write([]byte(email + ":" + code))
return hex.EncodeToString(mac.Sum(nil))
}
func SafeEqualHex(a, b string) bool {
ab, err1 := hex.DecodeString(a)
bb, err2 := hex.DecodeString(b)
if err1 != nil || err2 != nil || len(ab) != len(bb) {
return false
}
return hmac.Equal(ab, bb)
}
建议的表结构(每个邮箱一条有效码),对齐 验证码示例:
CREATE TABLE verification_codes (
email TEXT PRIMARY KEY,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
演示用内存方案: 单进程可用按邮箱索引的 sync.Map。多副本请用 Redis 或 Postgres。
服务:发码 + 验码(sync.Map 版)
// service.go
package otp
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
type record struct {
CodeHash string
Attempts int
ExpiresAt time.Time
SentAt time.Time
}
type Service struct {
Cfg Config
Mail *MailClient
store sync.Map // email -> record
}
const safeSendMsg = "若该地址可收信,验证码已在路上。"
func (s *Service) Send(ctx context.Context, rawEmail string) (map[string]any, error) {
email := NormalizeEmail(rawEmail)
now := time.Now().UTC()
if v, ok := s.store.Load(email); ok {
rec := v.(record)
if now.Sub(rec.SentAt) < s.Cfg.ResendCooldown {
return map[string]any{"ok": false, "error": "too_soon"}, nil
}
}
code, err := MintCode()
if err != nil {
return nil, err
}
codeHash := HashCode(s.Cfg.CodePepper, email, code)
s.store.Store(email, record{
CodeHash: codeHash,
Attempts: 0,
ExpiresAt: now.Add(s.Cfg.TTL),
SentAt: now,
})
// 同一逻辑发送用稳定键:哈希前缀。新码 ⇒ 新键。
idem := "otp-" + codeHash[:32]
ttlMin := int(s.Cfg.TTL.Minutes())
_, err = s.Mail.Send(ctx, SendInput{
To: email,
Subject: "Your Acme code: " + code,
HTML: "<p>Your verification code is</p>" +
`<p style="font-size:28px;font-weight:600;letter-spacing:4px">` + code + `</p>` +
fmt.Sprintf("<p>It expires in %d minutes. If you did not ask for it, ignore this email.</p>", ttlMin),
Text: fmt.Sprintf(
"Your verification code is %s. It expires in %d minutes. If you did not ask for it, ignore this email.",
code, ttlMin,
),
IdempotencyKey: idem,
})
if err != nil {
return nil, err
}
return map[string]any{"ok": true, "message": safeSendMsg}, nil
}
func (s *Service) Verify(rawEmail, rawCode string) map[string]any {
email := NormalizeEmail(rawEmail)
code := strings.TrimSpace(rawCode)
if len(code) != 6 || !allDigits(code) {
return map[string]any{"ok": false, "error": "invalid"}
}
v, ok := s.store.Load(email)
if !ok {
return map[string]any{"ok": false, "error": "invalid"}
}
rec := v.(record)
now := time.Now().UTC()
if now.After(rec.ExpiresAt) {
s.store.Delete(email)
return map[string]any{"ok": false, "error": "expired"}
}
if rec.Attempts >= s.Cfg.MaxAttempts {
return map[string]any{"ok": false, "error": "locked"}
}
if !SafeEqualHex(HashCode(s.Cfg.CodePepper, email, code), rec.CodeHash) {
rec.Attempts++
s.store.Store(email, rec)
return map[string]any{"ok": false, "error": "invalid"}
}
s.store.Delete(email)
// 在此签发 session / JWT
return map[string]any{"ok": true}
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
把码放进 subject 有利于锁屏预览——多数产品可接受,共享大屏场景可省略(验证码文档)。务必同时发送 html 与 text,并在 text 中带上数字(POST /emails)。
Redis:SET otp:{email} {json},TTL = 过期;另加 SET otp:cooldown:{email} 1 EX 60。SQL:INSERT … ON CONFLICT (email) DO UPDATE,事务里递增 attempts,成功后 DELETE。
Handler:net/http 或 chi + 限流
// handlers.go
package otp
import (
"encoding/json"
"net/http"
)
type Handlers struct {
Svc *Service
}
func (h *Handlers) Send(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Email == "" {
http.Error(w, `{"error":"invalid_email"}`, http.StatusBadRequest)
return
}
out, err := h.Svc.Send(r.Context(), body.Email)
if err != nil {
http.Error(w, `{"error":"send_failed"}`, http.StatusBadGateway)
return
}
if ok, _ := out["ok"].(bool); !ok {
if out["error"] == "too_soon" {
http.Error(w, `{"error":"too_soon"}`, http.StatusTooManyRequests)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"message": out["message"]})
}
func (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"invalid_json"}`, http.StatusBadRequest)
return
}
out := h.Svc.Verify(body.Email, body.Code)
if ok, _ := out["ok"].(bool); !ok {
status := http.StatusBadRequest
if out["error"] == "locked" || out["error"] == "too_soon" {
status = http.StatusTooManyRequests
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": out["error"]})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// main.go(标准库 net/http)
package main
import (
"log"
"net/http"
"example.com/app/otp"
)
func main() {
cfg := otp.LoadConfig()
svc := &otp.Service{
Cfg: cfg,
Mail: &otp.MailClient{APIKey: cfg.APIKey, From: cfg.From},
}
h := &otp.Handlers{Svc: svc}
mux := http.NewServeMux()
mux.Handle("POST /auth/otp/send", withIPLimit(http.HandlerFunc(h.Send), 10))
mux.Handle("POST /auth/otp/verify", withIPLimit(http.HandlerFunc(h.Verify), 20))
log.Fatal(http.ListenAndServe(":8080", mux))
}
// chi 挂载(可选)
// r := chi.NewRouter()
// r.Use(middleware.RealIP, middleware.Recoverer)
// r.With(ipLimit(10)).Post("/auth/otp/send", h.Send)
// r.With(ipLimit(20)).Post("/auth/otp/verify", h.Verify)
用按 IP 的令牌桶实现 withIPLimit / ipLimit(或前置 Cloudflare / nginx)。服务内按邮箱冷却,与按 IP 限流解决不同问题——两者都要。加固清单见 邮箱 OTP 安全。
Idempotency-Key:重试不会发两封码
Worker、反向代理与 AI agent 都会重试。没有 Idempotency-Key,一次超时就可能让收件箱出现两封不一致的码。
- Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原始响应(API: emails)。
- 由 code hash 派生(
otp-+ 前 32 位十六进制),使一次逻辑铸造对应一封入队邮件。 - 冷却后的主动重发会铸造新码 → 新哈希 → 新键。不要用旧键发新码。
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-go-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "you@example.com",
"subject": "Your Acme code: 482913",
"html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
"text": "Your verification code is 482913. Expires in 10 minutes."
}'
为什么 OTP 不用 SMTP dialer
net/http → POST /emails(本文) | net/smtp / MIME 库 | |
|---|---|---|
| 传输 | HTTPS 443 | 465 / 587(25 常被拦) |
| 幂等 | Idempotency-Key | 应用自己去重 |
| 反馈 | message id + webhook | 之后才有退信 DSN |
| 容器 / Cloud Run | 443 通常开放 | SMTP 常受限 |
| OTP 适配 | JSON html + text | 手写 MIME |
已经依赖 SMTP 时可以继续用。新的注册/登录数字码请调 HTTP API。更广的 Go 发送模式见 Go 发邮件。
认证数字码的投递注意(不是魔法 URL)
OTP 邮件短且敏感。优先用已验证子域,例如 security@mail.example.com,对齐 SPF/DKIM/DMARC,并保持 HTML 简洁。正文(以及常见的 subject)里的码不需要可点链接——点击追踪改写对魔法链接更关键——但打开/点击追踪仍应落在你控制的域名上。
若进垃圾箱,按 为什么 OTP / 魔法链接进垃圾箱 排查。产品该用数字码还是确认链接,见 验证码 vs 链接。
Go 里:验证码 vs 魔法链接
| 六位 OTP(本文) | 魔法链接 | |
|---|---|---|
| 用户动作 | 输入数字 | 点击 URL |
| 秘密熵 | 低 → 必须 HMAC + pepper | URL token 熵高 → 通常对 token 做 SHA-256 即可 |
| 文档形态 | 验证码 | 密码重置 / 链接模式 |
| Go 表面 | POST /auth/otp/send + POST /auth/otp/verify | POST /auth/magic/request + GET /auth/magic?token= |
| 点击追踪 | 可选关注 | 安全发信域上建议关闭 |
两者都调同一个 POST /emails。存储分开——不要把 OTP 记录当成魔法链接 token 复用。
上线前清单
crypto/rand+ HMAC pepper;永不存明文码;不用math/rand发 OTP。- 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
- upsert / 覆盖会使旧码失效;验码成功后 delete。
- 同时有
html和text;可选把码放进 subject 方便预览。 ROVELA_API_KEY(sending_access)只放服务端。Idempotency-Key对同一次逻辑发送保持稳定;新码 → 新键。- 若隐藏账号存在性,公开文案保持一致。
- OTP 优先 HTTP API 而非 SMTP;发信域已验证;进垃圾箱则按清单排查。
相关阅读
- 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
- 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
- UX 选择:邮箱验证:验证码 vs 链接。
- 投递:为什么 OTP / 魔法链接进垃圾箱。
- 框架姊妹篇:Express OTP · Django OTP · Laravel OTP · 魔法链接 Next.js / NestJS / Rails / FastAPI。
- 仅 Go 发送:Go 发邮件。
一句话: 把注册/登录 OTP 放进 Go handler——用 crypto/rand 铸码、HMAC 落库(sync.Map / Redis / SQL)、限流发码/验码,再带 Idempotency-Key 调一次 net/http POST 给 Rovela;优先于 SMTP dialer,也永远不要把邮件 API 当成认证服务器。