package smtp import ( "bytes" "context" "crypto/rand" "crypto/tls" "encoding/hex" "fmt" "io" "mime" "net" "net/mail" stdsmtp "net/smtp" "os" "strings" "time" "internal-mail-skill/internal/config" ) type SendRequest struct { FromAddress string FromName string To []string Cc []string Bcc []string Subject string Body string InReplyTo string References string MessageIDHeader string } type SendResult struct { MessageIDHeader string Raw []byte Recipients []string SentAt time.Time } type Sender struct { cfg config.SMTPConfig } func NewSender(cfg config.SMTPConfig) *Sender { return &Sender{cfg: cfg} } func (s *Sender) Send(ctx context.Context, req SendRequest) (SendResult, error) { if req.FromAddress == "" { req.FromAddress = s.cfg.FromAddress } if req.FromName == "" { req.FromName = s.cfg.FromName } raw, recipients, err := BuildMessage(req) if err != nil { return SendResult{}, err } if len(recipients) == 0 { return SendResult{}, fmt.Errorf("no recipients") } if s.cfg.Host == "" { return SendResult{}, fmt.Errorf("SMTP_HOST is required") } auth, err := authForConfig(s.cfg) if err != nil { return SendResult{}, err } if err := s.sendRaw(ctx, req.FromAddress, recipients, raw, auth); err != nil { return SendResult{}, err } return SendResult{ MessageIDHeader: extractHeader(raw, "Message-ID"), Raw: raw, Recipients: recipients, SentAt: time.Now().UTC(), }, nil } func BuildMessage(req SendRequest) ([]byte, []string, error) { if req.FromAddress == "" { return nil, nil, fmt.Errorf("from address is required") } recipients := append([]string{}, req.To...) recipients = append(recipients, req.Cc...) recipients = append(recipients, req.Bcc...) if len(recipients) == 0 { return nil, nil, fmt.Errorf("at least one recipient is required") } messageID := req.MessageIDHeader if messageID == "" { messageID = newMessageID() } var b bytes.Buffer writeHeader(&b, "From", (&mail.Address{Name: req.FromName, Address: req.FromAddress}).String()) writeHeader(&b, "To", strings.Join(req.To, ", ")) if len(req.Cc) > 0 { writeHeader(&b, "Cc", strings.Join(req.Cc, ", ")) } writeHeader(&b, "Subject", mime.QEncoding.Encode("utf-8", req.Subject)) writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z)) writeHeader(&b, "Message-ID", messageID) if req.InReplyTo != "" { writeHeader(&b, "In-Reply-To", req.InReplyTo) } if req.References != "" { writeHeader(&b, "References", req.References) } writeHeader(&b, "MIME-Version", "1.0") writeHeader(&b, "Content-Type", `text/plain; charset="utf-8"`) writeHeader(&b, "Content-Transfer-Encoding", "8bit") b.WriteString("\r\n") body := strings.ReplaceAll(req.Body, "\n", "\r\n") body = strings.ReplaceAll(body, "\r\r\n", "\r\n") b.WriteString(body) if !strings.HasSuffix(body, "\r\n") { b.WriteString("\r\n") } return b.Bytes(), recipients, nil } func (s *Sender) sendRaw(ctx context.Context, from string, recipients []string, raw []byte, auth stdsmtp.Auth) error { timeout := s.cfg.Timeout() if timeout <= 0 { timeout = 30 * time.Second } address := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) dialer := &net.Dialer{Timeout: timeout} var conn net.Conn var err error if s.cfg.UseTLS { conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}) } else { conn, err = dialer.DialContext(ctx, "tcp", address) } if err != nil { return fmt.Errorf("connect SMTP: %w", err) } defer conn.Close() client, err := stdsmtp.NewClient(conn, s.cfg.Host) if err != nil { return fmt.Errorf("create SMTP client: %w", err) } defer client.Close() if s.cfg.UseSTARTTLS { if err := client.StartTLS(&tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil { return fmt.Errorf("starttls: %w", err) } } if auth != nil { if err := client.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } } if err := client.Mail(from); err != nil { return fmt.Errorf("smtp MAIL FROM: %w", err) } for _, recipient := range recipients { if strings.TrimSpace(recipient) == "" { continue } if err := client.Rcpt(recipient); err != nil { return fmt.Errorf("smtp RCPT TO %s: %w", recipient, err) } } writer, err := client.Data() if err != nil { return fmt.Errorf("smtp DATA: %w", err) } if _, err := io.Copy(writer, bytes.NewReader(raw)); err != nil { _ = writer.Close() return fmt.Errorf("write SMTP body: %w", err) } if err := writer.Close(); err != nil { return fmt.Errorf("close SMTP body: %w", err) } if err := client.Quit(); err != nil { return fmt.Errorf("smtp quit: %w", err) } return nil } func authForConfig(cfg config.SMTPConfig) (stdsmtp.Auth, error) { if !cfg.AuthRequired { return nil, nil } if !cfg.UseTLS && !cfg.UseSTARTTLS { if cfg.AllowInsecureAuth { return plainAuthNoTLSCheck{username: cfg.Username, password: cfg.Password}, nil } if cfg.Host != "localhost" && cfg.Host != "127.0.0.1" && cfg.Host != "::1" { return nil, fmt.Errorf("SMTP auth over cleartext requires SMTP_USE_TLS=true, SMTP_USE_STARTTLS=true, or SMTP_ALLOW_INSECURE_AUTH=true") } } if cfg.UseTLS { return plainAuthNoTLSCheck{username: cfg.Username, password: cfg.Password}, nil } return stdsmtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host), nil } type plainAuthNoTLSCheck struct { username string password string } func (a plainAuthNoTLSCheck) Start(server *stdsmtp.ServerInfo) (string, []byte, error) { resp := []byte("\x00" + a.username + "\x00" + a.password) return "PLAIN", resp, nil } func (a plainAuthNoTLSCheck) Next(fromServer []byte, more bool) ([]byte, error) { if more { return nil, fmt.Errorf("unexpected server challenge for PLAIN auth") } return nil, nil } func writeHeader(b *bytes.Buffer, key, value string) { if strings.TrimSpace(value) == "" { return } b.WriteString(key) b.WriteString(": ") b.WriteString(value) b.WriteString("\r\n") } func newMessageID() string { host, err := os.Hostname() if err != nil || host == "" { host = "localhost" } var random [8]byte if _, err := rand.Read(random[:]); err != nil { return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), host) } return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(random[:]), host) } func extractHeader(raw []byte, name string) string { prefix := strings.ToLower(name) + ":" for _, line := range strings.Split(string(raw), "\r\n") { if strings.HasPrefix(strings.ToLower(line), prefix) { return strings.TrimSpace(line[len(name)+1:]) } } return "" }