From d30a4a73c6d8c12e8d900bcc76f5466555d48616 Mon Sep 17 00:00:00 2001 From: Winni Neessen Date: Tue, 19 Nov 2024 17:29:17 +0100 Subject: [PATCH 1/3] Add QuickSend function and unit tests Introduce the QuickSend function for sending emails quickly with TLS and optional SMTP authentication. Added comprehensive unit tests to ensure QuickSend works correctly with different authentication mechanisms and handles various error scenarios. --- quicksend.go | 112 ++++++++++++++ quicksend_test.go | 368 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 quicksend.go create mode 100644 quicksend_test.go diff --git a/quicksend.go b/quicksend.go new file mode 100644 index 0000000..797b4bd --- /dev/null +++ b/quicksend.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "crypto/tls" + "fmt" + "net" + "strconv" +) + +type AuthData struct { + Auth bool + Username string + Password string +} + +var testHookTLSConfig func() *tls.Config // nil, except for tests + +// QuickSend is an all-in-one method for quickly sending simple text mails in go-mail. +// +// This method will create a new client that connects to the server at addr, switches to TLS if possible, +// authenticates with the optional AuthData provided in auth and create a new simple Msg with the provided +// subject string and message bytes as body. The message will be sent using from as sender address and will +// be delivered to every address in rcpts. QuickSend will always send as text/plain ContentType. +// +// For the SMTP authentication, if auth is not nil and AuthData.Auth is set to true, it will try to +// autodiscover the best SMTP authentication mechanism supported by the server. If auth is set to true +// but autodiscover is not able to find a suitable authentication mechanism or if the authentication +// fails, the mail delivery will fail completely. +// +// The content parameter should be an RFC 822-style email body. The lines of content should be CRLF terminated. +// +// Parameters: +// - addr: The hostname and port of the mail server, it must include a port, as in "mail.example.com:smtp". +// - auth: A AuthData pointer. If nil or if AuthData.Auth is set to false, not SMTP authentication will be performed. +// - from: The from address of the sender as string. +// - rcpts: A slice of strings of receipient addresses. +// - subject: The subject line as string. +// - content: A byte slice of the mail content +// - opts: Optional parameters for customizing the body part. +// +// Returns: +// - A pointer to the generated Msg. +// - An error if any step in the process of mail generation or delivery failed. +func QuickSend(addr string, auth *AuthData, from string, rcpts []string, subject string, content []byte) (*Msg, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("failed to split host and port from address: %w", err) + } + portnum, err := strconv.Atoi(port) + if err != nil { + return nil, fmt.Errorf("failed to convert port to int: %w", err) + } + client, err := NewClient(host, WithPort(portnum), WithTLSPolicy(TLSOpportunistic)) + if err != nil { + return nil, fmt.Errorf("failed to create new client: %w", err) + } + + if auth != nil && auth.Auth { + client.SetSMTPAuth(SMTPAuthAutoDiscover) + client.SetUsername(auth.Username) + client.SetPassword(auth.Password) + } + + tlsConfig := client.tlsconfig + if testHookTLSConfig != nil { + tlsConfig = testHookTLSConfig() + } + if err = client.SetTLSConfig(tlsConfig); err != nil { + return nil, fmt.Errorf("failed to set TLS config: %w", err) + } + + message := NewMsg() + if err = message.From(from); err != nil { + return nil, fmt.Errorf("failed to set MAIL FROM address: %w", err) + } + if err = message.To(rcpts...); err != nil { + return nil, fmt.Errorf("failed to set RCPT TO address: %w", err) + } + message.Subject(subject) + buffer := bytes.NewBuffer(content) + writeFunc := writeFuncFromBuffer(buffer) + message.SetBodyWriter(TypeTextPlain, writeFunc) + + if err = client.DialAndSend(message); err != nil { + return nil, fmt.Errorf("failed to dial and send message: %w", err) + } + return message, nil +} + +// NewAuthData creates a new AuthData instance with the provided username and password. +// +// This function initializes an AuthData struct with authentication enabled and sets the +// username and password fields. +// +// Parameters: +// - user: The username for authentication. +// - pass: The password for authentication. +// +// Returns: +// - A pointer to the initialized AuthData instance. +func NewAuthData(user, pass string) *AuthData { + return &AuthData{ + Auth: true, + Username: user, + Password: pass, + } +} diff --git a/quicksend_test.go b/quicksend_test.go new file mode 100644 index 0000000..497e2a0 --- /dev/null +++ b/quicksend_test.go @@ -0,0 +1,368 @@ +// SPDX-FileCopyrightText: 2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "strings" + "testing" + "time" +) + +func TestNewAuthData(t *testing.T) { + t.Run("AuthData with username and password", func(t *testing.T) { + auth := NewAuthData("username", "password") + if !auth.Auth { + t.Fatal("expected auth to be true") + } + if auth.Username != "username" { + t.Fatalf("expected username to be %s, got %s", "username", auth.Username) + } + if auth.Password != "password" { + t.Fatalf("expected password to be %s, got %s", "password", auth.Password) + } + }) + t.Run("AuthData with username and empty password", func(t *testing.T) { + auth := NewAuthData("username", "") + if !auth.Auth { + t.Fatal("expected auth to be true") + } + if auth.Username != "username" { + t.Fatalf("expected username to be %s, got %s", "username", auth.Username) + } + if auth.Password != "" { + t.Fatalf("expected password to be %s, got %s", "", auth.Password) + } + }) + t.Run("AuthData with empty username and set password", func(t *testing.T) { + auth := NewAuthData("", "password") + if !auth.Auth { + t.Fatal("expected auth to be true") + } + if auth.Username != "" { + t.Fatalf("expected username to be %s, got %s", "", auth.Username) + } + if auth.Password != "password" { + t.Fatalf("expected password to be %s, got %s", "password", auth.Password) + } + }) + t.Run("AuthData with empty data", func(t *testing.T) { + auth := NewAuthData("", "") + if !auth.Auth { + t.Fatal("expected auth to be true") + } + if auth.Username != "" { + t.Fatalf("expected username to be %s, got %s", "", auth.Username) + } + if auth.Password != "" { + t.Fatalf("expected password to be %s, got %s", "", auth.Password) + } + }) +} + +func TestQuickSend(t *testing.T) { + subject := "This is a test subject" + body := []byte("This is a test body\r\nWith multiple lines\r\n\r\nBest,\r\n The go-mail team") + sender := TestSenderValid + rcpts := []string{TestRcptValid} + t.Run("QuickSend with authentication and TLS", func(t *testing.T) { + ctxAuth, cancelAuth := context.WithCancel(context.Background()) + defer cancelAuth() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8" + echoBuffer := bytes.NewBuffer(nil) + props := &serverProps{ + EchoBuffer: echoBuffer, + FeatureSet: featureSet, + ListenPort: serverPort, + } + go func() { + if err := simpleSMTPServer(ctxAuth, t, props); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + addr := TestServerAddr + ":" + fmt.Sprint(serverPort) + testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} } + + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body) + if err != nil { + t.Fatalf("failed to send email: %s", err) + } + + props.BufferMutex.RLock() + resp := strings.Split(echoBuffer.String(), "\r\n") + props.BufferMutex.RUnlock() + + expects := []struct { + line int + data string + }{ + {8, "STARTTLS"}, + {17, "AUTH PLAIN AHVzZXJuYW1lAHBhc3N3b3Jk"}, + {21, "MAIL FROM: BODY=8BITMIME SMTPUTF8"}, + {23, "RCPT TO:"}, + {30, "Subject: " + subject}, + {33, "From: "}, + {34, "To: "}, + {35, "Content-Type: text/plain; charset=UTF-8"}, + {36, "Content-Transfer-Encoding: quoted-printable"}, + {38, "This is a test body"}, + {39, "With multiple lines"}, + {40, ""}, + {41, "Best,"}, + {42, " The go-mail team"}, + } + for _, expect := range expects { + if !strings.EqualFold(resp[expect.line], expect.data) { + t.Errorf("expected %q at line %d, got: %q", expect.data, expect.line, resp[expect.line]) + } + } + }) + t.Run("QuickSend with authentication and TLS and multiple receipients", func(t *testing.T) { + ctxAuth, cancelAuth := context.WithCancel(context.Background()) + defer cancelAuth() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8" + echoBuffer := bytes.NewBuffer(nil) + props := &serverProps{ + EchoBuffer: echoBuffer, + FeatureSet: featureSet, + ListenPort: serverPort, + } + go func() { + if err := simpleSMTPServer(ctxAuth, t, props); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + addr := TestServerAddr + ":" + fmt.Sprint(serverPort) + testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} } + + multiRcpts := []string{TestRcptValid, TestRcptValid, TestRcptValid} + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, multiRcpts, subject, body) + if err != nil { + t.Fatalf("failed to send email: %s", err) + } + + props.BufferMutex.RLock() + resp := strings.Split(echoBuffer.String(), "\r\n") + props.BufferMutex.RUnlock() + + expects := []struct { + line int + data string + }{ + {8, "STARTTLS"}, + {17, "AUTH PLAIN AHVzZXJuYW1lAHBhc3N3b3Jk"}, + {21, "MAIL FROM: BODY=8BITMIME SMTPUTF8"}, + {23, "RCPT TO:"}, + {25, "RCPT TO:"}, + {27, "RCPT TO:"}, + {34, "Subject: " + subject}, + {37, "From: "}, + {38, "To: , , "}, + {39, "Content-Type: text/plain; charset=UTF-8"}, + {40, "Content-Transfer-Encoding: quoted-printable"}, + {42, "This is a test body"}, + {43, "With multiple lines"}, + {44, ""}, + {45, "Best,"}, + {46, " The go-mail team"}, + } + for _, expect := range expects { + if !strings.EqualFold(resp[expect.line], expect.data) { + t.Errorf("expected %q at line %d, got: %q", expect.data, expect.line, resp[expect.line]) + } + } + }) + t.Run("QuickSend uses stronged authentication method", func(t *testing.T) { + ctxAuth, cancelAuth := context.WithCancel(context.Background()) + defer cancelAuth() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN CRAM-MD5 SCRAM-SHA-256-PLUS SCRAM-SHA-256\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8" + echoBuffer := bytes.NewBuffer(nil) + props := &serverProps{ + EchoBuffer: echoBuffer, + FeatureSet: featureSet, + ListenPort: serverPort, + } + go func() { + if err := simpleSMTPServer(ctxAuth, t, props); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + addr := TestServerAddr + ":" + fmt.Sprint(serverPort) + testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} } + + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body) + if err != nil { + t.Fatalf("failed to send email: %s", err) + } + + props.BufferMutex.RLock() + resp := strings.Split(echoBuffer.String(), "\r\n") + props.BufferMutex.RUnlock() + + expects := []struct { + line int + data string + }{ + {17, "AUTH SCRAM-SHA-256-PLUS"}, + } + for _, expect := range expects { + if !strings.EqualFold(resp[expect.line], expect.data) { + t.Errorf("expected %q at line %d, got: %q", expect.data, expect.line, resp[expect.line]) + } + } + }) + t.Run("QuickSend uses stronged authentication method without TLS", func(t *testing.T) { + ctxAuth, cancelAuth := context.WithCancel(context.Background()) + defer cancelAuth() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN CRAM-MD5 SCRAM-SHA-256-PLUS SCRAM-SHA-256\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + echoBuffer := bytes.NewBuffer(nil) + props := &serverProps{ + EchoBuffer: echoBuffer, + FeatureSet: featureSet, + ListenPort: serverPort, + } + go func() { + if err := simpleSMTPServer(ctxAuth, t, props); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + addr := TestServerAddr + ":" + fmt.Sprint(serverPort) + testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} } + + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body) + if err != nil { + t.Fatalf("failed to send email: %s", err) + } + + props.BufferMutex.RLock() + resp := strings.Split(echoBuffer.String(), "\r\n") + props.BufferMutex.RUnlock() + + expects := []struct { + line int + data string + }{ + {7, "AUTH SCRAM-SHA-256"}, + } + for _, expect := range expects { + if !strings.EqualFold(resp[expect.line], expect.data) { + t.Errorf("expected %q at line %d, got: %q", expect.data, expect.line, resp[expect.line]) + } + } + }) + t.Run("QuickSend fails during DialAndSned", func(t *testing.T) { + ctxAuth, cancelAuth := context.WithCancel(context.Background()) + defer cancelAuth() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN CRAM-MD5 SCRAM-SHA-256-PLUS SCRAM-SHA-256\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + props := &serverProps{ + FailOnMailFrom: true, + FeatureSet: featureSet, + ListenPort: serverPort, + } + go func() { + if err := simpleSMTPServer(ctxAuth, t, props); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + addr := TestServerAddr + ":" + fmt.Sprint(serverPort) + testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} } + + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body) + if err == nil { + t.Error("expected QuickSend to fail during DialAndSend") + } + expect := `failed to dial and send message: send failed: sending SMTP MAIL FROM command: 500 ` + + `5.5.2 Error: fail on MAIL FROM` + if !strings.EqualFold(err.Error(), expect) { + t.Errorf("expected error to contain %s, got %s", expect, err) + } + }) + t.Run("QuickSend fails on server address without port", func(t *testing.T) { + addr := TestServerAddr + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body) + if err == nil { + t.Error("expected QuickSend to fail with invalid server address") + } + expect := "failed to split host and port from address: address 127.0.0.1: missing port in address" + if !strings.Contains(err.Error(), expect) { + t.Errorf("expected error to contain %s, got %s", expect, err) + } + }) + t.Run("QuickSend fails on server address with invalid port", func(t *testing.T) { + addr := TestServerAddr + ":invalid" + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body) + if err == nil { + t.Error("expected QuickSend to fail with invalid server port") + } + expect := `failed to convert port to int: strconv.Atoi: parsing "invalid": invalid syntax` + if !strings.Contains(err.Error(), expect) { + t.Errorf("expected error to contain %s, got %s", expect, err) + } + }) + t.Run("QuickSend fails on nil TLS config (test hook only)", func(t *testing.T) { + addr := TestServerAddr + ":587" + testHookTLSConfig = func() *tls.Config { return nil } + defer func() { + testHookTLSConfig = nil + }() + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body) + if err == nil { + t.Error("expected QuickSend to fail with nil-tlsConfig") + } + expect := `failed to set TLS config: invalid TLS config` + if !strings.Contains(err.Error(), expect) { + t.Errorf("expected error to contain %s, got %s", expect, err) + } + }) + t.Run("QuickSend fails with invalid from address", func(t *testing.T) { + addr := TestServerAddr + ":587" + invalid := "invalid-fromdomain.tld" + _, err := QuickSend(addr, NewAuthData("username", "password"), invalid, rcpts, subject, body) + if err == nil { + t.Error("expected QuickSend to fail with invalid from address") + } + expect := `failed to set MAIL FROM address: failed to parse mail address "invalid-fromdomain.tld": ` + + `mail: missing '@' or angle-addr` + if !strings.Contains(err.Error(), expect) { + t.Errorf("expected error to contain %s, got %s", expect, err) + } + }) + t.Run("QuickSend fails with invalid from address", func(t *testing.T) { + addr := TestServerAddr + ":587" + invalid := []string{"invalid-todomain.tld"} + _, err := QuickSend(addr, NewAuthData("username", "password"), sender, invalid, subject, body) + if err == nil { + t.Error("expected QuickSend to fail with invalid to address") + } + expect := `failed to set RCPT TO address: failed to parse mail address "invalid-todomain.tld": ` + + `mail: missing '@' or angle-add` + if !strings.Contains(err.Error(), expect) { + t.Errorf("expected error to contain %s, got %s", expect, err) + } + }) +} From 7ee4e47c8ec4691bf84ecdac7f70263a047f69ca Mon Sep 17 00:00:00 2001 From: Winni Neessen Date: Tue, 19 Nov 2024 17:29:26 +0100 Subject: [PATCH 2/3] Add EchoBuffer to serverProps for capturing SMTP data Introduced a new io.Writer field `EchoBuffer` and its associated `BufferMutex` to `serverProps`. Updated relevant test code to write SMTP transaction data to `EchoBuffer` if it is set, ensuring thread safety with `BufferMutex`. --- client_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/client_test.go b/client_test.go index 97289b8..a6530f0 100644 --- a/client_test.go +++ b/client_test.go @@ -3665,6 +3665,8 @@ func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", " // serverProps represents the configuration properties for the SMTP server. type serverProps struct { + BufferMutex sync.RWMutex + EchoBuffer io.Writer FailOnAuth bool FailOnDataInit bool FailOnDataClose bool @@ -3754,6 +3756,13 @@ func handleTestServerConnection(connection net.Conn, t *testing.T, props *server if err != nil { t.Logf("failed to write line: %s", err) } + if props.EchoBuffer != nil { + props.BufferMutex.Lock() + if _, berr := props.EchoBuffer.Write([]byte(data + "\r\n")); berr != nil { + t.Errorf("failed write to echo buffer: %s", berr) + } + props.BufferMutex.Unlock() + } _ = writer.Flush() } writeOK := func() { @@ -3770,6 +3779,13 @@ func handleTestServerConnection(connection net.Conn, t *testing.T, props *server break } time.Sleep(time.Millisecond) + if props.EchoBuffer != nil { + props.BufferMutex.Lock() + if _, berr := props.EchoBuffer.Write([]byte(data)); berr != nil { + t.Errorf("failed write to echo buffer: %s", berr) + } + props.BufferMutex.Unlock() + } var datastring string data = strings.TrimSpace(data) @@ -3830,6 +3846,13 @@ func handleTestServerConnection(connection net.Conn, t *testing.T, props *server t.Logf("failed to read data from connection: %s", derr) break } + if props.EchoBuffer != nil { + props.BufferMutex.Lock() + if _, berr := props.EchoBuffer.Write([]byte(ddata)); berr != nil { + t.Errorf("failed write to echo buffer: %s", berr) + } + props.BufferMutex.Unlock() + } ddata = strings.TrimSpace(ddata) if ddata == "." { if props.FailOnDataClose { From f05654d5e58c558253c201b785b65f20dd1a89ab Mon Sep 17 00:00:00 2001 From: Winni Neessen Date: Tue, 19 Nov 2024 17:29:37 +0100 Subject: [PATCH 3/3] Add exclusion rule for TLS settings in tests Updated the .golangci.toml config to exclude gosec rule G402 in quicksend_test.go. This exclusion is intentional as these tests do not require TLS settings. --- .golangci.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.golangci.toml b/.golangci.toml index 9456df2..5178a27 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -72,3 +72,10 @@ text = "G505:" linters = ["gosec"] path = "smtp/smtp_test.go" text = "G505:" + +## These are tests which intentionally do not need any TLS settings +[[issues.exclude-rules]] +linters = ["gosec"] +path = "quicksend_test.go" +text = "G402:" +