From 441bc4478e366d5101adf16cfb796dd808b31e81 Mon Sep 17 00:00:00 2001 From: jackyhowh Date: Thu, 9 Jul 2026 12:55:17 +0800 Subject: [PATCH] feat(email): negotiate SMTP AUTH mechanism, add Office 365 LOGIN support Go's net/smtp only ships PLAIN and CRAM-MD5. Office 365 / Outlook (smtp.office365.com) advertises only LOGIN and XOAUTH2, and rejects an AUTH PLAIN attempt with "504 5.7.4 Unrecognized authentication type". SMTPAuth negotiates PLAIN or LOGIN from the server's advertised mechanism list at handshake time, falling back to LOGIN when PLAIN isn't offered. The LOGIN implementation refuses to send credentials over a connection that hasn't negotiated TLS. Wires the negotiated auth into both the SMTP connection test and outgoing notification emails, replacing the hardcoded PlainAuth call in each. --- internal/handlers/settings.go | 5 +- internal/service/email.go | 10 ++-- internal/service/smtp_auth.go | 90 ++++++++++++++++++++++++++++++ internal/service/smtp_auth_test.go | 87 +++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 internal/service/smtp_auth.go create mode 100644 internal/service/smtp_auth_test.go diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index c26bf71..907cfd4 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -113,9 +113,10 @@ func (h *SettingsHandler) TestSMTPConnection(c *gin.Context) { return } - // Test connection with TLS/SSL support + // Test connection with TLS/SSL support. The auth mechanism (PLAIN, or LOGIN + // for Office 365 / Outlook) is negotiated from the server's advertised list. addr := fmt.Sprintf("%s:%d", config.Host, config.Port) - auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) + auth := service.SMTPAuth(config.Host, config.Username, config.Password) // Determine if this is an implicit TLS port (SMTPS) isSSLPort := config.Port == 465 || config.Port == 8465 || config.Port == 443 diff --git a/internal/service/email.go b/internal/service/email.go index 6004c8b..bfc1139 100644 --- a/internal/service/email.go +++ b/internal/service/email.go @@ -46,11 +46,11 @@ func (e *EmailService) SendEmail(subject, body string) error { // Determine if this is an implicit TLS port (SMTPS) isSSLPort := config.Port == 465 || config.Port == 8465 || config.Port == 443 - var auth smtp.Auth - var addr string - - auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) - addr = fmt.Sprintf("%s:%d", config.Host, config.Port) + // Negotiate the auth mechanism based on what the server advertises (PLAIN, + // or LOGIN for Office 365 / Outlook). The actual choice happens during the + // handshake once the server's mechanism list is known. + auth := SMTPAuth(config.Host, config.Username, config.Password) + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) if isSSLPort { // Use implicit TLS (direct SSL connection) diff --git a/internal/service/smtp_auth.go b/internal/service/smtp_auth.go new file mode 100644 index 0000000..1fe6c92 --- /dev/null +++ b/internal/service/smtp_auth.go @@ -0,0 +1,90 @@ +package service + +import ( + "errors" + "fmt" + "net/smtp" + "strings" +) + +// SMTPAuth returns an smtp.Auth that negotiates the authentication mechanism +// based on what the server advertises in its EHLO response. It prefers PLAIN, +// but falls back to LOGIN when the server does not offer PLAIN. +// +// This matters for Office 365 / Outlook (smtp.office365.com), which advertises +// only "LOGIN" and "XOAUTH2" and rejects an AUTH PLAIN attempt with +// "504 5.7.4 Unrecognized authentication type". Go's net/smtp only ships PLAIN +// and CRAM-MD5, so LOGIN is implemented here. +func SMTPAuth(host, username, password string) smtp.Auth { + return &autoAuth{host: host, username: username, password: password} +} + +// autoAuth picks PLAIN or LOGIN at negotiation time, once the server's +// advertised mechanism list is available via *smtp.ServerInfo. +type autoAuth struct { + host string + username string + password string + chosen smtp.Auth +} + +func (a *autoAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + hasPlain, hasLogin := false, false + for _, m := range server.Auth { + switch strings.ToUpper(strings.TrimSpace(m)) { + case "PLAIN": + hasPlain = true + case "LOGIN": + hasLogin = true + } + } + + switch { + case hasPlain: + a.chosen = smtp.PlainAuth("", a.username, a.password, a.host) + case hasLogin: + a.chosen = &loginAuth{username: a.username, password: a.password} + default: + // Server didn't advertise a mechanism we recognize; default to PLAIN so + // the server returns a meaningful error rather than us guessing. + a.chosen = smtp.PlainAuth("", a.username, a.password, a.host) + } + + return a.chosen.Start(server) +} + +func (a *autoAuth) Next(fromServer []byte, more bool) ([]byte, error) { + return a.chosen.Next(fromServer, more) +} + +// loginAuth implements the SMTP AUTH LOGIN mechanism. The server prompts for the +// username and then the password (each as a base64-encoded challenge, which +// net/smtp decodes before calling Next). +type loginAuth struct { + username string + password string +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + // Refuse to send credentials in the clear; every SubTrackr SMTP path runs + // over implicit TLS or STARTTLS before authenticating. + if !server.TLS { + return "", nil, errors.New("smtp: refusing to send LOGIN credentials over an unencrypted connection") + } + return "LOGIN", nil, nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + prompt := strings.ToLower(strings.TrimSpace(string(fromServer))) + switch { + case strings.HasPrefix(prompt, "user"): + return []byte(a.username), nil + case strings.HasPrefix(prompt, "pass"): + return []byte(a.password), nil + default: + return nil, fmt.Errorf("smtp: unexpected LOGIN challenge from server: %q", string(fromServer)) + } +} diff --git a/internal/service/smtp_auth_test.go b/internal/service/smtp_auth_test.go new file mode 100644 index 0000000..19643b1 --- /dev/null +++ b/internal/service/smtp_auth_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "net/smtp" + "testing" +) + +func TestAutoAuthChoosesPlainWhenAdvertised(t *testing.T) { + auth := SMTPAuth("mail.example.com", "user", "pass") + server := &smtp.ServerInfo{Name: "mail.example.com", TLS: true, Auth: []string{"LOGIN", "PLAIN"}} + + mech, _, err := auth.Start(server) + if err != nil { + t.Fatalf("Start returned error: %v", err) + } + if mech != "PLAIN" { + t.Errorf("mechanism = %q, want PLAIN (should prefer PLAIN when both are offered)", mech) + } +} + +func TestAutoAuthFallsBackToLoginForOffice365(t *testing.T) { + auth := SMTPAuth("smtp.office365.com", "user@example.com", "pass") + // Office 365 advertises LOGIN and XOAUTH2, but not PLAIN. + server := &smtp.ServerInfo{Name: "smtp.office365.com", TLS: true, Auth: []string{"LOGIN", "XOAUTH2"}} + + mech, _, err := auth.Start(server) + if err != nil { + t.Fatalf("Start returned error: %v", err) + } + if mech != "LOGIN" { + t.Errorf("mechanism = %q, want LOGIN", mech) + } +} + +func TestAutoAuthDefaultsToPlainForUnknownMechanisms(t *testing.T) { + auth := SMTPAuth("mail.example.com", "user", "pass") + server := &smtp.ServerInfo{Name: "mail.example.com", TLS: true, Auth: []string{"XOAUTH2"}} + + mech, _, err := auth.Start(server) + if err != nil { + t.Fatalf("Start returned error: %v", err) + } + if mech != "PLAIN" { + t.Errorf("mechanism = %q, want PLAIN as the fallback default", mech) + } +} + +func TestLoginAuthRefusesUnencryptedConnection(t *testing.T) { + auth := &loginAuth{username: "user", password: "pass"} + server := &smtp.ServerInfo{Name: "mail.example.com", TLS: false} + + if _, _, err := auth.Start(server); err == nil { + t.Error("Start should refuse to proceed when server.TLS is false") + } +} + +func TestLoginAuthNextRespondsToChallenges(t *testing.T) { + auth := &loginAuth{username: "user@example.com", password: "s3cret"} + + got, err := auth.Next([]byte("Username:"), true) + if err != nil { + t.Fatalf("Next(Username:) returned error: %v", err) + } + if string(got) != "user@example.com" { + t.Errorf("Next(Username:) = %q, want %q", got, "user@example.com") + } + + got, err = auth.Next([]byte("Password:"), true) + if err != nil { + t.Fatalf("Next(Password:) returned error: %v", err) + } + if string(got) != "s3cret" { + t.Errorf("Next(Password:) = %q, want %q", got, "s3cret") + } + + if _, err := auth.Next([]byte("Something else:"), true); err == nil { + t.Error("Next should error on an unrecognized challenge") + } + + got, err = auth.Next(nil, false) + if err != nil { + t.Fatalf("Next(more=false) returned error: %v", err) + } + if got != nil { + t.Errorf("Next(more=false) = %q, want nil", got) + } +}