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>
273 lines
9.5 KiB
Go
273 lines
9.5 KiB
Go
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
|
||
}
|