You've already forked agentic-coding-workflow
142 lines
4.4 KiB
Go
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())
|
|
}
|