From dbd5b95143ebf42d85db297e6d1c687d467b0934 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:42:05 +0800 Subject: [PATCH] fix: support SMTP LOGIN authentication --- .github/release-notes/v1.2.22.md | 5 +++ VERSION | 2 +- apps/api/internal/app/app_test.go | 45 +++++++++++++++++++++++++- apps/api/internal/app/submission.go | 50 +++++++++++++++++++++++++---- 4 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 .github/release-notes/v1.2.22.md diff --git a/.github/release-notes/v1.2.22.md b/.github/release-notes/v1.2.22.md new file mode 100644 index 0000000..85cf252 --- /dev/null +++ b/.github/release-notes/v1.2.22.md @@ -0,0 +1,5 @@ +- 修复 QQ 邮箱、网易邮箱、Gmail 等第三方客户端可以收信但无法发信的问题。 +- SMTP 提交服务新增 `AUTH LOGIN` 认证支持,并继续兼容 `AUTH PLAIN`;两种方式均只允许在 TLS 加密连接中使用。 +- 兼容带初始用户名和标准两步用户名/密码挑战的 LOGIN 流程,适配常见手机邮箱、Apple Mail 和 Thunderbird。 +- 客户端配置保持 IMAP 993/SSL、POP3 995/SSL、SMTP 465/SSL,不增加额外服务器地址或备用配置。 +- 新增 SMTP 能力声明、LOGIN 认证、STARTTLS、隐式 TLS 和完整发信回归测试。 diff --git a/VERSION b/VERSION index 9728bd6..9a83513 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.21 +1.2.22 diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index a2a3a1f..43dde3e 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -4492,6 +4492,49 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) { } } +func TestSubmissionLoginAuthenticationWithAndWithoutInitialResponse(t *testing.T) { + a := newTestApp(t) + for _, withInitialResponse := range []bool{false, true} { + t.Run(fmt.Sprintf("initial-response-%t", withInitialResponse), func(t *testing.T) { + session := &submissionSession{app: a} + if mechanisms := strings.Join(session.AuthMechanisms(), " "); mechanisms != "PLAIN LOGIN" { + t.Fatalf("submission auth mechanisms=%q", mechanisms) + } + server, err := session.Auth(sasl.Login) + if err != nil { + t.Fatal(err) + } + var response []byte + if withInitialResponse { + response = []byte("admin@lanqin.local") + } + challenge, done, err := server.Next(response) + if err != nil || done { + t.Fatalf("initial LOGIN response err=%v done=%t", err, done) + } + if !withInitialResponse { + if string(challenge) != "Username:" { + t.Fatalf("username challenge=%q", challenge) + } + challenge, done, err = server.Next([]byte("admin@lanqin.local")) + if err != nil || done { + t.Fatalf("username response err=%v done=%t", err, done) + } + } + if string(challenge) != "Password:" { + t.Fatalf("password challenge=%q", challenge) + } + challenge, done, err = server.Next([]byte("ChangeMe123!")) + if err != nil || !done || challenge != nil { + t.Fatalf("password response challenge=%q err=%v done=%t", challenge, err, done) + } + if session.user == nil || session.mailbox == nil { + t.Fatal("LOGIN authentication did not populate submission session") + } + }) + } +} + func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) { a := newTestApp(t) host, port, received := startCapturingSMTP(t, 2) @@ -4529,7 +4572,7 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Auth(sasl.NewPlainClient("", "admin@lanqin.local", "ChangeMe123!")); err != nil { + if err := client.Auth(sasl.NewLoginClient("admin@lanqin.local", "ChangeMe123!")); err != nil { t.Fatal(err) } if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil { diff --git a/apps/api/internal/app/submission.go b/apps/api/internal/app/submission.go index acf89a1..d1553cb 100644 --- a/apps/api/internal/app/submission.go +++ b/apps/api/internal/app/submission.go @@ -121,21 +121,59 @@ type submissionSession struct { } func (s *submissionSession) AuthMechanisms() []string { - return []string{sasl.Plain} + return []string{sasl.Plain, sasl.Login} } func (s *submissionSession) Auth(mech string) (sasl.Server, error) { - if !strings.EqualFold(mech, sasl.Plain) { - return nil, smtpserver.ErrAuthUnknownMechanism - } - return sasl.NewPlainServer(func(identity, username, password string) error { + authenticate := func(username, password string) error { user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password) if err != nil { return smtpserver.ErrAuthFailed } s.user, s.mailbox = user, mailbox return nil - }), nil + } + switch { + case strings.EqualFold(mech, sasl.Plain): + return sasl.NewPlainServer(func(_, username, password string) error { + return authenticate(username, password) + }), nil + case strings.EqualFold(mech, sasl.Login): + return &submissionLoginServer{authenticate: authenticate}, nil + default: + return nil, smtpserver.ErrAuthUnknownMechanism + } +} + +type submissionLoginServer struct { + authenticate func(username, password string) error + username string + step int +} + +func (s *submissionLoginServer) Next(response []byte) ([]byte, bool, error) { + switch s.step { + case 0: + if response == nil { + s.step = 1 + return []byte("Username:"), false, nil + } + s.username = string(response) + s.step = 2 + return []byte("Password:"), false, nil + case 1: + s.username = string(response) + s.step = 2 + return []byte("Password:"), false, nil + case 2: + if err := s.authenticate(s.username, string(response)); err != nil { + return nil, false, err + } + s.step = 3 + return nil, true, nil + default: + return nil, false, sasl.ErrUnexpectedClientResponse + } } func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {