Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/handlers/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions internal/service/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions internal/service/smtp_auth.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
87 changes: 87 additions & 0 deletions internal/service/smtp_auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}