Resolves #34104 When saving SMTP settings with SSL/TLS off, STARTTLS on, and SSL cert verification on, the test-email send produced an opaque Go cert error that gave users no actionable hint. The two TLS-related toggles also live on different settings cards with no cross-reference, which made the conflict hard to spot before hitting Save.
455 lines
13 KiB
Go
455 lines
13 KiB
Go
package mail
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/test"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var (
|
|
testMailpitSMTPPort = getTestMailpitSMTPPort()
|
|
testMailpitWebURL = getTestMailpitWebURL()
|
|
testSMTP4DevSMTPPort = getTestSMTP4DevSMTPPort()
|
|
)
|
|
|
|
func getTestMailpitSMTPPort() uint {
|
|
if port := os.Getenv("FLEET_MAILPIT_SMTP_PORT"); port != "" {
|
|
if p, err := strconv.ParseUint(port, 10, 32); err == nil && p > 0 {
|
|
return uint(p)
|
|
}
|
|
}
|
|
return 1026
|
|
}
|
|
|
|
func getTestMailpitWebURL() string {
|
|
if port := os.Getenv("FLEET_MAILPIT_WEB_PORT"); port != "" {
|
|
return "http://127.0.0.1:" + port
|
|
}
|
|
return "http://127.0.0.1:8026"
|
|
}
|
|
|
|
func getTestSMTP4DevSMTPPort() uint {
|
|
if port := os.Getenv("FLEET_SMTP4DEV_SMTP_PORT"); port != "" {
|
|
if p, err := strconv.ParseUint(port, 10, 32); err == nil && p > 0 {
|
|
return uint(p)
|
|
}
|
|
}
|
|
return 1027
|
|
}
|
|
|
|
var testFunctions = [...]func(*testing.T, fleet.MailService){
|
|
testSMTPPlainAuth,
|
|
testSMTPPlainAuthInvalidCreds,
|
|
testSMTPSkipVerify,
|
|
testSMTPNoAuthWithTLS,
|
|
testSMTPDomain,
|
|
testMailTest,
|
|
}
|
|
|
|
func TestCanSendMail(t *testing.T) {
|
|
settings := fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameUserNamePassword,
|
|
SMTPAuthenticationMethod: fleet.AuthMethodNamePlain,
|
|
SMTPUserName: "mailpit-username",
|
|
SMTPPassword: "mailpit-password",
|
|
SMTPEnableTLS: false,
|
|
SMTPVerifySSLCerts: false,
|
|
SMTPEnableStartTLS: false,
|
|
SMTPPort: testMailpitSMTPPort,
|
|
SMTPServer: "localhost",
|
|
SMTPSenderAddress: "test@example.com",
|
|
}
|
|
|
|
r, err := NewService(config.TestConfig())
|
|
require.NoError(t, err)
|
|
require.True(t, r.CanSendEmail(settings))
|
|
require.False(t, r.CanSendEmail(fleet.SMTPSettings{}))
|
|
}
|
|
|
|
func TestMail(t *testing.T) {
|
|
// This mail test requires mailhog and mailpit (ports read from env vars
|
|
// FLEET_MAILPIT_SMTP_PORT, FLEET_SMTP4DEV_SMTP_PORT, FLEET_MAILPIT_WEB_PORT).
|
|
if _, ok := os.LookupEnv("MAIL_TEST"); !ok {
|
|
t.Skip("Mail tests are disabled")
|
|
}
|
|
|
|
for _, f := range testFunctions {
|
|
r, err := NewService(config.TestConfig())
|
|
require.NoError(t, err)
|
|
|
|
t.Run(test.FunctionName(f), func(t *testing.T) {
|
|
f(t, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testSMTPPlainAuth(t *testing.T, mailer fleet.MailService) {
|
|
mail := fleet.Email{
|
|
Subject: "smtp plain auth",
|
|
To: []string{"john@fleet.co"},
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameUserNamePassword,
|
|
SMTPAuthenticationMethod: fleet.AuthMethodNamePlain,
|
|
SMTPUserName: "mailpit-username",
|
|
SMTPPassword: "mailpit-password",
|
|
SMTPEnableTLS: false,
|
|
SMTPVerifySSLCerts: false,
|
|
SMTPEnableStartTLS: false,
|
|
SMTPPort: testMailpitSMTPPort,
|
|
SMTPServer: "localhost",
|
|
SMTPSenderAddress: "test@example.com",
|
|
},
|
|
Mailer: &SMTPTestMailer{
|
|
BaseURL: "https://localhost:8080",
|
|
},
|
|
}
|
|
|
|
err := mailer.SendEmail(context.Background(), mail)
|
|
assert.Nil(t, err)
|
|
}
|
|
|
|
func testSMTPPlainAuthInvalidCreds(t *testing.T, mailer fleet.MailService) {
|
|
mail := fleet.Email{
|
|
Subject: "smtp plain auth with invalid credentials",
|
|
To: []string{"john@fleet.co"},
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameUserNamePassword,
|
|
SMTPAuthenticationMethod: fleet.AuthMethodNamePlain,
|
|
SMTPUserName: "mailpit-username",
|
|
SMTPPassword: "wrong",
|
|
SMTPEnableTLS: false,
|
|
SMTPVerifySSLCerts: false,
|
|
SMTPEnableStartTLS: false,
|
|
SMTPPort: testMailpitSMTPPort,
|
|
SMTPServer: "localhost",
|
|
SMTPSenderAddress: "test@example.com",
|
|
},
|
|
Mailer: &SMTPTestMailer{
|
|
BaseURL: "https://localhost:8080",
|
|
},
|
|
}
|
|
|
|
err := mailer.SendEmail(context.Background(), mail)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func testSMTPSkipVerify(t *testing.T, mailer fleet.MailService) {
|
|
mail := fleet.Email{
|
|
Subject: "skip verify",
|
|
To: []string{"john@fleet.co"},
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameUserNamePassword,
|
|
SMTPAuthenticationMethod: fleet.AuthMethodNamePlain,
|
|
SMTPUserName: "mailpit-username",
|
|
SMTPPassword: "mailpit-password",
|
|
SMTPEnableTLS: true,
|
|
SMTPVerifySSLCerts: false,
|
|
SMTPEnableStartTLS: true,
|
|
SMTPPort: testSMTP4DevSMTPPort,
|
|
SMTPServer: "localhost",
|
|
SMTPSenderAddress: "test@example.com",
|
|
},
|
|
Mailer: &SMTPTestMailer{
|
|
BaseURL: "https://localhost:8080",
|
|
},
|
|
}
|
|
|
|
err := mailer.SendEmail(context.Background(), mail)
|
|
assert.Nil(t, err)
|
|
}
|
|
|
|
func testSMTPNoAuthWithTLS(t *testing.T, mailer fleet.MailService) {
|
|
mail := fleet.Email{
|
|
Subject: "no auth",
|
|
To: []string{"bob@foo.com"},
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameNone,
|
|
SMTPEnableTLS: true,
|
|
SMTPVerifySSLCerts: true,
|
|
SMTPEnableStartTLS: true,
|
|
SMTPPort: testSMTP4DevSMTPPort,
|
|
SMTPServer: "localhost",
|
|
SMTPSenderAddress: "test@example.com",
|
|
},
|
|
Mailer: &SMTPTestMailer{
|
|
BaseURL: "https://localhost:8080",
|
|
},
|
|
}
|
|
|
|
err := mailer.SendEmail(context.Background(), mail)
|
|
assert.Nil(t, err)
|
|
}
|
|
|
|
func testSMTPDomain(t *testing.T, mailer fleet.MailService) {
|
|
randomAddress := uuid.NewString() + "@example.com"
|
|
|
|
mail := fleet.Email{
|
|
Subject: "custom client hello",
|
|
To: []string{"bob@foo.com"},
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameUserNamePassword,
|
|
SMTPAuthenticationMethod: fleet.AuthMethodNamePlain,
|
|
SMTPUserName: "mailpit-username",
|
|
SMTPPassword: "mailpit-password",
|
|
SMTPEnableTLS: false,
|
|
SMTPVerifySSLCerts: false,
|
|
SMTPEnableStartTLS: false,
|
|
SMTPPort: testMailpitSMTPPort,
|
|
SMTPServer: "localhost",
|
|
SMTPDomain: "custom.domain.example.com",
|
|
SMTPSenderAddress: randomAddress,
|
|
},
|
|
Mailer: &SMTPTestMailer{
|
|
BaseURL: "https://localhost:8080",
|
|
},
|
|
}
|
|
|
|
err := mailer.SendEmail(context.Background(), mail)
|
|
assert.Nil(t, err)
|
|
|
|
rawMsg := getLastRawMailpitMessageFrom(t, randomAddress)
|
|
|
|
require.Contains(t, rawMsg, "Received: from custom.domain.example.com")
|
|
}
|
|
|
|
// Only what we need for the current test. If you need more, fill the struct out.
|
|
// https://mailpit.axllent.org/docs/api-v1/view.html#get-/api/v1/messages
|
|
type MailpitMessages struct {
|
|
Messages []struct {
|
|
Created time.Time
|
|
From struct {
|
|
Address string `json:"Address"`
|
|
Name string `json:"Name"`
|
|
}
|
|
ID string `json:"ID"`
|
|
To []struct {
|
|
Address string `json:"Address"`
|
|
Name string `json:"Name"`
|
|
} `json:"To"`
|
|
BCC []struct {
|
|
Address string `json:"Address"`
|
|
Name string `json:"Name"`
|
|
} `json:"Bcc"`
|
|
} `json:"messages"`
|
|
}
|
|
|
|
func getLastRawMailpitMessageFrom(t *testing.T, address string) string {
|
|
res, err := http.Get(testMailpitWebURL + "/api/v1/messages")
|
|
require.NoError(t, err)
|
|
|
|
var messages MailpitMessages
|
|
err = json.NewDecoder(res.Body).Decode(&messages)
|
|
require.NoError(t, err)
|
|
|
|
var messageID string
|
|
for _, message := range messages.Messages {
|
|
if message.From.Address == address {
|
|
messageID = message.ID
|
|
}
|
|
}
|
|
require.NotNilf(t, messageID, "could not find message from %s in mailpit", address)
|
|
|
|
res, err = http.Get(fmt.Sprintf("%s/api/v1/message/%s/raw", testMailpitWebURL, messageID))
|
|
require.NoError(t, err)
|
|
|
|
rawMail, err := io.ReadAll(res.Body)
|
|
require.NoError(t, err)
|
|
|
|
return string(rawMail)
|
|
}
|
|
|
|
func testMailTest(t *testing.T, mailer fleet.MailService) {
|
|
mail := fleet.Email{
|
|
Subject: "test tester",
|
|
To: []string{"bob@foo.com"},
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameUserNamePassword,
|
|
SMTPAuthenticationMethod: fleet.AuthMethodNamePlain,
|
|
SMTPUserName: "foo",
|
|
SMTPPassword: "bar",
|
|
SMTPEnableTLS: true,
|
|
SMTPVerifySSLCerts: true,
|
|
SMTPEnableStartTLS: true,
|
|
SMTPPort: testSMTP4DevSMTPPort,
|
|
SMTPServer: "localhost",
|
|
SMTPSenderAddress: "test@example.com",
|
|
},
|
|
Mailer: &SMTPTestMailer{
|
|
BaseURL: "https://localhost:8080",
|
|
},
|
|
}
|
|
err := Test(mailer, mail)
|
|
assert.Nil(t, err)
|
|
}
|
|
|
|
func TestTemplateProcessor(t *testing.T) {
|
|
mailer := PasswordResetMailer{
|
|
BaseURL: "https://localhost.com:8080",
|
|
Token: "12345",
|
|
}
|
|
|
|
out, err := mailer.Message()
|
|
require.Nil(t, err)
|
|
assert.NotNil(t, out)
|
|
}
|
|
|
|
func startFakeSMTPServerFailingSTARTTLS(t *testing.T) (host string, port uint) {
|
|
t.Helper()
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func(c net.Conn) {
|
|
defer c.Close()
|
|
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
|
r := bufio.NewReader(c)
|
|
_, _ = io.WriteString(c, "220 fake.smtp.test ESMTP\r\n")
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
cmd := strings.ToUpper(strings.TrimSpace(line))
|
|
switch {
|
|
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
|
_, _ = io.WriteString(c, "250-fake.smtp.test\r\n250 STARTTLS\r\n")
|
|
case cmd == "STARTTLS":
|
|
_, _ = io.WriteString(c, "220 Ready to start TLS\r\n")
|
|
// Close immediately so the client's TLS handshake fails.
|
|
return
|
|
case cmd == "QUIT":
|
|
_, _ = io.WriteString(c, "221 Bye\r\n")
|
|
return
|
|
default:
|
|
_, _ = io.WriteString(c, "500 unrecognized\r\n")
|
|
}
|
|
}
|
|
}(conn)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = ln.Close()
|
|
<-done
|
|
})
|
|
|
|
addr := ln.Addr().(*net.TCPAddr)
|
|
return "127.0.0.1", uint(addr.Port) //nolint:gosec // dismiss G115 — TCPAddr.Port is in [0, 65535]
|
|
}
|
|
|
|
// fakeMailer satisfies fleet.Mailer without depending on bindata templates.
|
|
type fakeMailer struct{}
|
|
|
|
func (fakeMailer) Message() ([]byte, error) { return []byte("Subject: test\r\n\r\nbody\r\n"), nil }
|
|
|
|
func TestSendMailSTARTTLSFailureWithoutSSLTLS(t *testing.T) {
|
|
host, port := startFakeSMTPServerFailingSTARTTLS(t)
|
|
|
|
err := Test(mailService{}, fleet.Email{
|
|
Subject: "test",
|
|
To: []string{"to@example.com"},
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameNone,
|
|
SMTPAuthenticationMethod: fleet.AuthMethodNamePlain,
|
|
SMTPVerifySSLCerts: true,
|
|
SMTPEnableTLS: false,
|
|
SMTPEnableStartTLS: true,
|
|
SMTPPort: port,
|
|
SMTPServer: host,
|
|
SMTPSenderAddress: "test@example.com",
|
|
},
|
|
Mailer: fakeMailer{},
|
|
})
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrSTARTTLSWithoutSSLTLS)
|
|
}
|
|
|
|
func TestSendMailSTARTTLSFailureWithVerifyOff(t *testing.T) {
|
|
host, port := startFakeSMTPServerFailingSTARTTLS(t)
|
|
|
|
err := Test(mailService{}, fleet.Email{
|
|
Subject: "test",
|
|
To: []string{"to@example.com"},
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPConfigured: true,
|
|
SMTPAuthenticationType: fleet.AuthTypeNameNone,
|
|
SMTPAuthenticationMethod: fleet.AuthMethodNamePlain,
|
|
SMTPVerifySSLCerts: false, // user opted into skip-verify
|
|
SMTPEnableTLS: false,
|
|
SMTPEnableStartTLS: true,
|
|
SMTPPort: port,
|
|
SMTPServer: host,
|
|
SMTPSenderAddress: "test@example.com",
|
|
},
|
|
Mailer: fakeMailer{},
|
|
})
|
|
require.Error(t, err)
|
|
require.NotErrorIs(t, err, ErrSTARTTLSWithoutSSLTLS)
|
|
require.Contains(t, err.Error(), "startTLS error")
|
|
}
|
|
|
|
func Test_getFrom(t *testing.T) {
|
|
type args struct {
|
|
e fleet.Email
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want string
|
|
wantErr assert.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "should return SMTP formatted From string",
|
|
args: args{
|
|
e: fleet.Email{
|
|
SMTPSettings: fleet.SMTPSettings{
|
|
SMTPSenderAddress: "foo@bar.com",
|
|
},
|
|
},
|
|
},
|
|
want: "From: foo@bar.com\r\n",
|
|
wantErr: assert.NoError,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := getFrom(tt.args.e)
|
|
if !tt.wantErr(t, err, fmt.Sprintf("getFrom(%v)", tt.args.e)) {
|
|
return
|
|
}
|
|
assert.Equalf(t, tt.want, got, "getFrom(%v)", tt.args.e)
|
|
})
|
|
}
|
|
}
|