diff --git a/Doku/modules/tasks.md b/Doku/modules/tasks.md index 8e0977c..c39914c 100644 --- a/Doku/modules/tasks.md +++ b/Doku/modules/tasks.md @@ -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 `): 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). diff --git a/internal/api/mail.go b/internal/api/mail.go index 36e0123..d5dcb6c 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -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} } } diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go index 4be8459..6e2d321 100644 --- a/internal/api/mail_test.go +++ b/internal/api/mail_test.go @@ -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) + } +}