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:" + 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 { 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) + } + }) +}