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) } } // MAIL FROM must be a bare address; display names like "Name " are invalid here. fromAddr := extractEmailAddr(s.cfg.From) if err := client.Mail(fromAddr); 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()) } // extractEmailAddr returns the bare address from "Name " or the original string. func extractEmailAddr(from string) string { start := strings.LastIndex(from, "<") end := strings.LastIndex(from, ">") if start >= 0 && end > start { return strings.TrimSpace(from[start+1 : end]) } return strings.TrimSpace(from) }