Compare commits

...

11 commits

Author SHA1 Message Date
b03fbb4ae8
Add test server for SMTP authentication
Added a simple SMTP test server with basic features like PLAIN, LOGIN, and NOENC authentication. It can start, handle connections, and simulate authentication success or failure. Included support for TLS with a generated localhost certificate.
2024-11-07 22:42:23 +01:00
4221d48644
Update login authentication test cases in smtp_test.go
Renamed the test functions and improved the test structure for login authentication checks. Added subtests to provide clear descriptions and enhance error checking.
2024-11-07 21:31:24 +01:00
410343496c
Refactor and expand TestLoginAuth
Rename and uncomment TestLoginAuth with more test cases, ensuring coverage for successful and failed authentication scenarios, including checks for unencrypted logins and server response errors. This improves test robustness and coverage.
2024-11-07 21:14:52 +01:00
2391010e3a
Rename parameter for consistency in auth functions
Updated the parameter name `allowUnEnc` to `allowUnenc` in both `LoginAuth` and `PlainAuth` functions to maintain consistent naming conventions. This change improves code readability and follows standard naming practices.
2024-11-07 20:58:20 +01:00
e42b022076
Merge branch 'main' into smtp-client-tests 2024-11-07 20:50:59 +01:00
b4aa414a4d
Update doc.go
Bump version to v0.5.2
2024-11-06 11:22:54 +01:00
0f46ce800e
Merge pull request #354 from wneessen/test_close_on_nil
Add test for closing a nil smtpclient
2024-11-06 10:55:59 +01:00
03e53cbabc
Merge branch 'main' into test_close_on_nil 2024-11-06 10:52:32 +01:00
632ac17845
Merge pull request #353 from sonalys/fix/close_panic
Fix(close): Access to nil variable causes panic
2024-11-06 10:51:53 +01:00
a5fcb3ae8b
Add test for closing a nil smtpclient
Introduce a unit test to ensure that invoking Close on a nil smtpclient instance returns nil without errors. This enhances the robustness of the client closure functionality. This test accommodates the fix provided with PR #353
2024-11-06 10:47:31 +01:00
Alysson Ribeiro
8c4eb62360
Fix(close): Access to nil variable causes panic 2024-11-06 10:20:59 +01:00
6 changed files with 690 additions and 94 deletions

View file

@ -996,7 +996,7 @@ func (c *Client) DialWithContext(dialCtx context.Context) error {
// Returns: // Returns:
// - An error if the disconnection fails; otherwise, returns nil. // - An error if the disconnection fails; otherwise, returns nil.
func (c *Client) Close() error { func (c *Client) Close() error {
if !c.smtpClient.HasConnection() { if c.smtpClient == nil || !c.smtpClient.HasConnection() {
return nil return nil
} }
if err := c.smtpClient.Quit(); err != nil { if err := c.smtpClient.Quit(); err != nil {

View file

@ -1647,6 +1647,15 @@ func TestClient_Close(t *testing.T) {
t.Errorf("close was supposed to fail, but didn't") t.Errorf("close was supposed to fail, but didn't")
} }
}) })
t.Run("close on a nil smtpclient should return nil", func(t *testing.T) {
client, err := NewClient(DefaultHost)
if err != nil {
t.Fatalf("failed to create new client: %s", err)
}
if err = client.Close(); err != nil {
t.Errorf("failed to close the client: %s", err)
}
})
} }
func TestClient_DialWithContext(t *testing.T) { func TestClient_DialWithContext(t *testing.T) {

2
doc.go
View file

@ -11,4 +11,4 @@ package mail
// VERSION indicates the current version of the package. It is also attached to the default user // VERSION indicates the current version of the package. It is also attached to the default user
// agent string. // agent string.
const VERSION = "0.5.1" const VERSION = "0.5.2"

View file

@ -36,8 +36,8 @@ type loginAuth struct {
// LoginAuth will only send the credentials if the connection is using TLS // LoginAuth will only send the credentials if the connection is using TLS
// or is connected to localhost. Otherwise authentication will fail with an // or is connected to localhost. Otherwise authentication will fail with an
// error, without sending the credentials. // error, without sending the credentials.
func LoginAuth(username, password, host string, allowUnEnc bool) Auth { func LoginAuth(username, password, host string, allowUnenc bool) Auth {
return &loginAuth{username, password, host, 0, allowUnEnc} return &loginAuth{username, password, host, 0, allowUnenc}
} }
// Start begins the SMTP authentication process by validating server's TLS status and hostname. // Start begins the SMTP authentication process by validating server's TLS status and hostname.

View file

@ -28,8 +28,8 @@ type plainAuth struct {
// PlainAuth will only send the credentials if the connection is using TLS // PlainAuth will only send the credentials if the connection is using TLS
// or is connected to localhost. Otherwise authentication will fail with an // or is connected to localhost. Otherwise authentication will fail with an
// error, without sending the credentials. // error, without sending the credentials.
func PlainAuth(identity, username, password, host string, allowUnEnc bool) Auth { func PlainAuth(identity, username, password, host string, allowUnenc bool) Auth {
return &plainAuth{identity, username, password, host, allowUnEnc} return &plainAuth{identity, username, password, host, allowUnenc}
} }
func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) { func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) {

View file

@ -14,11 +14,69 @@
package smtp package smtp
import ( import (
"bufio"
"bytes" "bytes"
"context"
"crypto/tls"
"errors" "errors"
"fmt"
"net"
"strings"
"sync/atomic"
"testing" "testing"
"time"
) )
const (
// TestServerProto is the protocol used for the simple SMTP test server
TestServerProto = "tcp"
// TestServerAddr is the address the simple SMTP test server listens on
TestServerAddr = "127.0.0.1"
// TestServerPortBase is the base port for the simple SMTP test server
TestServerPortBase = 12025
)
// PortAdder is an atomic counter used to increment port numbers for the test SMTP server instances.
var PortAdder atomic.Int32
// localhostCert is a PEM-encoded TLS cert generated from src/crypto/tls:
//
// go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example.com \
// --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h
var localhostCert = []byte(`
-----BEGIN CERTIFICATE-----
MIICFDCCAX2gAwIBAgIRAK0xjnaPuNDSreeXb+z+0u4wDQYJKoZIhvcNAQELBQAw
EjEQMA4GA1UEChMHQWNtZSBDbzAgFw03MDAxMDEwMDAwMDBaGA8yMDg0MDEyOTE2
MDAwMFowEjEQMA4GA1UEChMHQWNtZSBDbzCBnzANBgkqhkiG9w0BAQEFAAOBjQAw
gYkCgYEA0nFbQQuOWsjbGtejcpWz153OlziZM4bVjJ9jYruNw5n2Ry6uYQAffhqa
JOInCmmcVe2siJglsyH9aRh6vKiobBbIUXXUU1ABd56ebAzlt0LobLlx7pZEMy30
LqIi9E6zmL3YvdGzpYlkFRnRrqwEtWYbGBf3znO250S56CCWH2UCAwEAAaNoMGYw
DgYDVR0PAQH/BAQDAgKkMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQF
MAMBAf8wLgYDVR0RBCcwJYILZXhhbXBsZS5jb22HBH8AAAGHEAAAAAAAAAAAAAAA
AAAAAAEwDQYJKoZIhvcNAQELBQADgYEAbZtDS2dVuBYvb+MnolWnCNqvw1w5Gtgi
NmvQQPOMgM3m+oQSCPRTNGSg25e1Qbo7bgQDv8ZTnq8FgOJ/rbkyERw2JckkHpD4
n4qcK27WkEDBtQFlPihIM8hLIuzWoi/9wygiElTy/tVL3y7fGCvY2/k1KBthtZGF
tN8URjVmyEo=
-----END CERTIFICATE-----`)
// localhostKey is the private key for localhostCert.
var localhostKey = []byte(testingKey(`
-----BEGIN RSA TESTING KEY-----
MIICXgIBAAKBgQDScVtBC45ayNsa16NylbPXnc6XOJkzhtWMn2Niu43DmfZHLq5h
AB9+Gpok4icKaZxV7ayImCWzIf1pGHq8qKhsFshRddRTUAF3np5sDOW3QuhsuXHu
lkQzLfQuoiL0TrOYvdi90bOliWQVGdGurAS1ZhsYF/fOc7bnRLnoIJYfZQIDAQAB
AoGBAMst7OgpKyFV6c3JwyI/jWqxDySL3caU+RuTTBaodKAUx2ZEmNJIlx9eudLA
kucHvoxsM/eRxlxkhdFxdBcwU6J+zqooTnhu/FE3jhrT1lPrbhfGhyKnUrB0KKMM
VY3IQZyiehpxaeXAwoAou6TbWoTpl9t8ImAqAMY8hlULCUqlAkEA+9+Ry5FSYK/m
542LujIcCaIGoG1/Te6Sxr3hsPagKC2rH20rDLqXwEedSFOpSS0vpzlPAzy/6Rbb
PHTJUhNdwwJBANXkA+TkMdbJI5do9/mn//U0LfrCR9NkcoYohxfKz8JuhgRQxzF2
6jpo3q7CdTuuRixLWVfeJzcrAyNrVcBq87cCQFkTCtOMNC7fZnCTPUv+9q1tcJyB
vNjJu3yvoEZeIeuzouX9TJE21/33FaeDdsXbRhQEj23cqR38qFHsF1qAYNMCQQDP
QXLEiJoClkR2orAmqjPLVhR3t2oB3INcnEjLNSq8LHyQEfXyaFfu4U9l5+fRPL2i
jiC0k/9L5dHUsF0XZothAkEA23ddgRs+Id/HxtojqqUT27B8MT/IGNrYsp4DvS/c
qgkeluku4GjxRlDMBuXk94xOBEinUs+p/hwP1Alll80Tpg==
-----END RSA TESTING KEY-----`))
type authTest struct { type authTest struct {
auth Auth auth Auth
challenges []string challenges []string
@ -302,6 +360,59 @@ func TestPlainAuth(t *testing.T) {
t.Errorf("expected error to be: %s, got: %s", ErrUnexpectedServerChallange, err) t.Errorf("expected error to be: %s, got: %s", ErrUnexpectedServerChallange, err)
} }
}) })
t.Run("PLAIN authentication on test server", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := PlainAuth("", "user", "pass", TestServerAddr, false)
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
})
t.Run("PLAIN authentication on test server should fail", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FailOnAuth: true,
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := PlainAuth("", "user", "pass", TestServerAddr, false)
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err == nil {
t.Errorf("expected authentication to fail")
}
})
} }
func TestPlainAuth_noEnc(t *testing.T) { func TestPlainAuth_noEnc(t *testing.T) {
@ -397,100 +508,350 @@ func TestPlainAuth_noEnc(t *testing.T) {
t.Errorf("expected error to be: %s, got: %s", ErrUnexpectedServerChallange, err) t.Errorf("expected error to be: %s, got: %s", ErrUnexpectedServerChallange, err)
} }
}) })
t.Run("PLAIN-NOENC authentication on test server", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := PlainAuth("", "user", "pass", TestServerAddr, true)
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
})
t.Run("PLAIN-NOENC authentication on test server should fail", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FailOnAuth: true,
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := PlainAuth("", "user", "pass", TestServerAddr, true)
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err == nil {
t.Errorf("expected authentication to fail")
}
})
}
func TestLoginAuth(t *testing.T) {
tests := []struct {
name string
authName string
server *ServerInfo
shouldFail bool
wantErr error
}{
{
name: "LOGIN auth succeeds",
authName: "servername",
server: &ServerInfo{Name: "servername", TLS: true},
shouldFail: false,
},
{
// OK to use PlainAuth on localhost without TLS
name: "LOGIN on localhost is allowed to go unencrypted",
authName: "localhost",
server: &ServerInfo{Name: "localhost", TLS: false},
shouldFail: false,
},
{
// NOT OK on non-localhost, even if server says LOGIN is OK.
// (We don't know that the server is the real server.)
name: "LOGIN on non-localhost is not allowed to go unencrypted",
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"LOGIN"}},
shouldFail: true,
wantErr: ErrUnencrypted,
},
{
name: "LOGIN on non-localhost with no LOGIN announcement, is not allowed to go unencrypted",
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"CRAM-MD5"}},
shouldFail: true,
wantErr: ErrUnencrypted,
},
{
name: "LOGIN with wrong hostname",
authName: "servername",
server: &ServerInfo{Name: "attacker", TLS: true},
shouldFail: true,
wantErr: ErrWrongHostname,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := "toni.tester@example.com"
pass := "v3ryS3Cur3P4ssw0rd"
auth := LoginAuth(user, pass, tt.authName, false)
method, _, err := auth.Start(tt.server)
if err != nil && !tt.shouldFail {
t.Errorf("login authentication failed: %s", err)
}
if err == nil && tt.shouldFail {
t.Error("login authentication was expected to fail")
}
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Errorf("expected error to be: %s, got: %s", tt.wantErr, err)
}
return
}
if method != "LOGIN" {
t.Errorf("expected method return to be: %q, got: %q", "LOGIN", method)
}
resp, err := auth.Next([]byte(user), true)
if err != nil {
t.Errorf("failed on first server challange: %s", err)
}
if !bytes.Equal([]byte(user), resp) {
t.Errorf("expected response to first challange to be: %q, got: %q", user, resp)
}
resp, err = auth.Next([]byte(pass), true)
if err != nil {
t.Errorf("failed on second server challange: %s", err)
}
if !bytes.Equal([]byte(pass), resp) {
t.Errorf("expected response to second challange to be: %q, got: %q", pass, resp)
}
resp, err = auth.Next([]byte("nonsense"), true)
if err == nil {
t.Error("expected third server challange to fail, but didn't")
}
if !errors.Is(err, ErrUnexpectedServerResponse) {
t.Errorf("expected error to be: %s, got: %s", ErrUnexpectedServerResponse, err)
}
})
}
t.Run("LOGIN authentication on test server", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := LoginAuth("user", "pass", TestServerAddr, false)
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
})
t.Run("LOGIN authentication on test server should fail", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FailOnAuth: true,
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := LoginAuth("user", "pass", TestServerAddr, false)
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err == nil {
t.Errorf("expected authentication to fail")
}
})
}
func TestLoginAuth_noEnc(t *testing.T) {
tests := []struct {
name string
authName string
server *ServerInfo
shouldFail bool
wantErr error
}{
{
name: "LOGIN-NOENC auth succeeds",
authName: "servername",
server: &ServerInfo{Name: "servername", TLS: true},
shouldFail: false,
},
{
// OK to use PlainAuth on localhost without TLS
name: "LOGIN-NOENC on localhost is allowed to go unencrypted",
authName: "localhost",
server: &ServerInfo{Name: "localhost", TLS: false},
shouldFail: false,
},
{
// ALSO OK on non-localhost. This auth mode is specificly for that.
name: "LOGIN-NOENC on non-localhost is allowed to go unencrypted",
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"LOGIN"}},
shouldFail: false,
},
{
name: "LOGIN-NOENC on non-localhost with no LOGIN announcement, is not allowed to go unencrypted",
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"CRAM-MD5"}},
shouldFail: false,
},
{
name: "LOGIN-NOENC with wrong hostname",
authName: "servername",
server: &ServerInfo{Name: "attacker", TLS: true},
shouldFail: true,
wantErr: ErrWrongHostname,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := "toni.tester@example.com"
pass := "v3ryS3Cur3P4ssw0rd"
auth := LoginAuth(user, pass, tt.authName, true)
method, _, err := auth.Start(tt.server)
if err != nil && !tt.shouldFail {
t.Errorf("login authentication failed: %s", err)
}
if err == nil && tt.shouldFail {
t.Error("login authentication was expected to fail")
}
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Errorf("expected error to be: %s, got: %s", tt.wantErr, err)
}
return
}
if method != "LOGIN" {
t.Errorf("expected method return to be: %q, got: %q", "LOGIN", method)
}
resp, err := auth.Next([]byte(user), true)
if err != nil {
t.Errorf("failed on first server challange: %s", err)
}
if !bytes.Equal([]byte(user), resp) {
t.Errorf("expected response to first challange to be: %q, got: %q", user, resp)
}
resp, err = auth.Next([]byte(pass), true)
if err != nil {
t.Errorf("failed on second server challange: %s", err)
}
if !bytes.Equal([]byte(pass), resp) {
t.Errorf("expected response to second challange to be: %q, got: %q", pass, resp)
}
resp, err = auth.Next([]byte("nonsense"), true)
if err == nil {
t.Error("expected third server challange to fail, but didn't")
}
if !errors.Is(err, ErrUnexpectedServerResponse) {
t.Errorf("expected error to be: %s, got: %s", ErrUnexpectedServerResponse, err)
}
})
}
t.Run("LOGIN-NOENC authentication on test server", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := LoginAuth("user", "pass", TestServerAddr, true)
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
})
t.Run("LOGIN-NOENC authentication on test server should fail", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FailOnAuth: true,
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := LoginAuth("user", "pass", TestServerAddr, true)
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err == nil {
t.Errorf("expected authentication to fail")
}
})
} }
/* /*
func TestAuthLogin(t *testing.T) {
tests := []struct {
authName string
server *ServerInfo
err string
}{
{
authName: "servername",
server: &ServerInfo{Name: "servername", TLS: true},
},
{
// OK to use LoginAuth on localhost without TLS
authName: "localhost",
server: &ServerInfo{Name: "localhost", TLS: false},
},
{
// NOT OK on non-localhost, even if server says PLAIN is OK.
// (We don't know that the server is the real server.)
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"LOGIN"}},
err: "unencrypted connection",
},
{
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"CRAM-MD5"}},
err: "unencrypted connection",
},
{
authName: "servername",
server: &ServerInfo{Name: "attacker", TLS: true},
err: "wrong host name",
},
}
for i, tt := range tests {
auth := LoginAuth("foo", "bar", tt.authName, false)
_, _, err := auth.Start(tt.server)
got := ""
if err != nil {
got = err.Error()
}
if got != tt.err {
t.Errorf("%d. got error = %q; want %q", i, got, tt.err)
}
}
}
func TestAuthLoginNoEnc(t *testing.T) {
tests := []struct {
authName string
server *ServerInfo
err string
}{
{
authName: "servername",
server: &ServerInfo{Name: "servername", TLS: true},
},
{
// OK to use LoginAuth on localhost without TLS
authName: "localhost",
server: &ServerInfo{Name: "localhost", TLS: false},
},
{
// Also OK on non-TLS secured connections. The NoEnc mechanism is meant to allow
// non-encrypted connections.
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"LOGIN"}},
},
{
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"CRAM-MD5"}},
},
{
authName: "servername",
server: &ServerInfo{Name: "attacker", TLS: true},
err: "wrong host name",
},
}
for i, tt := range tests {
auth := LoginAuth("foo", "bar", tt.authName, true)
_, _, err := auth.Start(tt.server)
got := ""
if err != nil {
got = err.Error()
}
if got != tt.err {
t.Errorf("%d. got error = %q; want %q", i, got, tt.err)
}
}
}
func TestXOAuth2OK(t *testing.T) { func TestXOAuth2OK(t *testing.T) {
server := []string{ server := []string{
@ -2655,3 +3016,229 @@ func startSMTPServer(tlsServer bool, hostname, port string, h func() hash.Hash)
*/ */
// testingKey replaces the substring "TESTING KEY" with "PRIVATE KEY" in the given string s.
func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }
// serverProps represents the configuration properties for the SMTP server.
type serverProps struct {
FailOnAuth bool
FailOnDataInit bool
FailOnDataClose bool
FailOnHelo bool
FailOnMailFrom bool
FailOnNoop bool
FailOnQuit bool
FailOnReset bool
FailOnSTARTTLS bool
FailTemp bool
FeatureSet string
ListenPort int
SSLListener bool
IsTLS bool
SupportDSN bool
}
// simpleSMTPServer starts a simple TCP server that resonds to SMTP commands.
// The provided featureSet represents in what the server responds to EHLO command
// failReset controls if a RSET succeeds
func simpleSMTPServer(ctx context.Context, t *testing.T, props *serverProps) error {
t.Helper()
if props == nil {
return fmt.Errorf("no server properties provided")
}
var listener net.Listener
var err error
if props.SSLListener {
keypair, err := tls.X509KeyPair(localhostCert, localhostKey)
if err != nil {
return fmt.Errorf("failed to read TLS keypair: %w", err)
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{keypair}}
listener, err = tls.Listen(TestServerProto, fmt.Sprintf("%s:%d", TestServerAddr, props.ListenPort),
tlsConfig)
if err != nil {
t.Fatalf("failed to create TLS listener: %s", err)
}
} else {
listener, err = net.Listen(TestServerProto, fmt.Sprintf("%s:%d", TestServerAddr, props.ListenPort))
}
if err != nil {
return fmt.Errorf("unable to listen on %s://%s: %w (SSL: %t)", TestServerProto, TestServerAddr, err,
props.SSLListener)
}
defer func() {
if err := listener.Close(); err != nil {
t.Logf("failed to close listener: %s", err)
}
}()
for {
select {
case <-ctx.Done():
return nil
default:
connection, err := listener.Accept()
var opErr *net.OpError
if err != nil {
if errors.As(err, &opErr) && opErr.Temporary() {
continue
}
return fmt.Errorf("unable to accept connection: %w", err)
}
handleTestServerConnection(connection, t, props)
}
}
}
func handleTestServerConnection(connection net.Conn, t *testing.T, props *serverProps) {
t.Helper()
if !props.IsTLS {
t.Cleanup(func() {
if err := connection.Close(); err != nil {
t.Logf("failed to close connection: %s", err)
}
})
}
reader := bufio.NewReader(connection)
writer := bufio.NewWriter(connection)
writeLine := func(data string) {
_, err := writer.WriteString(data + "\r\n")
if err != nil {
t.Logf("failed to write line: %s", err)
}
_ = writer.Flush()
}
writeOK := func() {
writeLine("250 2.0.0 OK")
}
if !props.IsTLS {
writeLine("220 go-mail test server ready ESMTP")
}
for {
data, err := reader.ReadString('\n')
if err != nil {
break
}
time.Sleep(time.Millisecond)
var datastring string
data = strings.TrimSpace(data)
switch {
case strings.HasPrefix(data, "EHLO"), strings.HasPrefix(data, "HELO"):
if len(strings.Split(data, " ")) != 2 {
writeLine("501 Syntax: EHLO hostname")
break
}
if props.FailOnHelo {
writeLine("500 5.5.2 Error: fail on HELO")
break
}
writeLine("250-localhost.localdomain\r\n" + props.FeatureSet)
case strings.HasPrefix(data, "MAIL FROM:"):
if props.FailOnMailFrom {
writeLine("500 5.5.2 Error: fail on MAIL FROM")
break
}
from := strings.TrimPrefix(data, "MAIL FROM:")
from = strings.ReplaceAll(from, "BODY=8BITMIME", "")
from = strings.ReplaceAll(from, "SMTPUTF8", "")
if props.SupportDSN {
from = strings.ReplaceAll(from, "RET=FULL", "")
}
from = strings.TrimSpace(from)
if !strings.EqualFold(from, "<valid-from@domain.tld>") {
writeLine(fmt.Sprintf("503 5.1.2 Invalid from: %s", from))
break
}
writeOK()
case strings.HasPrefix(data, "RCPT TO:"):
to := strings.TrimPrefix(data, "RCPT TO:")
if props.SupportDSN {
to = strings.ReplaceAll(to, "NOTIFY=FAILURE,SUCCESS", "")
}
to = strings.TrimSpace(to)
if !strings.EqualFold(to, "<valid-to@domain.tld>") {
writeLine(fmt.Sprintf("500 5.1.2 Invalid to: %s", to))
break
}
writeOK()
case strings.HasPrefix(data, "AUTH"):
if props.FailOnAuth {
writeLine("535 5.7.8 Error: authentication failed")
break
}
writeLine("235 2.7.0 Authentication successful")
case strings.EqualFold(data, "DATA"):
if props.FailOnDataInit {
writeLine("503 5.5.1 Error: fail on DATA init")
break
}
writeLine("354 End data with <CR><LF>.<CR><LF>")
for {
ddata, derr := reader.ReadString('\n')
if derr != nil {
t.Logf("failed to read data from connection: %s", derr)
break
}
ddata = strings.TrimSpace(ddata)
if ddata == "." {
if props.FailOnDataClose {
writeLine("500 5.0.0 Error during DATA transmission")
break
}
if props.FailTemp {
writeLine("451 4.3.0 Error: fail on DATA close")
break
}
writeLine("250 2.0.0 Ok: queued as 1234567890")
break
}
datastring += ddata + "\n"
}
case strings.EqualFold(data, "noop"):
if props.FailOnNoop {
writeLine("500 5.0.0 Error: fail on NOOP")
break
}
writeOK()
case strings.EqualFold(data, "vrfy"):
writeOK()
case strings.EqualFold(data, "rset"):
if props.FailOnReset {
writeLine("500 5.1.2 Error: reset failed")
break
}
writeOK()
case strings.EqualFold(data, "quit"):
if props.FailOnQuit {
writeLine("500 5.1.2 Error: quit failed")
break
}
writeLine("221 2.0.0 Bye")
return
case strings.EqualFold(data, "starttls"):
if props.FailOnSTARTTLS {
writeLine("500 5.1.2 Error: starttls failed")
break
}
keypair, err := tls.X509KeyPair(localhostCert, localhostKey)
if err != nil {
writeLine("500 5.1.2 Error: starttls failed - " + err.Error())
break
}
writeLine("220 Ready to start TLS")
tlsConfig := &tls.Config{Certificates: []tls.Certificate{keypair}}
connection = tls.Server(connection, tlsConfig)
props.IsTLS = true
handleTestServerConnection(connection, t, props)
default:
writeLine("500 5.5.2 Error: bad syntax")
}
}
}