Merge pull request #378 from wneessen/feature/quicksend

Add QuickSend feature
This commit is contained in:
Winni Neessen 2024-11-19 17:46:22 +01:00 committed by GitHub
commit 9410381bfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 510 additions and 0 deletions

View file

@ -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:"

View file

@ -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 {

112
quicksend.go Normal file
View file

@ -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,
}
}

368
quicksend_test.go Normal file
View file

@ -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:<valid-from@domain.tld> BODY=8BITMIME SMTPUTF8"},
{23, "RCPT TO:<valid-to@domain.tld>"},
{30, "Subject: " + subject},
{33, "From: <valid-from@domain.tld>"},
{34, "To: <valid-to@domain.tld>"},
{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:<valid-from@domain.tld> BODY=8BITMIME SMTPUTF8"},
{23, "RCPT TO:<valid-to@domain.tld>"},
{25, "RCPT TO:<valid-to@domain.tld>"},
{27, "RCPT TO:<valid-to@domain.tld>"},
{34, "Subject: " + subject},
{37, "From: <valid-from@domain.tld>"},
{38, "To: <valid-to@domain.tld>, <valid-to@domain.tld>, <valid-to@domain.tld>"},
{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)
}
})
}