package smtpserver import ( "fmt" "time" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "mailgoserver/internal/abuseguard" "mailgoserver/internal/db" ) // loginServer implements the LOGIN SASL mechanism server-side (go-sasl only ships the // client half), mirroring the state machine aiosmtpd's built-in LOGIN handler drives: // ask for username (unless an initial response already supplied it), then password. type loginServer struct { state int // 0: need username, 1: need password username string verify func(username, password string) error } func (s *loginServer) Next(response []byte) (challenge []byte, done bool, err error) { switch s.state { case 0: if response == nil { return []byte("Username:"), false, nil } s.username = string(response) s.state = 1 return []byte("Password:"), false, nil case 1: password := string(response) if err := s.verify(s.username, password); err != nil { return nil, false, err } return nil, true, nil default: return nil, false, fmt.Errorf("unexpected LOGIN state") } } // AuthMechanisms mirrors CustomSMTP._get_auth_methods's effective mechanism set // (aiosmtpd's default LOGIN/PLAIN) once auth is allowed at all — the TLS-required gate // itself is handled by go-smtp's own AllowInsecureAuth/isTLS check per listener. func (s *Session) AuthMechanisms() []string { return []string{sasl.Login, sasl.Plain} } // Auth mirrors EnhancedCombinedAuthenticator.__call__ for the LOGIN/PLAIN case (the // only mechanisms advertised): credentials are always present by the time verify runs, // so the "no auth_data supplied" fallback branch in the Python version is unreachable // here and isn't replicated. func (s *Session) Auth(mech string) (sasl.Server, error) { switch mech { case sasl.Login: return &loginServer{verify: s.authenticate}, nil case sasl.Plain: return sasl.NewPlainServer(func(identity, username, password string) error { return s.authenticate(username, password) }), nil default: return nil, smtp.ErrAuthUnknownMechanism } } // authenticate mirrors EnhancedAuthenticator.__call__: verifies credentials, logs an // AuthLog row either way, and on any failure returns a *smtp.SMTPError carrying the // exact Python response code/message, arming the connection to close right after that // response is flushed — mirroring CustomSMTP.smtp_AUTH's transport.close() override. // // Two independent identity types can authenticate here: a Sender (relay-only, tried // first — unchanged from the original behavior), or a mailbox's app password (never // its portal password — see esrv_mailbox_app_passwords), which lets a mailbox owner // send mail as their own primary address or a send-as-enabled alias. func (s *Session) authenticate(username, password string) error { sender, err := s.backend.DB.GetSenderByEmail(username) if err != nil { s.backend.Logger.Error("Authentication error: %v", err) _ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", err)) abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return s.failAuth(451, "Internal server error") } if sender != nil && db.CheckPassword(password, sender.PasswordHash) { s.authenticatedSender = sender s.authType = "sender" s.username = username _ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, true, "Successful sender authentication") return nil } if s.backend.Mailstore != nil { mbox, merr := s.backend.DB.VerifyMailboxAppPassword(username, password) if merr != nil { s.backend.Logger.Error("Mailbox authentication error: %v", merr) _ = s.backend.DB.LogAuthAttempt("mailbox", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", merr)) abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return s.failAuth(451, "Internal server error") } if mbox != nil { s.authenticatedMailbox = mbox s.authType = "mailbox" s.username = username _ = s.backend.DB.LogAuthAttempt("mailbox", username, s.peerIP, true, "Successful mailbox app-password authentication") return nil } } _ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username)) abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return s.failAuth(535, "Authentication failed") } // failAuth builds the SMTPError for a failed AUTH attempt and closes the connection // shortly after go-smtp writes this response, mirroring CustomSMTP.smtp_AUTH's // transport.close() override. go-smtp writes the response synchronously right after // this error is returned, so a short delay comfortably outlasts that write without // needing to intercept the raw connection (which would break TLS detection on the // implicit-TLS listener — see server.go). func (s *Session) failAuth(code int, message string) error { conn := s.conn go func() { time.Sleep(100 * time.Millisecond) conn.Close() }() return &smtp.SMTPError{Code: code, EnhancedCode: smtp.NoEnhancedCode, Message: message} }