邮件发送
邮件能力集中在 server/plugin/email/(不在 util/):email.go 负责基础 SMTP 发送,code.go 负责邮箱验证码场景(Redis 限频 / 失败锁定 / 异步入队),配合 app/queue/ 消费者与 notice_service 完成通知邮件的延迟补推。底层依赖 github.com/wneessen/go-mail。
一、基础发送(plugin/email/email.go)
配置
.env.yaml 的 Email 段是账号数组,发送时随机挑一个分摊限流:
Email:
- { Host: smtp.example.com, Port: 465, SSL: true, Username: u1, Password: p1, Timeout: 10 }
- { Host: smtp.example.com, Port: 465, SSL: true, Username: u2, Password: p2, Timeout: 10 }字段对应 config.EmailConfigStruct(mapstructure:"Host/Port/SSL/Username/Password/Timeout"),全局切片是 config.EmailConfig;发件人直接使用 Username,无独立 From 字段。
核心 API(包级函数,非实例)
// plugin/email/email.go
type EmailOptions struct {
To []string // 收件人邮箱列表(必填)
Cc []string // 抄送
Bcc []string // 密送
Subject string // 主题
HTMLBody string // 优先使用
TextBody string // HTMLBody 为空时使用;两者皆空则报错
Attachments []map[string]string // 每项含 path / filename,走 mail.AttachFile
}
// 唯一入口,内部先 getEmailConfig() 随机取账号,再构造 go-mail 客户端投递
func SendEmail(opts EmailOptions) error- 多账号:
getEmailConfig()用util.ToolsUtil.Random(0, len(config.EmailConfig))从切片随机取一条;空切片直接返回"没有可用的邮件配置"。 - SSL:按账号
SSL字段决定是否mail.WithSSL();认证固定SMTPAuthPlain。 - 超时:
Timeout<=0兜底 10 秒。 - 失败仅返回
error,不重试;重试 / 补推由调用方(如通知补推、验证码队列)实现。
二、邮箱验证码(plugin/email/code.go)
面向"注册 / 找回密码 / 绑定 / 解绑",Redis 存储 + 双重限频 + 失败锁定,SMTP 走异步入队。
场景常量
const (
CodeSceneRegister = "register" // 注册账号
CodeSceneReset = "reset" // 重置密码
CodeSceneBind = "bind" // 绑定邮箱
CodeSceneUnbind = "unbind" // 解绑手机(用邮箱验证码确认身份)
)核心 API
// uid 为可选:非空时 key 变为 user:code:{scene}:{uid}:{email},把验证码与具体账号绑定,
// 防止跨账号滥用;为空则保持 user:code:{scene}:{email}(向后兼容)。
// 校验时 uid 必须与发送时一致,否则取不到验证码。
func SendCode(email, scene, uid string) error
func VerifyCode(email, scene, code, uid string) error任务载荷(推入异步队列):
type EmailCodeTask struct {
To string `json:"to"`
Subject string `json:"subject"`
HTMLBody string `json:"html_body"`
CreatedAt int64 `json:"created_at"` // 入队时间戳(秒),供 worker 判过期
}流程
- 60s 频率限制(并发安全):
SET user:code:limit:{email} NX EX 60——用SetNX抢占窗口,避免并发请求同时通过。 - 每日上限 10 次(并发安全):
INCR user:code:daily:{email}:{YYYY-MM-DD},首次自增后EXPIRE 86400;INCR返回值>10拒绝发送。 - 生成 6 位码:
util.ToolsUtil.Random(100000, 999999);写入 Redis,TTL300s(codeExpireSec)。 - 清旧失败计数:
DEL user:code:fail:{codeKey},防止上一版验证码失败次数串到新码上。 - 异步入队:组装
EmailCodeTask投queue_schema.QueueEmailCode(值"email:code:send"),走core.Queue.Enqueue;入队失败仅记日志并回传"验证码发送失败"。 - worker 消费:
app/queue/email_code.go::ProcessEmailCode(core.Queue.Consume(..., 3)3 并发),入队超过 60 秒自动丢弃(验证码时效优先,避免积压时推过期码)。 - 校验 + 失败锁定:
VerifyCode命中即通过并DEL验证码与失败计数;不匹配则INCR user:code:fail:{codeKey}(首次EXPIRE 300s与验证码同周期),累计错误 10 次即作废当前验证码(codeMaxFailTimes),拒绝继续尝试,防止暴力猜测。
关键常量 / Key
// 队列名(app/schema/queue_schema/queue.go)
QueueEmailCode = "email:code:send"
// Redis key 模板(plugin/email/code.go)
user:code:{scene}:{email} // uid 为空
user:code:{scene}:{uid}:{email} // uid 非空,与账号绑定
user:code:limit:{email} // 60s 频率限制
user:code:daily:{email}:{date} // 每日上限
user:code:fail:{codeKey} // 校验失败计数(10 次作废)
// 时长常量
codeExpireSec = 300 // 验证码 5 分钟
codeLimitSec = 60 // 发送频率窗口
codeDailyTTL = 86400 // 每日计数保留
codeDailyLimit = 10 // 同邮箱每日上限
codeMaxFailTimes = 10 // 单验证码失败上限使用方(示例)
// 发送
if err := email.SendCode(req.Email, email.CodeSceneReset, ""); err != nil { return err }
// 校验
if err := email.VerifyCode(req.Email, email.CodeSceneRegister, req.Code, ""); err != nil { return err }调用点:user_service/user_service.go(注册 / 找回密码 / 通用发码)、user_service/auth_service.go(解绑手机)、system_service/forget_pwd_service.go(后台忘记密码)。
三、通知邮件补推(notice_service)
系统通知同时支持 WebSocket 即时送达 + 邮件延迟兜底,由独立延迟队列 core/queue_delay 保障:
- 发送通知时若
needSend=true,notice_service.Send会把NoticeEmailDelayTask投到延迟队列queue_schema.QueueNoticeEmailDelay(值"notice:email:delay"),延迟时长 =config.NoticeConfig.EmailDelaySeconds(默认 120s)。 - 到期后由消费者
ProcessNoticeEmailDelay转交NoticeService.PushUserEmail(receiverID)合成邮件,再入即时队列queue_schema.QueueNoticeEmail(值"notice:email")。PushUserEmail有三重过滤,确保只推该推的:- 未读:仅查
is_read = 0,用户在延迟期内已读则查询为空、直接跳过; - 未推送:仅查
is_emailed = 0,去重不重复; - 渠道开启:校验
x_system_notice_setting(channel='email'),用户需显式开启才补推,未配置或is_enabled=0则跳过。
- 未读:仅查
- 即时队列 worker
ProcessNoticeEmail(3 并发)调email.SendEmail;无论成败都回写最终状态(2成功 /3失败)到通知记录,return nil,避免队列无限重试。 - 延迟队列基于
core/queue_delay(Redis ZSET 到期轮询),与即时队列core/queue独立,注册见app/queue/start.go。
邮件推送状态流转(x_system_notice.is_emailed)
| 值 | 常量 | 含义 | 流转 |
|---|---|---|---|
-1 | EmailStatusNotSend | 不发送 | Send(send=false) 初值,不参与邮件推送 |
0 | EmailStatusPending | 待发送 | Send(send=true) 初值,等待延迟队列到期 |
1 | EmailStatusSending | 发送中 | PushUserEmail 入队 notice:email 后立即标记(防重复) |
2 | EmailStatusSuccess | 发送成功 | ProcessNoticeEmail 发送后回写 |
3 | EmailStatusFailed | 发送失败 | ProcessNoticeEmail 发送失败回写 |
链路:-1 不发送 / 0 待发送 →(延迟到期)PushUserEmail 标记 1 发送中 →(即时队列发送)ProcessNoticeEmail 回写 2/3。PushUserEmail 仅查询 is_emailed = 0,因此 1/2/3 状态的通知不会被重复推送。
四、扩展:新增邮件场景
- 简单同步发送:直接
email.SendEmail(email.EmailOptions{ To: []string{...}, Subject: "...", HTMLBody: "..." })。 - 需限频 / 异步:仿
plugin/email/code.go——定义任务载荷 + 队列名(放app/schema/queue_schema/),投递到core.Queue,在app/queue/start.go用core.Queue.Consume(ctx, 队列名, 处理函数, 并发)注册消费者。 - 需延迟 / 状态追踪:仿通知补推——用
core.QueueDelay.EnqueueDelayed(...)投递到期任务,业务表加状态字段(如is_emailed),由 worker 回写。
五、依赖与配置
- SMTP 账号:
config/email.go的EmailConfig []EmailConfigStruct,来自.env.yaml的Email数组。 - 验证码 / 限频 / 失败计数:Redis(
config/redis.go)。 - 异步与延迟发送:
core.Queue/core.QueueDelay(见 异步队列)。 - SMTP 库:
github.com/wneessen/go-mail。