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
+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}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user