Files
agentic-coding-workflow/internal/infra/notify/notifier_email.go
T
2026-06-22 08:55:57 +08:00

142 lines
4.4 KiB
Go

// 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())
}