Skip to content

Commit

Permalink
Merge pull request #18 from JenswBE/add-starttls-support
Browse files Browse the repository at this point in the history
Add STARTTLS support
  • Loading branch information
JenswBE committed Dec 29, 2023
2 parents c334435 + 24ab735 commit f4696ff
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 44 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test-and-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -46,6 +47,7 @@ func main() {
ToAddress: *toAddress,
Subject: *subject,
BodyReader: os.Stdin,
Security: *security,
AllowInsecureTLS: *allowInsecureTLS,
})
if err != nil {
Expand Down
79 changes: 59 additions & 20 deletions send/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
11 changes: 10 additions & 1 deletion test/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
version: "3"

services:
smtp4dev:
smtp4dev-implicit-tls:
image: docker.io/rnwood/smtp4dev:v3
ports:
- "8080:80"
- "8465:25"
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
20 changes: 15 additions & 5 deletions test/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import (
"github.com/stretchr/testify/suite"
)

const smtpPort = 8465
const (
smtpPortImplicitTLS = 8465
smtpPortSTARTTLS = 8587
)

type E2ETestSuite struct {
suite.Suite
Expand All @@ -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
Expand All @@ -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)
}
17 changes: 10 additions & 7 deletions test/mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down
32 changes: 23 additions & 9 deletions test/send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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" <TestFromAddress@example.com>`, messages[0].From)
s.Require().Equal(`"TestToName" <TestToAddress@example.com>`, 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")
}

0 comments on commit f4696ff

Please sign in to comment.