直接用 net/smtp 发邮件在 Gin 里容易失败,根本原因是未设超时、未启用 STARTTLS、未按 RFC 2047 编码中文 header 和 body,且缺乏错误分类处理与上下文控制。

为什么直接用 net/smtp 发邮件在 Gin 里容易失败
Gin 本身不处理邮件,真正发验证码靠的是 Go 标准库的 net/smtp 或第三方 SMTP 客户端。但很多人一上来就写个 smtp.SendMail 放进 HTTP handler,结果遇到超时、连接被重置、中文乱码或被邮箱服务商拒收——根本原因是没设超时、没加 TLS、没正确编码 header 和 body。
- SMTP 连接默认无超时,HTTP 请求可能卡住几十秒
- Gmail / QQ 邮箱等强制要求 STARTTLS,裸 TCP 会直接拒绝
- 验证码邮件主题/正文含中文时,必须用
mime.BEncoding编码,否则显示为乱码或被当垃圾邮件 - 别硬编码密码:用环境变量(如
SMTP_PASSWORD)或配置文件,避免提交到 Git
怎么构造一封合规的验证码邮件(带 UTF-8 主题和纯文本正文)
Go 标准库没有开箱即用的 MIME 构建器,得手动拼 header。重点是 Subject 和 Content-Type 两行必须按 RFC 2047 编码,否则 Outlook 或 Apple Mail 会显示异常。
func buildVerifyEmail(to, code string) []byte {
subject := "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte("【验证码】您的登录验证码")) + "?="
body := fmt.Sprintf("您的验证码是:%s\n5分钟内有效。", code)
msg := []byte(
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"Content-Transfer-Encoding: base64\r\n" +
"\r\n" +
base64.StdEncoding.EncodeToString([]byte(body)) + "\r\n",
)
return msg
}
-
Subject必须用=?UTF-8?B?...格式,不能只写中文字符串 - 正文用
base64编码,比quoted-printable更稳妥,尤其含 emoji 或特殊符号时 - 每行结尾必须是
\r\n,不是\n,否则某些 SMTP 服务会截断
如何在 Gin handler 中安全调用发送逻辑并返回结构化响应
验证码发送不能阻塞 HTTP 响应,但也不建议盲目扔进 goroutine——万一并发量大,没限流会打爆 SMTP 服务器或触发风控。更合理的做法是同步发、带 context 超时、错误分类返回。
func sendCodeHandler(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "邮箱格式错误"})
return
}
code := generateCode() // 6位数字,存入 Redis 并设 TTL
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
err := sendEmail(ctx, req.Email, code)
if err != nil {
switch {
case strings.Contains(err.Error(), "timeout"):
c.JSON(504, gin.H{"error": "邮件发送超时,请稍后重试"})
case strings.Contains(err.Error(), "authentication"):
c.JSON(500, gin.H{"error": "邮件服务配置错误"})
default:
c.JSON(500, gin.H{"error": "发送失败,请检查邮箱地址"})
}
return
}
c.JSON(200, gin.H{"message": "验证码已发送"})
}
- 用
context.WithTimeout控制整个 SMTP 流程,别只包smtp.SendMail - 错误类型要区分:认证失败、网络超时、邮箱不存在(后者通常 SMTP 不报错,得靠后续退信分析)
- 别在 handler 里生成随机数用
rand.Intn—— 没 seed 会固定,改用rand.New(rand.NewSource(time.Now().UnixNano()))
Redis 存验证码时 key 设计和过期时间怎么设才不容易出问题
key 名不能只用邮箱,否则同一邮箱多次请求会覆盖;也不能带时间戳导致查不到。最简方案是 "verify:email:" + md5(email),value 存 JSON:{"code":"123456","ts":1717023456}。
立即学习“go语言免费学习笔记(深入)”;
- 过期时间统一设 300 秒(5 分钟),用
SET key value EX 300,别先SET再EXPIRE—— 有竞态风险 - 校验时用
GET读出再解析,别用 Lua 脚本原子操作——除非你真需要防止重放,普通登录场景没必要 - 别把验证码明文存 Redis 日志里,调试时用
log.Printf("sent to %s, code: ****")
SMTP 配置、Redis 连接、验证码生成这三块逻辑必须拆成独立函数,方便单元测试和替换 mock。线上千万别用 163 免费邮箱发验证码——日限额低、IP 经常被封,至少用腾讯企业邮箱或 SendGrid。


















