// Package notify 内的 notifier_email.go 实现 EmailNotifier:通过 SMTP(net/smtp) // 把通知投递为邮件。它实现 Notifier 接口,由 Dispatcher 在配置启用时注册。 // // 投递前置条件(任一不满足则静默跳过,返回 nil——per-channel 失败/跳过对 // Dispatcher 非致命): // - 全局 cfg.Enabled 为 true; // - 用户 notification_prefs.email_enabled 为 true; // - 消息 severity 达到用户 min_severity; // - 能解析出该用户的收件邮箱。 // // SMTP 调用经 transport 抽象注入,测试用 fake transport 断言收件人/正文形态, // 绝不真的发邮件;生产用 smtpTransport(net/smtp.SendMail)。 package notify import ( "context" "fmt" "log/slog" "net/smtp" "strings" "github.com/google/uuid" ) // EmailConfig 是 EmailNotifier 的配置,对应 cfg.Notify.Email。 type EmailConfig struct { Enabled bool SMTPHost string Port int From string Username string Password string } // addr 返回 "host:port" 形式的 SMTP 地址。 func (c EmailConfig) addr() string { return fmt.Sprintf("%s:%d", c.SMTPHost, c.Port) } // EmailLookup 把 userID 解析为收件邮箱。由 user.Service 适配注入,避免 notify // 反向依赖 user 包造成的循环导入。 type EmailLookup interface { EmailForUser(ctx context.Context, userID uuid.UUID) (string, error) } // SMTPTransport 抽象一次 SMTP 发送,便于测试注入 fake。参数与 net/smtp.SendMail // 对齐:addr 形如 "host:port",auth 可为 nil(无认证),from 为发件人,to 为收件人 // 列表,msg 为完整 RFC 822 报文(含头)。 type SMTPTransport interface { Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error } // smtpTransport 是 SMTPTransport 的生产实现,直接委托 net/smtp.SendMail。 type smtpTransport struct{} func (smtpTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { return smtp.SendMail(addr, auth, from, to, msg) } // EmailNotifier 通过 SMTP 投递邮件,实现 Notifier。 type EmailNotifier struct { cfg EmailConfig prefs PrefsRepository lookup EmailLookup tr SMTPTransport log *slog.Logger } // NewEmailNotifier 构造 EmailNotifier。tr 为 nil 时用生产 net/smtp 实现; // log 为 nil 时回落到 slog.Default。 func NewEmailNotifier(cfg EmailConfig, prefs PrefsRepository, lookup EmailLookup, tr SMTPTransport, log *slog.Logger) *EmailNotifier { if tr == nil { tr = smtpTransport{} } if log == nil { log = slog.Default() } return &EmailNotifier{cfg: cfg, prefs: prefs, lookup: lookup, tr: tr, log: log} } // Name 返回 ChannelEmail。 func (n *EmailNotifier) Name() Channel { return ChannelEmail } // Send 在满足全部前置条件时发送一封邮件;任一条件不满足静默返回 nil。 // SMTP 调用失败返回错误(Dispatcher 仅记日志,不影响其它通道)。 func (n *EmailNotifier) Send(ctx context.Context, msg Message) error { if !n.cfg.Enabled { return nil } p, err := n.prefs.Get(ctx, msg.UserID) if err != nil { return err } if !p.EmailEnabled { return nil } if !meetsMinSeverity(msg.Severity, p.MinSeverity) { return nil } to, err := n.lookup.EmailForUser(ctx, msg.UserID) if err != nil { return err } if strings.TrimSpace(to) == "" { return nil } var auth smtp.Auth if n.cfg.Username != "" { auth = smtp.PlainAuth("", n.cfg.Username, n.cfg.Password, n.cfg.SMTPHost) } body := buildEmailMessage(n.cfg.From, to, msg) if err := n.tr.Send(n.cfg.addr(), auth, n.cfg.From, []string{to}, body); err != nil { return fmt.Errorf("smtp send: %w", err) } return nil } // buildEmailMessage 组装一封最小 RFC 822 纯文本邮件报文(含 From/To/Subject 头)。 // 主题前缀用 severity 便于客户端过滤;正文带标题、正文与可选链接。 func buildEmailMessage(from, to string, msg Message) []byte { subject := msg.Title if subject == "" { subject = msg.Topic } var b strings.Builder fmt.Fprintf(&b, "From: %s\r\n", from) fmt.Fprintf(&b, "To: %s\r\n", to) fmt.Fprintf(&b, "Subject: [%s] %s\r\n", strings.ToUpper(string(msg.Severity)), subject) b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") b.WriteString("\r\n") if msg.Body != "" { b.WriteString(msg.Body) b.WriteString("\r\n") } if msg.Link != "" { fmt.Fprintf(&b, "\r\n%s\r\n", msg.Link) } return []byte(b.String()) }