From 24ab73575561c6a3413d390ca99c60bcd507d79f Mon Sep 17 00:00:00 2001 From: Jens Willemsens <6514515+JenswBE@users.noreply.github.com> Date: Fri, 29 Dec 2023 19:38:08 +0100 Subject: [PATCH] Add STARTTLS support --- .github/workflows/test-and-build.yml | 2 +- lint.sh | 2 +- main.go | 2 + send/send.go | 79 +++++++++++++++++++++------- test/docker-compose.yml | 11 +++- test/main_test.go | 20 +++++-- test/mock_test.go | 17 +++--- test/send_test.go | 32 +++++++---- 8 files changed, 121 insertions(+), 44 deletions(-) diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml index d4ec01b..6b2d0d3 100644 --- a/.github/workflows/test-and-build.yml +++ b/.github/workflows/test-and-build.yml @@ -33,7 +33,7 @@ jobs: version: latest args: >- --timeout=5m - --disable noctx + --disable goerr113,noctx,wrapcheck --exclude 'func name will be used as send.SendEmail' - name: Start E2E services diff --git a/lint.sh b/lint.sh index a4c9fc3..5a675d4 100644 --- a/lint.sh +++ b/lint.sh @@ -2,5 +2,5 @@ # Config is available at https://raw.githubusercontent.com/JenswBE/setup/main/programming_configs/golang/.golangci.yml golangci-lint run \ -c ../setup/programming_configs/golang/.golangci.yml \ - --disable noctx \ + --disable goerr113,noctx,wrapcheck \ --exclude 'func name will be used as send.SendEmail' diff --git a/main.go b/main.go index f76ebab..1f7b152 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ func main() { toName = flag.String("to-name", "", "Name of the receiver") toAddress = flag.String("to-address", "", "Address of the receiver") subject = flag.String("subject", "", "Subject of the email") + security = flag.String("security", "FORCE_TLS", "Supported options: FORCE_TLS (= implicit TLS), STARTTLS") allowInsecureTLS = flag.Bool("allow-insecure-tls", false, "Skip TLS certificate verification. Should only be used for testing!") ) flag.Parse() @@ -46,6 +47,7 @@ func main() { ToAddress: *toAddress, Subject: *subject, BodyReader: os.Stdin, + Security: *security, AllowInsecureTLS: *allowInsecureTLS, }) if err != nil { diff --git a/send/send.go b/send/send.go index 575d109..b7c5723 100644 --- a/send/send.go +++ b/send/send.go @@ -21,30 +21,44 @@ type EmailConfig struct { ToAddress string Subject string BodyReader io.Reader + Security string AllowInsecureTLS bool } -func SendEmail(c EmailConfig) error { - // Connect to server - hostPort := fmt.Sprintf("%s:%d", c.Host, c.Port) - conn, err := tls.Dial("tcp", hostPort, &tls.Config{InsecureSkipVerify: c.AllowInsecureTLS}) // #nosec G402 - if err != nil { - log.Error().Err(err).Str("server", hostPort).Msg("Failed to connect to SMTP server over TLS") - return fmt.Errorf("failed to connect to SMTP server over TLS: %w", err) - } +const ( + EmailSecurityForceTLS = "FORCE_TLS" + EmailSecuritySTARTTLS = "STARTTLS" +) - // Start SMTP session - client, err := smtp.NewClient(conn, c.Host) - if err != nil { - log.Error().Err(err).Str("server", hostPort).Msg("Failed to create SMTP client from TLS connection") - return fmt.Errorf("failed to create SMTP client from TLS connection: %w", err) +func SendEmail(c EmailConfig) (err error) { + // Get client + var client *smtp.Client + switch c.Security { + case EmailSecurityForceTLS: + client, err = createForceTLSClient(c.Host, c.Port, c.AllowInsecureTLS) + case EmailSecuritySTARTTLS: + client, err = createSTARTTLSClient(c.Host, c.Port, c.AllowInsecureTLS) + default: + return fmt.Errorf("unknown email security option: %s", c.Security) } + // Defer server connection close + defer func() { + quitErr := client.Quit() + if quitErr != nil { + if err == nil { + err = fmt.Errorf("failed to close connection with server: %w", quitErr) + } else { + log.Error().Err(err).Msg("Failed to close connection with server") + } + } + }() + // Authenticate to server if c.Username != "" || c.Password != "" { err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)) if err != nil { - log.Error().Err(err).Str("server", hostPort).Msg("Failed to authenticate to SMTP server") + log.Error().Err(err).Msg("Failed to authenticate to SMTP server") return fmt.Errorf("failed to authenticate to SMTP server: %w", err) } } else { @@ -94,13 +108,38 @@ func SendEmail(c EmailConfig) error { return fmt.Errorf("failed to close email body: %w", err) } - // Close server connection - err = client.Quit() + // Email sent successfully + return nil +} + +func createForceTLSClient(host string, port uint, allowInsecureTLS bool) (*smtp.Client, error) { + // Connect to server + hostPort := fmt.Sprintf("%s:%d", host, port) + conn, err := tls.Dial("tcp", hostPort, &tls.Config{InsecureSkipVerify: allowInsecureTLS}) // #nosec G402 + if err != nil { + return nil, fmt.Errorf("failed to connect to SMTP server at %s over TLS: %w", hostPort, err) + } + + // Start SMTP session + client, err := smtp.NewClient(conn, host) if err != nil { - log.Error().Err(err).Msg("Failed to close connection with server") - return fmt.Errorf("failed to close connection with server: %w", err) + return nil, fmt.Errorf("failed to create SMTP client from TLS connection at %s: %w", hostPort, err) } + return client, nil +} - // Email sent successfully - return nil +func createSTARTTLSClient(host string, port uint, allowInsecureTLS bool) (*smtp.Client, error) { + // Connect to server + hostPort := fmt.Sprintf("%s:%d", host, port) + client, err := smtp.Dial(hostPort) + if err != nil { + return nil, fmt.Errorf("failed to connect to SMTP server at %s: %w", hostPort, err) + } + + // Switch to STARTTLS + err = client.StartTLS(&tls.Config{InsecureSkipVerify: allowInsecureTLS}) // #nosec G402 + if err != nil { + return nil, fmt.Errorf("failed to switch to TLS using STARTTLS on server %s: %w", hostPort, err) + } + return client, err } diff --git a/test/docker-compose.yml b/test/docker-compose.yml index 9201b70..a39f0c8 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -1,7 +1,7 @@ version: "3" services: - smtp4dev: + smtp4dev-implicit-tls: image: docker.io/rnwood/smtp4dev:v3 ports: - "8080:80" @@ -9,3 +9,12 @@ services: environment: - ServerOptions__HostName=localhost - ServerOptions__TlsMode=ImplicitTls + + smtp4dev-starttls: + image: docker.io/rnwood/smtp4dev:v3 + ports: + - "8081:80" + - "8587:25" + environment: + - ServerOptions__HostName=localhost + - ServerOptions__TlsMode=StartTls diff --git a/test/main_test.go b/test/main_test.go index d867b3d..56ead6f 100644 --- a/test/main_test.go +++ b/test/main_test.go @@ -8,7 +8,10 @@ import ( "github.com/stretchr/testify/suite" ) -const smtpPort = 8465 +const ( + smtpPortImplicitTLS = 8465 + smtpPortSTARTTLS = 8587 +) type E2ETestSuite struct { suite.Suite @@ -20,9 +23,15 @@ func Test_E2ETestSuite(t *testing.T) { func (s *E2ETestSuite) SetupSuite() { // Poll SMTP mock server for completed start - log.Info().Msg("Checking if SMTP mock server is reachable ...") + log.Info().Msg("Checking if SMTP mock servers are reachable ...") + checkSMTPMockRunning(s, smtpMockImplictTLSBaseURL) + checkSMTPMockRunning(s, smtpMockSTARTTLSBaseURL) + log.Info().Msg("SMTP mock servers up and running") +} + +func checkSMTPMockRunning(s *E2ETestSuite, baseURL string) { for i := 0; true; i++ { - _, err := getMessages() + _, err := getMessages(baseURL) if err == nil { // Server started break @@ -36,10 +45,11 @@ func (s *E2ETestSuite) SetupSuite() { log.Info().Err(err).Msg("Polling SMTP mock server failed, retrying in 2 seconds") time.Sleep(2 * time.Second) } - log.Info().Msg("SMTP mock server up and running") } func (s *E2ETestSuite) SetupTest() { - err := clearMessages() + err := clearMessages(smtpMockImplictTLSBaseURL) + s.Require().NoError(err) + err = clearMessages(smtpMockSTARTTLSBaseURL) s.Require().NoError(err) } diff --git a/test/mock_test.go b/test/mock_test.go index 745dffa..10e2d32 100644 --- a/test/mock_test.go +++ b/test/mock_test.go @@ -8,7 +8,10 @@ import ( "strings" ) -const smtpMockBaseURL = "http://localhost:8080" +const ( + smtpMockImplictTLSBaseURL = "http://localhost:8080" + smtpMockSTARTTLSBaseURL = "http://localhost:8081" +) type message struct { ID string `json:"id"` @@ -17,9 +20,9 @@ type message struct { Subject string `json:"subject"` } -func clearMessages() error { +func clearMessages(baseURL string) error { // /api/Messages/* - req, _ := http.NewRequest(http.MethodDelete, smtpMockBaseURL+"/api/Messages/*", nil) + req, _ := http.NewRequest(http.MethodDelete, baseURL+"/api/Messages/*", nil) res, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("failed to clear messages: %w", err) @@ -28,9 +31,9 @@ func clearMessages() error { return nil } -func getMessages() ([]message, error) { +func getMessages(baseURL string) ([]message, error) { // Call SMTP mock server - resp, err := http.Get(smtpMockBaseURL + "/api/Messages") + resp, err := http.Get(baseURL + "/api/Messages") if err != nil { return nil, fmt.Errorf("failed to get messages: %w", err) } @@ -44,9 +47,9 @@ func getMessages() ([]message, error) { return messages, nil } -func getMessageBody(id string) (string, error) { +func getMessageBody(baseURL string, id string) (string, error) { // Call SMTP mock server - url := fmt.Sprintf("%s/api/Messages/%s/raw", smtpMockBaseURL, id) + url := fmt.Sprintf("%s/api/Messages/%s/raw", baseURL, id) resp, err := http.Get(url) // #nosec G107 if err != nil { return "", fmt.Errorf("failed to get message body: %w", err) diff --git a/test/send_test.go b/test/send_test.go index b116760..232d83b 100644 --- a/test/send_test.go +++ b/test/send_test.go @@ -6,11 +6,24 @@ import ( "github.com/jenswbe/smtp-cli/send" ) -func (s *E2ETestSuite) TestSendEmail() { - // Send email - err := send.SendEmail(send.EmailConfig{ +func (s *E2ETestSuite) TestSendEmailImplicitTLS() { + config := getEmailConfig(smtpPortImplicitTLS, send.EmailSecurityForceTLS) + err := send.SendEmail(config) + s.Require().NoError(err) + validateEmailMessages(s, smtpMockImplictTLSBaseURL) +} + +func (s *E2ETestSuite) TestSendEmailSTARTTLS() { + config := getEmailConfig(smtpPortSTARTTLS, send.EmailSecuritySTARTTLS) + err := send.SendEmail(config) + s.Require().NoError(err) + validateEmailMessages(s, smtpMockSTARTTLSBaseURL) +} + +func getEmailConfig(port uint, security string) send.EmailConfig { + return send.EmailConfig{ Host: "localhost", - Port: smtpPort, + Port: port, Username: "TestUsername", Password: "TestPassword", FromName: "TestFromName", @@ -19,18 +32,19 @@ func (s *E2ETestSuite) TestSendEmail() { ToAddress: "TestToAddress@example.com", Subject: "TestSubject", BodyReader: bytes.NewBufferString("TestBody"), + Security: security, AllowInsecureTLS: true, - }) - s.Require().NoError(err) + } +} - // Validate email - messages, err := getMessages() +func validateEmailMessages(s *E2ETestSuite, baseURL string) { + messages, err := getMessages(baseURL) s.Require().NoError(err) s.Require().Len(messages, 1, "Server should have received a single message") s.Require().Equal(`"TestFromName" `, messages[0].From) s.Require().Equal(`"TestToName" `, messages[0].To) s.Require().Equal("TestSubject", messages[0].Subject) - messageBody, err := getMessageBody(messages[0].ID) + messageBody, err := getMessageBody(baseURL, messages[0].ID) s.Require().NoError(err) s.Require().Contains(messageBody, "TestBody") }