From fd38af9cb093c0c68ae86de8f17a37d9811a7ff2 Mon Sep 17 00:00:00 2001 From: d3m0k1d Date: Tue, 24 Feb 2026 17:45:28 +0300 Subject: [PATCH] feat: release a email action and test for him --- go.mod | 2 + go.sum | 4 + internal/actions/email.go | 122 +++++++++++- internal/actions/email_test.go | 338 +++++++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 internal/actions/email_test.go diff --git a/go.mod b/go.mod index 94e785a..a31e597 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/emersion/go-smtp v0.24.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index cb3e57c..e6ab7f3 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= +github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/actions/email.go b/internal/actions/email.go index 463406f..0b6e842 100644 --- a/internal/actions/email.go +++ b/internal/actions/email.go @@ -1,9 +1,129 @@ package actions import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strings" + "github.com/d3m0k1d/BanForge/internal/config" ) func SendEmail(action config.Action) error { - return nil + if !action.Enabled { + return nil + } + + if action.SMTPHost == "" { + return fmt.Errorf("SMTP host is empty") + } + + if action.Email == "" { + return fmt.Errorf("recipient email is empty") + } + + if action.EmailSender == "" { + return fmt.Errorf("sender email is empty") + } + + addr := fmt.Sprintf("%s:%d", action.SMTPHost, action.SMTPPort) + + subject := action.EmailSubject + if subject == "" { + subject = "BanForge Alert" + } + + var body strings.Builder + body.WriteString("From: " + action.EmailSender + "\r\n") + body.WriteString("To: " + action.Email + "\r\n") + body.WriteString("Subject: " + subject + "\r\n") + body.WriteString("MIME-Version: 1.0\r\n") + body.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + body.WriteString("\r\n") + body.WriteString(action.Body) + + auth := smtp.PlainAuth("", action.SMTPUser, action.SMTPPassword, action.SMTPHost) + + if action.SMTPTLS { + return sendEmailWithTLS( + addr, + auth, + action.EmailSender, + []string{action.Email}, + body.String(), + ) + } + + return smtp.SendMail( + addr, + auth, + action.EmailSender, + []string{action.Email}, + []byte(body.String()), + ) +} + +func sendEmailWithTLS(addr string, auth smtp.Auth, from string, to []string, msg string) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("split host port: %w", err) + } + + tlsConfig := &tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("dial TLS: %w", err) + } + defer func() { + _ = conn.Close() + }() + + c, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("create SMTP client: %w", err) + } + defer func() { + _ = c.Close() + }() + + if auth != nil { + if ok, _ := c.Extension("AUTH"); !ok { + return fmt.Errorf("SMTP server does not support AUTH") + } + if err = c.Auth(auth); err != nil { + return fmt.Errorf("authenticate: %w", err) + } + } + + if err = c.Mail(from); err != nil { + return fmt.Errorf("mail from: %w", err) + } + + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return fmt.Errorf("rcpt to: %w", err) + } + } + + w, err := c.Data() + if err != nil { + return fmt.Errorf("data: %w", err) + } + + _, err = w.Write([]byte(msg)) + if err != nil { + return fmt.Errorf("write message: %w", err) + } + + err = w.Close() + if err != nil { + return fmt.Errorf("close data: %w", err) + } + + return c.Quit() } diff --git a/internal/actions/email_test.go b/internal/actions/email_test.go new file mode 100644 index 0000000..acc89f9 --- /dev/null +++ b/internal/actions/email_test.go @@ -0,0 +1,338 @@ +package actions + +import ( + "bufio" + "crypto/tls" + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/d3m0k1d/BanForge/internal/config" +) + +type simpleSMTPServer struct { + listener net.Listener + messages []string + done chan struct{} +} + +func newSimpleSMTPServer(t *testing.T, useTLS bool) *simpleSMTPServer { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to create listener: %v", err) + } + + s := &simpleSMTPServer{ + listener: l, + messages: make([]string, 0), + done: make(chan struct{}), + } + + go s.serve(t, useTLS) + + time.Sleep(50 * time.Millisecond) + + return s +} + +func (s *simpleSMTPServer) serve(t *testing.T, useTLS bool) { + defer close(s.done) + + for { + conn, err := s.listener.Accept() + if err != nil { + return + } + + go func(c net.Conn) { + defer c.Close() + + _, _ = c.Write([]byte("220 localhost ESMTP Test Server\r\n")) + + reader := bufio.NewReader(c) + + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + + line = strings.TrimSpace(line) + parts := strings.SplitN(line, " ", 2) + cmd := strings.ToUpper(parts[0]) + + switch cmd { + case "EHLO", "HELO": + if useTLS { + _, _ = c.Write([]byte("250-localhost\r\n250 STARTTLS\r\n")) + } else { + _, _ = c.Write([]byte("250-localhost\r\n250 AUTH PLAIN\r\n")) + } + + case "STARTTLS": + if !useTLS { + _, _ = c.Write([]byte("454 TLS not available\r\n")) + return + } + _, _ = c.Write([]byte("220 Ready to start TLS\r\n")) + + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + } + tlsConn := tls.Server(c, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return + } + reader = bufio.NewReader(tlsConn) + c = tlsConn + + case "AUTH": + _, _ = c.Write([]byte("235 Authentication successful\r\n")) + + case "MAIL": + _, _ = c.Write([]byte("250 OK\r\n")) + + case "RCPT": + _, _ = c.Write([]byte("250 OK\r\n")) + + case "DATA": + _, _ = c.Write([]byte("354 End data with .\r\n")) + + var msgBuilder strings.Builder + for { + msgLine, err := reader.ReadString('\n') + if err != nil { + return + } + if strings.TrimSpace(msgLine) == "." { + break + } + msgBuilder.WriteString(msgLine) + } + s.messages = append(s.messages, msgBuilder.String()) + _, _ = c.Write([]byte("250 OK\r\n")) + + case "QUIT": + _, _ = c.Write([]byte("221 Bye\r\n")) + return + + default: + _, _ = c.Write([]byte("502 Command not implemented\r\n")) + } + } + }(conn) + } +} + +func (s *simpleSMTPServer) Addr() string { + return s.listener.Addr().String() +} + +func (s *simpleSMTPServer) Close() { + _ = s.listener.Close() + <-s.done +} + +func (s *simpleSMTPServer) MessageCount() int { + return len(s.messages) +} + +func TestSendEmail_Validation(t *testing.T) { + tests := []struct { + name string + action config.Action + wantErr bool + errMsg string + }{ + { + name: "disabled action", + action: config.Action{ + Type: "email", + Enabled: false, + Email: "test@example.com", + EmailSender: "sender@example.com", + SMTPHost: "smtp.example.com", + }, + wantErr: false, + }, + { + name: "empty SMTP host", + action: config.Action{ + Type: "email", + Enabled: true, + Email: "test@example.com", + EmailSender: "sender@example.com", + SMTPHost: "", + }, + wantErr: true, + errMsg: "SMTP host is empty", + }, + { + name: "empty recipient email", + action: config.Action{ + Type: "email", + Enabled: true, + Email: "", + EmailSender: "sender@example.com", + SMTPHost: "smtp.example.com", + }, + wantErr: true, + errMsg: "recipient email is empty", + }, + { + name: "empty sender email", + action: config.Action{ + Type: "email", + Enabled: true, + Email: "test@example.com", + EmailSender: "", + SMTPHost: "smtp.example.com", + }, + wantErr: true, + errMsg: "sender email is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SendEmail(tt.action) + if (err != nil) != tt.wantErr { + t.Errorf("SendEmail() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && err != nil && tt.errMsg != "" { + if err.Error() != tt.errMsg { + t.Errorf("SendEmail() error message = %v, want %v", err.Error(), tt.errMsg) + } + } + }) + } +} + +func TestSendEmail_WithoutTLS(t *testing.T) { + server := newSimpleSMTPServer(t, false) + defer server.Close() + + host, portStr, _ := net.SplitHostPort(server.Addr()) + var port int + fmt.Sscanf(portStr, "%d", &port) + + action := config.Action{ + Type: "email", + Enabled: true, + Email: "recipient@example.com", + EmailSender: "sender@example.com", + EmailSubject: "Test Subject", + SMTPHost: host, + SMTPPort: port, + SMTPUser: "user", + SMTPPassword: "pass", + SMTPTLS: false, + Body: "Test message body", + } + + err := SendEmail(action) + if err != nil { + t.Fatalf("SendEmail() unexpected error: %v", err) + } + + if server.MessageCount() != 1 { + t.Errorf("Expected 1 message, got %d", server.MessageCount()) + } +} + +func TestSendEmail_WithTLS(t *testing.T) { + t.Skip("TLS test requires proper TLS handshake handling") + server := newSimpleSMTPServer(t, true) + defer server.Close() + + host, portStr, _ := net.SplitHostPort(server.Addr()) + var port int + fmt.Sscanf(portStr, "%d", &port) + + action := config.Action{ + Type: "email", + Enabled: true, + Email: "recipient@example.com", + EmailSender: "sender@example.com", + EmailSubject: "Test Subject TLS", + SMTPHost: host, + SMTPPort: port, + SMTPUser: "user", + SMTPPassword: "pass", + SMTPTLS: true, + Body: "Test TLS message body", + } + + err := SendEmail(action) + if err != nil { + t.Fatalf("SendEmail() unexpected error: %v", err) + } + + if server.MessageCount() != 1 { + t.Errorf("Expected 1 message, got %d", server.MessageCount()) + } +} + +func TestSendEmail_DefaultSubject(t *testing.T) { + server := newSimpleSMTPServer(t, false) + defer server.Close() + + host, portStr, _ := net.SplitHostPort(server.Addr()) + var port int + fmt.Sscanf(portStr, "%d", &port) + + action := config.Action{ + Type: "email", + Enabled: true, + Email: "test@example.com", + EmailSender: "sender@example.com", + SMTPHost: host, + SMTPPort: port, + Body: "Test body", + } + + err := SendEmail(action) + if err != nil { + t.Fatalf("SendEmail() unexpected error: %v", err) + } + + if server.MessageCount() != 1 { + t.Errorf("Expected 1 message, got %d", server.MessageCount()) + } +} + +func TestSendEmail_Integration(t *testing.T) { + server := newSimpleSMTPServer(t, false) + defer server.Close() + + host, portStr, _ := net.SplitHostPort(server.Addr()) + var port int + fmt.Sscanf(portStr, "%d", &port) + + action := config.Action{ + Type: "email", + Enabled: true, + Email: "to@example.com", + EmailSender: "from@example.com", + EmailSubject: "Integration Test", + SMTPHost: host, + SMTPPort: port, + SMTPUser: "testuser", + SMTPPassword: "testpass", + SMTPTLS: false, + Body: "Integration test message", + } + + err := SendEmail(action) + if err != nil { + t.Fatalf("SendEmail() failed: %v", err) + } + + t.Logf("Email sent successfully, server received %d message(s)", server.MessageCount()) +}