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:
1 parent
75a7b67d09
commit
d8a16c6775
3 files changed
+149
-8
No files matched your search
@@ -41,7 +41,8 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa
|
||||
|
||||
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
|
||||
|
||||
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, `AUTH PLAIN`, `MAIL FROM`, `RCPT TO`, Daten.
|
||||
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, Anmeldung, `MAIL FROM`, `RCPT TO`, Daten.
|
||||
- Anmeldung: `AUTH PLAIN`; bietet der Server nur `LOGIN` an, direkt `AUTH LOGIN`. Lehnt er PLAIN ab und bietet auch LOGIN an, baut MindSift die Verbindung neu auf und versucht `AUTH LOGIN` (Go schließt die Verbindung nach einem gescheiterten AUTH). Scheitern beide mit `451`, nennt die Meldung beide Versuche und verweist auf das SASL-Backend des Mailservers (z. B. Dovecot-Auth), denn dann liegt der Fehler dort. Nie unverschlüsselt an fremde Hosts.
|
||||
- `SMTP_FROM` darf einen Namen enthalten (`MindSift <alarm@domain>`): im Umschlag steht nur die Adresse, im Header Name und Adresse.
|
||||
- Die Nachricht hat `Date` und `Message-ID`, einen nach RFC 2047 kodierten Betreff (Umlaute, Emoji) und einen Quoted-Printable-Text.
|
||||
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
|
||||
|
||||
+86
-3
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
@@ -21,6 +22,8 @@ type smtpSettings struct {
|
||||
Host, Port, User, Pass, From string
|
||||
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
|
||||
SkipVerify bool // accept self-signed / mismatching certificates
|
||||
|
||||
authMech string // "" = automatic; "LOGIN" forces AUTH LOGIN (fallback after a refused PLAIN)
|
||||
}
|
||||
|
||||
// smtpStepError names the protocol step at which the mail server refused, e.g. "RCPT TO".
|
||||
@@ -116,9 +119,89 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// sendMail runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
|
||||
// properly, honour SkipVerify and report the exact step a server refuses.
|
||||
// loginAuth implements AUTH LOGIN (username and password as separate Base64 challenges). Some servers,
|
||||
// e.g. Postfix with Dovecot SASL or Exchange, fail on PLAIN but accept LOGIN.
|
||||
type loginAuth struct{ user, pass, host string }
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
// Same rule as smtp.PlainAuth: never send credentials unencrypted to a remote host
|
||||
if !server.TLS && a.host != "localhost" && a.host != "127.0.0.1" && a.host != "::1" {
|
||||
return "", nil, fmt.Errorf("unverschlüsselte Verbindung – Anmeldung verweigert")
|
||||
}
|
||||
return "LOGIN", nil, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
switch prompt := strings.ToLower(strings.TrimSpace(string(fromServer))); {
|
||||
case strings.Contains(prompt, "username"), strings.Contains(prompt, "user"):
|
||||
return []byte(a.user), nil
|
||||
case strings.Contains(prompt, "password"), strings.Contains(prompt, "pass"):
|
||||
return []byte(a.pass), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unerwartete AUTH-LOGIN-Frage %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// authAttemptError records which mechanism failed and whether LOGIN would have been an alternative.
|
||||
type authAttemptError struct {
|
||||
mech string
|
||||
loginOffered bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *authAttemptError) Error() string { return e.mech + ": " + e.err.Error() }
|
||||
func (e *authAttemptError) Unwrap() error { return e.err }
|
||||
|
||||
// authenticate uses PLAIN, or LOGIN when the server offers only that (or when cfg.authMech forces it).
|
||||
// net/smtp closes the connection after a failed AUTH, so a fallback needs a new connection (sendMail).
|
||||
func authenticate(client *smtp.Client, cfg smtpSettings) error {
|
||||
_, mechs := client.Extension("AUTH")
|
||||
offered := " " + strings.ToUpper(mechs) + " "
|
||||
hasLogin := strings.Contains(offered, " LOGIN ")
|
||||
mech := cfg.authMech
|
||||
if mech == "" {
|
||||
mech = "PLAIN"
|
||||
if hasLogin && !strings.Contains(offered, " PLAIN ") {
|
||||
mech = "LOGIN"
|
||||
}
|
||||
}
|
||||
var a smtp.Auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
|
||||
if mech == "LOGIN" {
|
||||
a = &loginAuth{user: cfg.User, pass: cfg.Pass, host: cfg.Host}
|
||||
}
|
||||
if err := client.Auth(a); err != nil {
|
||||
return &authAttemptError{mech: mech, loginOffered: hasLogin, err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
|
||||
// and tries LOGIN; a 451 on both points to the mail server's SASL backend.
|
||||
func sendMail(cfg smtpSettings, to, subject, body string) error {
|
||||
err := sendMailOnce(cfg, to, subject, body)
|
||||
var first *authAttemptError
|
||||
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
|
||||
return err
|
||||
}
|
||||
cfg.authMech = "LOGIN"
|
||||
retryErr := sendMailOnce(cfg, to, subject, body)
|
||||
var second *authAttemptError
|
||||
if retryErr == nil || !errors.As(retryErr, &second) {
|
||||
return retryErr
|
||||
}
|
||||
combined := fmt.Errorf("%v; %v", first, second)
|
||||
if strings.Contains(first.err.Error(), "451") && strings.Contains(second.err.Error(), "451") {
|
||||
combined = fmt.Errorf("%w (der Mailserver meldet einen internen Fehler bei der Anmeldung – bitte dessen SASL/Dovecot-Logs prüfen)", combined)
|
||||
}
|
||||
return &smtpStepError{"AUTH", combined}
|
||||
}
|
||||
|
||||
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
|
||||
// properly, honour SkipVerify and report the exact step a server refuses.
|
||||
func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
|
||||
fromAddr, fromHeader, err := splitAddress(cfg.From)
|
||||
if err != nil {
|
||||
return &smtpStepError{"Absender", err}
|
||||
@@ -162,7 +245,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
|
||||
}
|
||||
}
|
||||
if cfg.User != "" || cfg.Pass != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)); err != nil {
|
||||
if err := authenticate(client, cfg); err != nil {
|
||||
return &smtpStepError{"AUTH", err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user