Files
MindSift/internal/api/mail.go
T
sandrodachandClaude Opus 5.5 d8a16c6775 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>
2026-10-29 00:09:27 +01:00

273 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"bytes"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"mime"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"os"
"strings"
"time"
)
// smtpSettings is the effective mail server configuration (admin settings, else environment).
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".
type smtpStepError struct {
Step string
Err error
}
func (e *smtpStepError) Error() string {
return fmt.Sprintf("SMTP-Schritt %s fehlgeschlagen: %v", e.Step, e.Err)
}
func (e *smtpStepError) Unwrap() error { return e.Err }
const smtpTimeout = 30 * time.Second
func (s *Server) smtpSettings() (smtpSettings, error) {
cfg := smtpSettings{Port: "587", TLS: true}
if sys, _ := s.store.GetSystemSettings(); sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
cfg = smtpSettings{
Host: strings.TrimSpace(sys.SMTPHost), Port: strings.TrimSpace(sys.SMTPPort),
User: strings.TrimSpace(sys.SMTPUser), Pass: sys.SMTPPass, From: strings.TrimSpace(sys.SMTPFrom),
TLS: sys.SMTPTLS, SkipVerify: sys.SMTPSkipVerify,
}
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
cfg = smtpSettings{
Host: strings.TrimSpace(s.cfg.SMTPHost), Port: strings.TrimSpace(s.cfg.SMTPPort),
User: strings.TrimSpace(s.cfg.SMTPUser), Pass: s.cfg.SMTPPass, From: strings.TrimSpace(s.cfg.SMTPFrom),
TLS: s.cfg.SMTPTLS, SkipVerify: s.cfg.SMTPSkipVerify,
}
}
if cfg.Host == "" {
return cfg, fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
}
if cfg.Port == "" {
cfg.Port = "587"
}
if cfg.From == "" {
if strings.Contains(cfg.User, "@") {
cfg.From = cfg.User
} else {
cfg.From = "noreply@mindsift.local"
}
}
return cfg, nil
}
// ehloName is how MindSift introduces itself (EHLO). It must name the sending side, never "localhost",
// which strict servers (Exchange/O365, Postfix with HELO checks) refuse: the sender's mail domain,
// else the machine's hostname.
func ehloName(from string) string {
if at := strings.LastIndex(from, "@"); at >= 0 {
if domain := strings.Trim(from[at+1:], " >"); strings.Contains(domain, ".") {
return domain
}
}
if h, err := os.Hostname(); err == nil && strings.Contains(h, ".") {
return h
}
return "mindsift.local"
}
// splitAddress turns "MindSift <alarm@firma.de>" into the bare address for the SMTP envelope
// (MAIL FROM / RCPT TO only accept that) and the header form with an encoded display name.
func splitAddress(raw string) (envelope, header string, err error) {
addr, err := mail.ParseAddress(strings.TrimSpace(raw))
if err != nil {
return "", "", fmt.Errorf("ungültige E-Mail-Adresse %q", raw)
}
return addr.Address, addr.String(), nil
}
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
func buildMessage(from, to, subject, body string, now time.Time) []byte {
idBytes := make([]byte, 12)
_, _ = rand.Read(idBytes)
domain := ehloName(from)
var buf bytes.Buffer
fmt.Fprintf(&buf, "From: %s\r\n", from)
fmt.Fprintf(&buf, "To: %s\r\n", to)
fmt.Fprintf(&buf, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z))
fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain)
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qp := quotedprintable.NewWriter(&buf)
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n")))
_ = qp.Close()
buf.WriteString("\r\n")
return buf.Bytes()
}
// 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}
}
toAddr, toHeader, err := splitAddress(to)
if err != nil {
return &smtpStepError{"Empfänger", err}
}
addr := net.JoinHostPort(cfg.Host, cfg.Port)
tlsCfg := &tls.Config{ServerName: cfg.Host, InsecureSkipVerify: cfg.SkipVerify} //nolint:gosec // admin opt-in for self-signed certificates
dialer := &net.Dialer{Timeout: smtpTimeout}
implicitTLS := cfg.Port == "465" && cfg.TLS
var conn net.Conn
if implicitTLS {
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
} else {
conn, err = dialer.Dial("tcp", addr)
}
if err != nil {
return &smtpStepError{"Verbindung", err}
}
_ = conn.SetDeadline(time.Now().Add(2 * smtpTimeout))
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
_ = conn.Close()
return &smtpStepError{"Begrüßung", err}
}
defer client.Close()
if err := client.Hello(ehloName(cfg.From)); err != nil {
return &smtpStepError{"EHLO", err}
}
if cfg.TLS && !implicitTLS {
if ok, _ := client.Extension("STARTTLS"); !ok {
return &smtpStepError{"STARTTLS", fmt.Errorf("der Server bietet kein STARTTLS an (TLS deaktivieren oder Port 465 nutzen)")}
}
if err := client.StartTLS(tlsCfg); err != nil {
return &smtpStepError{"STARTTLS", err}
}
}
if cfg.User != "" || cfg.Pass != "" {
if err := authenticate(client, cfg); err != nil {
return &smtpStepError{"AUTH", err}
}
}
if err := client.Mail(fromAddr); err != nil {
return &smtpStepError{"MAIL FROM", err}
}
if err := client.Rcpt(toAddr); err != nil {
return &smtpStepError{"RCPT TO", err}
}
wc, err := client.Data()
if err != nil {
return &smtpStepError{"DATA", err}
}
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil {
_ = wc.Close()
return &smtpStepError{"DATA", err}
}
if err := wc.Close(); err != nil {
// The server answers after the final "." – content filters reject here
return &smtpStepError{"Nachricht annehmen", err}
}
_ = client.Quit()
return nil
}