From 49b2384cf798dc0595234bcca60306a7e9183fda Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 27 Jun 2026 13:17:32 +0800 Subject: [PATCH] feat: email adapter with SMTP TLS support --- internal/adapter/adapter.go | 2 +- internal/adapter/email.go | 123 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 internal/adapter/email.go diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index a6cf66a..75e0ce2 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -22,7 +22,7 @@ func NewSender(channelType string, smtpCfg *config.SMTPConfig) (ChannelSender, e case "bark": return &BarkSender{}, nil case "email": - return nil, fmt.Errorf("email sender not yet implemented (see Task 10)") + return NewEmailSender(*smtpCfg), nil default: return nil, fmt.Errorf("unknown channel type: %s", channelType) } diff --git a/internal/adapter/email.go b/internal/adapter/email.go new file mode 100644 index 0000000..17e21da --- /dev/null +++ b/internal/adapter/email.go @@ -0,0 +1,123 @@ +package adapter + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/smtp" + "strings" + + "aiaa-notification-service/internal/config" +) + +type emailConfig struct { + To []string `json:"to"` +} + +type EmailSender struct { + cfg config.SMTPConfig +} + +func NewEmailSender(cfg config.SMTPConfig) *EmailSender { + return &EmailSender{cfg: cfg} +} + +func (s *EmailSender) Type() string { return "email" } + +func (s *EmailSender) Send(title, content string, config json.RawMessage) error { + var ecfg emailConfig + if err := json.Unmarshal(config, &ecfg); err != nil { + return fmt.Errorf("parse email config: %w", err) + } + if len(ecfg.To) == 0 { + return fmt.Errorf("email: 'to' list is empty") + } + + msg := buildEmail(s.cfg.From, ecfg.To, title, content) + addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) + + if s.cfg.Port == 465 { + return s.sendTLS(addr, ecfg.To, msg) + } + return s.sendSTARTTLS(addr, ecfg.To, msg) +} + +func (s *EmailSender) sendTLS(addr string, to []string, msg []byte) error { + tlsCfg := &tls.Config{ServerName: s.cfg.Host} + conn, err := tls.Dial("tcp", addr, tlsCfg) + if err != nil { + return fmt.Errorf("tls dial: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, s.cfg.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Quit() + + return s.authAndSend(client, to, msg) +} + +func (s *EmailSender) sendSTARTTLS(addr string, to []string, msg []byte) error { + conn, err := net.Dial("tcp", addr) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, s.cfg.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Quit() + + if ok, _ := client.Extension("STARTTLS"); ok { + tlsCfg := &tls.Config{ServerName: s.cfg.Host} + if err := client.StartTLS(tlsCfg); err != nil { + return fmt.Errorf("starttls: %w", err) + } + } + + return s.authAndSend(client, to, msg) +} + +func (s *EmailSender) authAndSend(client *smtp.Client, to []string, msg []byte) error { + if s.cfg.User != "" { + auth := smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("auth: %w", err) + } + } + + if err := client.Mail(s.cfg.From); err != nil { + return fmt.Errorf("mail: %w", err) + } + for _, recipient := range to { + if err := client.Rcpt(recipient); err != nil { + return fmt.Errorf("rcpt %s: %w", recipient, err) + } + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("data: %w", err) + } + _, err = w.Write(msg) + if err != nil { + return fmt.Errorf("write: %w", err) + } + return w.Close() +} + +func buildEmail(from string, to []string, subject, body string) []byte { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("From: %s\r\n", from)) + sb.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(to, ", "))) + sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + sb.WriteString("MIME-Version: 1.0\r\n") + sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + sb.WriteString("\r\n") + sb.WriteString(body) + return []byte(sb.String()) +}