fix: support SMTP STARTTLS mode and NTLM auth (#5426)

* fix: support SMTP STARTTLS mode and NTLM auth

Add explicit SMTP STARTTLS configuration for 587-style connections and keep SSL/TLS as the implicit TLS mode.

Prefer PLAIN when advertised, keep LOGIN compatibility, and add NTLM as a fallback for Exchange SMTP servers that require it after STARTTLS.

* fix: respect explicit SMTP encryption mode

* fix: preserve SMTP TLS compatibility
This commit is contained in:
Benson Yan
2026-06-24 12:45:39 +08:00
committed by GitHub
parent 9fc9c8f1e3
commit 2f23a66733
27 changed files with 959 additions and 52 deletions
+2
View File
@@ -120,6 +120,8 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
var SMTPServer = ""
var SMTPPort = 587
var SMTPSSLEnabled = false
var SMTPStartTLSEnabled = false
var SMTPInsecureSkipVerify = false
var SMTPForceAuthLogin = false
var SMTPAccount = ""
var SMTPFrom = ""
+72 -40
View File
@@ -27,10 +27,52 @@ func shouldUseSMTPLoginAuth() bool {
}
func getSMTPAuth() smtp.Auth {
if shouldUseSMTPLoginAuth() {
return LoginAuth(SMTPAccount, SMTPToken)
return AutoSMTPAuth(SMTPAccount, SMTPToken)
}
func shouldAuthenticateSMTP() bool {
return SMTPAccount != "" && SMTPToken != ""
}
func smtpTLSConfig() *tls.Config {
return &tls.Config{
ServerName: SMTPServer,
InsecureSkipVerify: SMTPInsecureSkipVerify, // #nosec G402 -- admin-controlled SMTP compatibility option.
}
return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
}
func newSMTPClient(addr string) (*smtp.Client, error) {
if SMTPSSLEnabled || (SMTPPort == 465 && !SMTPStartTLSEnabled) {
conn, err := tls.Dial("tcp", addr, smtpTLSConfig())
if err != nil {
return nil, err
}
client, err := smtp.NewClient(conn, SMTPServer)
if err != nil {
_ = conn.Close()
return nil, err
}
return client, nil
}
client, err := smtp.Dial(addr)
if err != nil {
return nil, err
}
if SMTPStartTLSEnabled {
startTLSSupported, _ := client.Extension("STARTTLS")
if !startTLSSupported {
_ = client.Close()
return nil, fmt.Errorf("SMTP server does not support STARTTLS")
}
if err := client.StartTLS(smtpTLSConfig()); err != nil {
_ = client.Close()
return nil, err
}
}
return client, nil
}
func SendEmail(subject string, receiver string, content string) error {
@@ -56,47 +98,37 @@ func SendEmail(subject string, receiver string, content string) error {
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";")
var err error
if SMTPPort == 465 || SMTPSSLEnabled {
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: SMTPServer,
}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, SMTPServer)
if err != nil {
return err
}
defer client.Close()
client, err := newSMTPClient(addr)
if err != nil {
return err
}
defer client.Close()
if shouldAuthenticateSMTP() {
if err = client.Auth(auth); err != nil {
return err
}
if err = client.Mail(SMTPFrom); err != nil {
return err
}
receiverEmails := strings.Split(receiver, ";")
for _, receiver := range receiverEmails {
if err = client.Rcpt(receiver); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(mail)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
if err = client.Mail(SMTPFrom); err != nil {
return err
}
for _, receiver := range to {
if err = client.Rcpt(receiver); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(mail)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
err = client.Quit()
if err != nil {
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
}
+83
View File
@@ -0,0 +1,83 @@
package common
import (
"errors"
"net/smtp"
"strings"
ntlmssp "github.com/Azure/go-ntlmssp"
)
type smtpAutoAuth struct {
username string
password string
mech string
}
func AutoSMTPAuth(username, password string) smtp.Auth {
return &smtpAutoAuth{username: username, password: password}
}
func (a *smtpAutoAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
useLoginAuth := SMTPForceAuthLogin
if !useLoginAuth && shouldUseSMTPLoginAuth() {
useLoginAuth = !(server != nil && len(server.Auth) == 1 && smtpServerSupportsAuth(server, "NTLM"))
}
if useLoginAuth {
a.mech = "LOGIN"
return "LOGIN", []byte{}, nil
}
switch {
case smtpServerSupportsAuth(server, "PLAIN"):
a.mech = "PLAIN"
return "PLAIN", []byte("\x00" + a.username + "\x00" + a.password), nil
case smtpServerSupportsAuth(server, "LOGIN"):
a.mech = "LOGIN"
return "LOGIN", []byte{}, nil
case smtpServerSupportsAuth(server, "NTLM"):
a.mech = "NTLM"
negotiateMessage, err := ntlmssp.NewNegotiateMessage("", "")
if err != nil {
return "", nil, err
}
return "NTLM", negotiateMessage, nil
default:
a.mech = "PLAIN"
return "PLAIN", []byte("\x00" + a.username + "\x00" + a.password), nil
}
}
func (a *smtpAutoAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
switch a.mech {
case "LOGIN":
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("unknown SMTP AUTH LOGIN challenge")
}
case "NTLM":
return ntlmssp.NewAuthenticateMessage(fromServer, a.username, a.password, nil)
default:
return nil, errors.New("unexpected SMTP auth challenge")
}
}
func smtpServerSupportsAuth(server *smtp.ServerInfo, mechanism string) bool {
if server == nil {
return false
}
for _, auth := range server.Auth {
if strings.EqualFold(auth, mechanism) {
return true
}
}
return false
}
+531
View File
@@ -0,0 +1,531 @@
package common
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type fakeSMTPServer struct {
listener net.Listener
host string
port int
cert tls.Certificate
advertiseSTARTTLS bool
authMechanisms []string
messages chan string
authCommands chan string
startTLSCommands chan string
}
func newFakeSMTPServer(t *testing.T) *fakeSMTPServer {
return newFakeSMTPServerWithSTARTTLSAdvertisement(t, true)
}
func newFakeSMTPServerWithSTARTTLSAdvertisement(t *testing.T, advertiseSTARTTLS bool) *fakeSMTPServer {
t.Helper()
cert, err := newTestTLSCertificate()
require.NoError(t, err)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
host, portText, err := net.SplitHostPort(listener.Addr().String())
require.NoError(t, err)
port, err := strconv.Atoi(portText)
require.NoError(t, err)
server := &fakeSMTPServer{
listener: listener,
host: host,
port: port,
cert: cert,
advertiseSTARTTLS: advertiseSTARTTLS,
authMechanisms: []string{"PLAIN", "LOGIN"},
messages: make(chan string, 1),
authCommands: make(chan string, 1),
startTLSCommands: make(chan string, 1),
}
go server.serve()
return server
}
func newFakeImplicitTLSSMTPServer(t *testing.T) *fakeSMTPServer {
t.Helper()
cert, err := newTestTLSCertificate()
require.NoError(t, err)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
host, portText, err := net.SplitHostPort(listener.Addr().String())
require.NoError(t, err)
port, err := strconv.Atoi(portText)
require.NoError(t, err)
server := &fakeSMTPServer{
listener: tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{cert}}),
host: host,
port: port,
cert: cert,
advertiseSTARTTLS: false,
authMechanisms: []string{"PLAIN", "LOGIN"},
messages: make(chan string, 1),
authCommands: make(chan string, 1),
startTLSCommands: make(chan string, 1),
}
go server.serve()
return server
}
func (s *fakeSMTPServer) close() {
_ = s.listener.Close()
}
func (s *fakeSMTPServer) serve() {
conn, err := s.listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
if err := writeSMTPLine(rw, "220 fake.smtp.local ESMTP"); err != nil {
return
}
encrypted := false
for {
line, err := rw.ReadString('\n')
if err != nil {
return
}
command := strings.TrimRight(line, "\r\n")
upperCommand := strings.ToUpper(command)
switch {
case strings.HasPrefix(upperCommand, "EHLO"):
if err := writeSMTPLine(rw, "250-fake.smtp.local"); err != nil {
return
}
if !encrypted && s.advertiseSTARTTLS {
if err := writeSMTPLine(rw, "250-STARTTLS"); err != nil {
return
}
}
if len(s.authMechanisms) > 0 {
if err := writeSMTPLine(rw, "250 AUTH "+strings.Join(s.authMechanisms, " ")); err != nil {
return
}
} else if err := writeSMTPLine(rw, "250 8BITMIME"); err != nil {
return
}
case upperCommand == "STARTTLS":
if encrypted || !s.advertiseSTARTTLS {
if err := writeSMTPLine(rw, "502 5.5.1 STARTTLS not supported"); err != nil {
return
}
continue
}
select {
case s.startTLSCommands <- command:
default:
}
if err := writeSMTPLine(rw, "220 2.0.0 Ready to start TLS"); err != nil {
return
}
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{s.cert}})
if err := tlsConn.Handshake(); err != nil {
return
}
conn = tlsConn
rw = bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
encrypted = true
case strings.HasPrefix(upperCommand, "AUTH"):
select {
case s.authCommands <- command:
default:
}
if err := writeSMTPLine(rw, "235 2.7.0 Authentication successful"); err != nil {
return
}
case strings.HasPrefix(upperCommand, "MAIL FROM:"):
if err := writeSMTPLine(rw, "250 2.1.0 Sender OK"); err != nil {
return
}
case strings.HasPrefix(upperCommand, "RCPT TO:"):
if err := writeSMTPLine(rw, "250 2.1.5 Recipient OK"); err != nil {
return
}
case upperCommand == "DATA":
if err := writeSMTPLine(rw, "354 End data with <CR><LF>.<CR><LF>"); err != nil {
return
}
var data strings.Builder
for {
dataLine, err := rw.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dataLine, "\r\n") == "." {
break
}
data.WriteString(dataLine)
}
s.messages <- data.String()
if err := writeSMTPLine(rw, "250 2.0.0 Queued"); err != nil {
return
}
case upperCommand == "QUIT":
_ = writeSMTPLine(rw, "221 2.0.0 Bye")
return
default:
if err := writeSMTPLine(rw, "502 5.5.1 Command not implemented"); err != nil {
return
}
}
}
}
func writeSMTPLine(rw *bufio.ReadWriter, line string) error {
_, err := rw.WriteString(line + "\r\n")
if err != nil {
return err
}
return rw.Flush()
}
func newTestTLSCertificate() (tls.Certificate, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, err
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "aixinexchange01.aixin-chip.com",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"aixinexchange01", "aixinexchange01.aixin-chip.com"},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return tls.Certificate{}, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
return tls.X509KeyPair(certPEM, keyPEM)
}
func withSMTPSettings(t *testing.T) {
t.Helper()
originalSMTPServer := SMTPServer
originalSMTPPort := SMTPPort
originalSMTPSSLEnabled := SMTPSSLEnabled
originalSMTPStartTLSEnabled := SMTPStartTLSEnabled
originalSMTPInsecureSkipVerify := SMTPInsecureSkipVerify
originalSMTPForceAuthLogin := SMTPForceAuthLogin
originalSMTPAccount := SMTPAccount
originalSMTPFrom := SMTPFrom
originalSMTPToken := SMTPToken
originalSystemName := SystemName
t.Cleanup(func() {
SMTPServer = originalSMTPServer
SMTPPort = originalSMTPPort
SMTPSSLEnabled = originalSMTPSSLEnabled
SMTPStartTLSEnabled = originalSMTPStartTLSEnabled
SMTPInsecureSkipVerify = originalSMTPInsecureSkipVerify
SMTPForceAuthLogin = originalSMTPForceAuthLogin
SMTPAccount = originalSMTPAccount
SMTPFrom = originalSMTPFrom
SMTPToken = originalSMTPToken
SystemName = originalSystemName
})
}
func TestSendEmailUsesExplicitStartTLSWithInsecureCertificate(t *testing.T) {
server := newFakeSMTPServer(t)
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = server.port
SMTPSSLEnabled = false
SMTPStartTLSEnabled = true
SMTPInsecureSkipVerify = true
SMTPForceAuthLogin = false
SMTPAccount = "sender@example.com"
SMTPFrom = "sender@example.com"
SMTPToken = "secret"
SystemName = "New API"
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
require.NoError(t, err)
select {
case message := <-server.messages:
require.Contains(t, message, "Subject: =?UTF-8?B?")
require.Contains(t, message, "<p>123456</p>")
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SMTP DATA")
}
}
func TestSendEmailExplicitStartTLSRequiresServerSupport(t *testing.T) {
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = server.port
SMTPSSLEnabled = false
SMTPStartTLSEnabled = true
SMTPInsecureSkipVerify = true
SMTPForceAuthLogin = false
SMTPAccount = "sender@example.com"
SMTPFrom = "sender@example.com"
SMTPToken = "secret"
SystemName = "New API"
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
require.Error(t, err)
require.Contains(t, err.Error(), "STARTTLS")
}
func TestSendEmailDoesNotAutoUpgradeWhenStartTLSDisabled(t *testing.T) {
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, true)
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = server.port
SMTPSSLEnabled = false
SMTPStartTLSEnabled = false
SMTPInsecureSkipVerify = false
SMTPForceAuthLogin = false
SMTPAccount = "sender@example.com"
SMTPFrom = "sender@example.com"
SMTPToken = "secret"
SystemName = "New API"
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
require.NoError(t, err)
select {
case command := <-server.startTLSCommands:
t.Fatalf("unexpected SMTP STARTTLS command: %s", command)
default:
}
select {
case message := <-server.messages:
require.Contains(t, message, "<p>123456</p>")
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SMTP DATA")
}
}
func TestNewSMTPClientHonorsExplicitStartTLSWhenPortIs465(t *testing.T) {
server := newFakeSMTPServer(t)
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = 465
SMTPSSLEnabled = false
SMTPStartTLSEnabled = true
SMTPInsecureSkipVerify = true
client, err := newSMTPClient(fmt.Sprintf("%s:%d", server.host, server.port))
require.NoError(t, err)
defer client.Close()
select {
case command := <-server.startTLSCommands:
require.Equal(t, "STARTTLS", command)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SMTP STARTTLS")
}
}
func TestNewSMTPClientKeepsImplicitTLSForLegacyPort465(t *testing.T) {
server := newFakeImplicitTLSSMTPServer(t)
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = 465
SMTPSSLEnabled = false
SMTPStartTLSEnabled = false
SMTPInsecureSkipVerify = true
client, err := newSMTPClient(fmt.Sprintf("%s:%d", server.host, server.port))
require.NoError(t, err)
defer client.Close()
}
func TestSendEmailSkipsAuthWhenCredentialsAreEmpty(t *testing.T) {
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = server.port
SMTPSSLEnabled = false
SMTPStartTLSEnabled = false
SMTPInsecureSkipVerify = false
SMTPForceAuthLogin = false
SMTPAccount = ""
SMTPFrom = "sender@example.com"
SMTPToken = ""
SystemName = "New API"
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
require.NoError(t, err)
select {
case command := <-server.authCommands:
t.Fatalf("unexpected SMTP auth command: %s", command)
default:
}
select {
case message := <-server.messages:
require.Contains(t, message, "<p>123456</p>")
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SMTP DATA")
}
}
func TestSendEmailSkipsAuthWhenCredentialsAreIncomplete(t *testing.T) {
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = server.port
SMTPSSLEnabled = false
SMTPStartTLSEnabled = false
SMTPInsecureSkipVerify = false
SMTPForceAuthLogin = false
SMTPAccount = "sender@example.com"
SMTPFrom = "sender@example.com"
SMTPToken = ""
SystemName = "New API"
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
require.NoError(t, err)
select {
case command := <-server.authCommands:
t.Fatalf("unexpected SMTP auth command: %s", command)
default:
}
select {
case message := <-server.messages:
require.Contains(t, message, "<p>123456</p>")
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SMTP DATA")
}
}
func TestSendEmailUsesNTLMWhenServerOnlySupportsNTLM(t *testing.T) {
server := newFakeSMTPServer(t)
server.authMechanisms = []string{"NTLM"}
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = server.port
SMTPSSLEnabled = false
SMTPStartTLSEnabled = true
SMTPInsecureSkipVerify = true
SMTPForceAuthLogin = false
SMTPAccount = "no-reply"
SMTPFrom = "no-reply@example.com"
SMTPToken = "secret"
SystemName = "New API"
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
require.NoError(t, err)
select {
case command := <-server.authCommands:
require.True(t, strings.HasPrefix(command, "AUTH NTLM "), "unexpected auth command: %s", command)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SMTP AUTH")
}
}
func TestSendEmailUsesNTLMForMicrosoftAccountWhenServerOnlySupportsNTLM(t *testing.T) {
server := newFakeSMTPServer(t)
server.authMechanisms = []string{"NTLM"}
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = server.port
SMTPSSLEnabled = false
SMTPStartTLSEnabled = true
SMTPInsecureSkipVerify = true
SMTPForceAuthLogin = false
SMTPAccount = "no-reply@contoso.onmicrosoft.com"
SMTPFrom = "no-reply@contoso.onmicrosoft.com"
SMTPToken = "secret"
SystemName = "New API"
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
require.NoError(t, err)
select {
case command := <-server.authCommands:
require.True(t, strings.HasPrefix(command, "AUTH NTLM "), "unexpected auth command: %s", command)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SMTP AUTH")
}
}
func TestSendEmailExplicitStartTLSRejectsUntrustedCertificateByDefault(t *testing.T) {
server := newFakeSMTPServer(t)
defer server.close()
withSMTPSettings(t)
SMTPServer = server.host
SMTPPort = server.port
SMTPSSLEnabled = false
SMTPStartTLSEnabled = true
SMTPInsecureSkipVerify = false
SMTPForceAuthLogin = false
SMTPAccount = "sender@example.com"
SMTPFrom = "sender@example.com"
SMTPToken = "secret"
SystemName = "New API"
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
require.Error(t, err)
require.Contains(t, fmt.Sprint(err), "certificate")
}
+2
View File
@@ -95,6 +95,8 @@ func InitEnv() {
}
}
}
SMTPStartTLSEnabled = GetEnvOrDefaultBool("SMTP_STARTTLS_ENABLE", GetEnvOrDefaultBool("SMTP_STARTTLS_ENABLED", false))
SMTPInsecureSkipVerify = GetEnvOrDefaultBool("SMTP_INSECURE_SKIP_VERIFY", GetEnvOrDefaultBool("SMTP_TLS_INSECURE_SKIP_VERIFY", false))
// Parse requestInterval and set RequestInterval
requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL"))