Skip to content

邮件发送 ​

邮件能力集中在 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 段是账号数组,发送时随机挑一个分摊限流:

yaml
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(包级函数,非实例) ​

go
// 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 走异步入队。

场景常量 ​

go
const (
    CodeSceneRegister = "register" // 注册账号
    CodeSceneReset    = "reset"    // 重置密码
    CodeSceneBind     = "bind"     // 绑定邮箱
    CodeSceneUnbind   = "unbind"   // 解绑手机(用邮箱验证码确认身份)
)

核心 API ​

go
// 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

任务载荷(推入异步队列):

go
type EmailCodeTask struct {
    To        string `json:"to"`
    Subject   string `json:"subject"`
    HTMLBody  string `json:"html_body"`
    CreatedAt int64  `json:"created_at"` // 入队时间戳(秒),供 worker 判过期
}

流程 ​

  1. 60s 频率限制(并发安全):SET user:code:limit:{email} NX EX 60——用 SetNX 抢占窗口,避免并发请求同时通过。
  2. 每日上限 10 次(并发安全):INCR user:code:daily:{email}:{YYYY-MM-DD},首次自增后 EXPIRE 86400;INCR 返回值 >10 拒绝发送。
  3. 生成 6 位码:util.ToolsUtil.Random(100000, 999999);写入 Redis,TTL 300s(codeExpireSec)。
  4. 清旧失败计数:DEL user:code:fail:{codeKey},防止上一版验证码失败次数串到新码上。
  5. 异步入队:组装 EmailCodeTask 投 queue_schema.QueueEmailCode(值 "email:code:send"),走 core.Queue.Enqueue;入队失败仅记日志并回传"验证码发送失败"。
  6. worker 消费:app/queue/email_code.go::ProcessEmailCode(core.Queue.Consume(..., 3) 3 并发),入队超过 60 秒自动丢弃(验证码时效优先,避免积压时推过期码)。
  7. 校验 + 失败锁定:VerifyCode 命中即通过并 DEL 验证码与失败计数;不匹配则 INCR user:code:fail:{codeKey}(首次 EXPIRE 300s 与验证码同周期),累计错误 10 次即作废当前验证码(codeMaxFailTimes),拒绝继续尝试,防止暴力猜测。

关键常量 / Key ​

go
// 队列名(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       // 单验证码失败上限

使用方(示例) ​

go
// 发送
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) ​

值常量含义流转
-1EmailStatusNotSend不发送Send(send=false) 初值,不参与邮件推送
0EmailStatusPending待发送Send(send=true) 初值,等待延迟队列到期
1EmailStatusSending发送中PushUserEmail 入队 notice:email 后立即标记(防重复)
2EmailStatusSuccess发送成功ProcessNoticeEmail 发送后回写
3EmailStatusFailed发送失败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。