← 返回博客

Go 发送邮箱验证码(2026):用 HTTP API 发 OTP

在 Go 里做注册/登录邮箱验证码:crypto/rand、HMAC 落 sync.Map/Redis/SQL、限流、用 net/http 或 chi 调 POST /emails,以及 Idempotency-Key——优先 HTTP 而非 SMTP;Rovela 只负责投递。

更新于 2026年11月4日
Go 发送邮箱验证码(2026):用 HTTP API 发 OTP

为什么用 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 443465 / 587(25 常被拦)
幂等Idempotency-Key应用自己去重
反馈message id + webhook之后才有退信 DSN
容器 / Cloud Run443 通常开放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 + pepperURL token 熵高 → 通常对 token 做 SHA-256 即可
文档形态验证码密码重置 / 链接模式
Go 表面POST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
点击追踪可选关注安全发信域上建议关闭

两者都调同一个 POST /emails。存储分开——不要把 OTP 记录当成魔法链接 token 复用。

上线前清单

  1. crypto/rand + HMAC pepper;永不存明文码;不用 math/rand 发 OTP。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
  3. upsert / 覆盖会使旧码失效;验码成功后 delete。
  4. 同时有 html 和 text;可选把码放进 subject 方便预览。
  5. ROVELA_API_KEY(sending_access)只放服务端。
  6. Idempotency-Key 对同一次逻辑发送保持稳定;新码 → 新键。
  7. 若隐藏账号存在性,公开文案保持一致。
  8. OTP 优先 HTTP API 而非 SMTP;发信域已验证;进垃圾箱则按清单排查。

相关阅读

  1. 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
  2. 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
  3. UX 选择:邮箱验证:验证码 vs 链接。
  4. 投递:为什么 OTP / 魔法链接进垃圾箱。
  5. 框架姊妹篇:Express OTP · Django OTP · Laravel OTP · 魔法链接 Next.js / NestJS / Rails / FastAPI。
  6. 仅 Go 发送:Go 发邮件。

一句话: 把注册/登录 OTP 放进 Go handler——用 crypto/rand 铸码、HMAC 落库(sync.Map / Redis / SQL)、限流发码/验码,再带 Idempotency-Key 调一次 net/http POST 给 Rovela;优先于 SMTP dialer,也永远不要把邮件 API 当成认证服务器。