fix(smtp): AUTH LOGIN fallback when PLAIN is refused

Servers like Postfix with Dovecot SASL or Exchange can fail PLAIN with
451 4.3.2 but accept LOGIN. MindSift now uses LOGIN directly when it is
the only mechanism, and otherwise reconnects and retries with LOGIN after
a refused PLAIN (net/smtp closes the connection on a failed AUTH). If both
fail with 451, the error names both attempts and points to the server's
SASL backend. LOGIN, like PLAIN, is never sent unencrypted to remote hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 00:09:27 +01:00
1 parent 75a7b67d09
commit d8a16c6775
3 files changed
+149 -8

No files matched your search

+61 -4
View File
@@ -8,6 +8,7 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"errors"
"math/big"
"net"
@@ -21,8 +22,13 @@ import (
type fakeSMTP struct {
addr string
rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO
// AUTH behaviour: mechanisms announced, and per mechanism a reply code to fail with ("" = accept)
authMechs string
authFail map[string]string
mu sync.Mutex
authUsed []string // mechanisms tried, in order
authUser string // decoded user of a successful LOGIN
ehlo string
mailFrom string
rcptTo string
@@ -53,7 +59,7 @@ func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode}
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode, authMechs: "PLAIN", authFail: map[string]string{}}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}
go func() {
for {
@@ -86,9 +92,9 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
f.ehlo = cmd[5:]
f.mu.Unlock()
if secure {
reply("250-fake\r\n250 AUTH PLAIN")
reply("250-fake\r\n250 AUTH " + f.authMechs)
} else {
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN")
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH " + f.authMechs)
}
case upper == "STARTTLS":
reply("220 go ahead")
@@ -99,7 +105,27 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
conn, secure = tlsConn, true
r, w = bufio.NewReader(conn), bufio.NewWriter(conn)
case strings.HasPrefix(upper, "AUTH "):
reply("235 ok")
mech := strings.Fields(upper)[1]
f.mu.Lock()
f.authUsed = append(f.authUsed, mech)
fail := f.authFail[mech]
f.mu.Unlock()
if mech == "LOGIN" {
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
u, _ := r.ReadString('\n')
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
_, _ = r.ReadString('\n')
if dec, err := base64.StdEncoding.DecodeString(strings.TrimSpace(u)); err == nil {
f.mu.Lock()
f.authUser = string(dec)
f.mu.Unlock()
}
}
if fail != "" {
reply(fail)
} else {
reply("235 ok")
}
case strings.HasPrefix(upper, "MAIL FROM"):
f.mu.Lock()
f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):])
@@ -213,3 +239,34 @@ func TestEHLONameNeverLocalhost(t *testing.T) {
t.Errorf("fallback must not be localhost, got %q", got)
}
}
func TestAuthFallsBackToLogin(t *testing.T) {
f := startFakeSMTP(t, "")
f.authMechs = "PLAIN LOGIN"
f.authFail["PLAIN"] = "451 4.3.2 Internal server error"
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
t.Fatalf("LOGIN fallback should succeed: %v", err)
}
f.mu.Lock()
defer f.mu.Unlock()
if strings.Join(f.authUsed, ",") != "PLAIN,LOGIN" || f.authUser != "alarm@firma.de" {
t.Fatalf("expected PLAIN then LOGIN with the user, got %v / %q", f.authUsed, f.authUser)
}
}
func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
f := startFakeSMTP(t, "")
f.authMechs = "LOGIN"
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
t.Fatalf("LOGIN-only server: %v", err)
}
g := startFakeSMTP(t, "")
g.authMechs = "PLAIN LOGIN"
g.authFail["PLAIN"] = "451 4.3.2 Internal server error"
g.authFail["LOGIN"] = "451 4.3.2 Internal server error"
err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo")
if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") {
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
}
}