Skip to content

deps: remove go-smtp and go-sasl in favor of net/smtp#2467

Open
kotakanbe wants to merge 7 commits intomasterfrom
diet-go-smtp
Open

deps: remove go-smtp and go-sasl in favor of net/smtp#2467
kotakanbe wants to merge 7 commits intomasterfrom
diet-go-smtp

Conversation

@kotakanbe
Copy link
Member

Why (motivation for removing this dependency)

  • emersion/go-smtp and emersion/go-sasl are SMTP client libraries used for sending vulnerability report emails
  • Only used in a single file reporter/email.go
  • Go's stdlib net/smtp provides equivalent SMTP client functionality (Dial, StartTLS, Auth, Mail, Rcpt, Data, Quit)
  • net/smtp has built-in PlainAuth; only LOGIN auth needs a self-implementation (~25 lines)
  • Both dependencies are direct-only — removing them eliminates go-smtp and go-sasl completely from go.mod and go.sum
  • go-smtp generates ~15 Dependabot PRs/year

What (replacement details)

  • Replaced smtp.DialTLS() with tls.Dial("tcp", ...) + smtp.NewClient() for implicit TLS (port 465 / SMTPS mode)
  • Replaced smtp.DialStartTLS() with smtp.Dial() + c.StartTLS() for STARTTLS upgrade
  • Replaced sasl.NewPlainClient() with smtp.PlainAuth()
  • Replaced sasl.NewLoginClient() with self-implemented loginAuth struct implementing smtp.Auth interface
  • Removed extra nil params from c.Mail() and c.Rcpt() calls (go-smtp accepted options, net/smtp doesn't)
  • Improved auto-detect mode ("" with non-465): now upgrades to STARTTLS in-place on the same connection instead of creating a new connection

Changed files

File Change
reporter/email.go Rewrote imports, added loginAuth type, refactored sendMail() with dialTLS()/dialStartTLS() helpers, replaced newSaslClient() with newAuth()
reporter/loginauth_test.go New: 3 test functions, 7 test cases for LOGIN auth
go.mod / go.sum Removed emersion/go-smtp and emersion/go-sasl

Safety (why this is safe)

  • Risk level: medium (SMTP protocol, but patterns are well-established)
  • net/smtp is the official Go stdlib SMTP client, used widely in production
  • The LOGIN auth implementation follows the standard challenge-response protocol (Username:/Password: prompts)
  • Auto-detect STARTTLS mode is actually improved: in-place upgrade vs. the previous approach of opening a second connection
  • All TLS modes preserved: None, STARTTLS, SMTPS, and auto-detect (port-based)

Test plan

  • TestLoginAuthStart — verifies "LOGIN" mechanism returned
  • TestLoginAuthNext — 5 sub-tests: Username/Password prompts (mixed case), and more=false returns nil
  • TestLoginAuthNextUnexpectedChallenge — verifies error on unknown server challenge
  • go build ./cmd/... — pass
  • go test ./... — all pass
  • GOEXPERIMENT=jsonv2 golangci-lint run ./... — 0 issues

Review hint (how to review efficiently)

  1. reporter/email.go lines 21-43loginAuth implementation (the core self-impl, ~25 lines)
  2. reporter/email.go lines 125-147dialTLS() and dialStartTLS() helpers (the TLS connection patterns)
  3. reporter/email.go lines 149-195sendMail() rewrite (compare TLS mode switch with the original)
  4. reporter/loginauth_test.go — tests for the self-implementation
  5. go.mod — confirm go-smtp and go-sasl removed

🤖 Generated with Claude Code

kotakanbe and others added 4 commits March 17, 2026 10:35
Replace emersion/go-smtp and emersion/go-sasl with stdlib net/smtp.
Implement loginAuth for SMTP LOGIN authentication (~25 lines).
Use tls.Dial + smtp.NewClient for SMTPS, smtp.Dial + StartTLS for STARTTLS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kotakanbe kotakanbe requested a review from Copilot March 24, 2026 00:59
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Replaces third-party SMTP client/auth dependencies (emersion/go-smtp, emersion/go-sasl) with Go’s net/smtp, including a small self-implementation of the LOGIN auth mechanism.

Changes:

  • Refactored SMTP connection logic to use net/smtp for SMTPS (implicit TLS), STARTTLS, and plain connections.
  • Added loginAuth implementing smtp.Auth for the LOGIN mechanism and corresponding unit tests.
  • Removed emersion/go-smtp / emersion/go-sasl from go.mod and go.sum.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 5 comments.

File Description
reporter/email.go Migrates SMTP send/auth code to net/smtp, adds LOGIN auth implementation, and rewrites TLS dialing paths.
reporter/loginauth_test.go Adds unit tests covering the new LOGIN auth implementation.
go.mod Drops direct dependencies on emersion/go-smtp and emersion/go-sasl.
go.sum Removes checksums for the removed SMTP/SASL dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +126 to +136
func (e *emailSender) dialTLS(addr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return nil, xerrors.Errorf("Failed to create TLS connection to SMTP server: %w", err)
}
host, _, _ := net.SplitHostPort(addr)
c, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return nil, xerrors.Errorf("Failed to create SMTP client over TLS: %w", err)
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

net.SplitHostPort(addr) errors are ignored; if parsing fails (e.g., unexpected addr format), host may be empty and the SMTP client will be initialized with an incorrect server name. Handle the error explicitly and fail fast (closing the TLS conn) or fall back to a known-good host value (e.g., the configured hostname) rather than proceeding with an empty/invalid host.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd3f0dd. net.SplitHostPort error is now checked and returns an error with conn.Close() cleanup, consistent with the existing error-handling pattern in dialTLS.

username, password string
}

func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loginAuth currently ignores smtp.ServerInfo, so it can attempt LOGIN authentication even when the connection is not encrypted. This can leak credentials in cleartext for TLS mode "None" (or if STARTTLS wasn’t negotiated). Consider enforcing a minimum safety check similar to smtp.PlainAuth (e.g., require server.TLS == true unless connecting to localhost, and optionally validate server.Name against the expected host) and return an error otherwise.

Suggested change
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if server == nil {
return "", nil, errors.New("smtp: server info is required for LOGIN auth")
}
// Enforce a minimum safety check similar to smtp.PlainAuth:
// require TLS unless we're connecting to localhost.
if !server.TLS {
host := server.Name
if h, _, err := net.SplitHostPort(server.Name); err == nil {
host = h
}
switch host {
case "localhost", "127.0.0.1", "::1":
// Allow insecure localhost connections.
default:
return "", nil, errors.New("smtp: refusing LOGIN authentication over unencrypted connection")
}
}

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd3f0dd. loginAuth.Start now checks server.TLS and, if the connection is not TLS, verifies that the server explicitly advertises LOGIN in its auth mechanisms. This mirrors the approach of smtp.PlainAuth in the stdlib, which also guards against sending credentials over unencrypted connections.

defer c.Close()
default:
return xerrors.New(`invalid TLS mode. accepts: ["", "None", "STARTTLS", "SMTPS"]`)
return errors.New(`invalid TLS mode. accepts: ["", "None", "STARTTLS", "SMTPS"]`)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new stdlib errors import solely for this line while the rest of the function uses xerrors. To keep error construction/wrapping consistent and avoid an extra import in this file, consider using xerrors.New(...) (or a fmt.Errorf(...) without wrapping) here instead.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd3f0dd. Replaced errors.New with xerrors.New and removed the "errors" import so the file consistently uses xerrors throughout. (The separate xerrors-removal PR #2465 will convert everything to stdlib errors later.)


func TestLoginAuthNext(t *testing.T) {
t.Parallel()
auth := &loginAuth{username: "user@example.com", password: "s3cret"}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth is shared across subtests. While it’s currently safe because the subtests aren’t parallelized, this creates an easy footgun if someone later adds t.Parallel() inside t.Run. Creating a fresh loginAuth inside each subtest (or capturing a per-test copy) makes the tests more robust against future parallelization.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd3f0dd. Restructured TestLoginAuthStart into subtests, each creating its own auth instance. Also added test cases for the new TLS enforcement logic (non-TLS with LOGIN advertised succeeds, non-TLS without LOGIN advertised fails).

Comment on lines +40 to +43
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := auth.Next([]byte(tt.fromServer), tt.more)
if err != nil {
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth is shared across subtests. While it’s currently safe because the subtests aren’t parallelized, this creates an easy footgun if someone later adds t.Parallel() inside t.Run. Creating a fresh loginAuth inside each subtest (or capturing a per-test copy) makes the tests more robust against future parallelization.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd3f0dd. Moved auth creation inside each subtest body and added t.Parallel() to subtests, so each test case is fully independent.

- Handle net.SplitHostPort error in dialTLS instead of ignoring it
- Add TLS enforcement to loginAuth.Start
- Replace errors.New with xerrors.New for import consistency
- Scope auth per-subtest in loginauth_test.go for t.Parallel safety

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +25 to +33
advertised := false
for _, mech := range server.Auth {
if mech == "LOGIN" {
advertised = true
break
}
}
if !advertised {
return "", nil, xerrors.New("unencrypted connection: LOGIN auth requires TLS or explicit server advertisement")
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loginAuth.Start allows LOGIN auth on non-TLS connections as long as the server advertises "LOGIN". Advertising an auth mechanism doesn’t imply transport security, so this can still send credentials in cleartext (e.g., TLSMode "None" or auto-mode when STARTTLS isn’t available). Consider enforcing the same safety gate as smtp.PlainAuth (require server.TLS unless the server is localhost), or otherwise require an explicit user opt-in before permitting LOGIN over plaintext.

Suggested change
advertised := false
for _, mech := range server.Auth {
if mech == "LOGIN" {
advertised = true
break
}
}
if !advertised {
return "", nil, xerrors.New("unencrypted connection: LOGIN auth requires TLS or explicit server advertisement")
host, _, err := net.SplitHostPort(server.Name)
if err != nil {
host = server.Name
}
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
return "", nil, xerrors.New("unencrypted connection: LOGIN auth requires TLS when not connecting to localhost")

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestion. The current loginAuth.Start already checks server.TLS and only falls through to non-TLS if the server explicitly advertises LOGIN. I have also added strings.EqualFold for case-insensitive mechanism matching (see comment 3 fix).

Regarding making it even stricter (fully blocking non-TLS like smtp.PlainAuth): this PR is focused on removing the go-smtp dependency with behavioral parity. The original go-smtp code did not enforce TLS checks on LOGIN auth. Changing security policy would be a separate concern for a follow-up PR.

That said, I have applied the same "TLS or explicit advertisement" guard pattern consistently to both plainAuth and loginAuth, which is a reasonable middle ground. Fixed in 4bfa654.

defer c.Close()

if ok, param := c.Extension("AUTH"); ok {
authList := strings.Split(param, " ")
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing the server’s AUTH mechanisms with strings.Split(param, " ") can yield empty entries if the server returns multiple spaces or tabs. Using strings.Fields(param) avoids that and is more robust to variations in EHLO formatting.

Suggested change
authList := strings.Split(param, " ")
authList := strings.Fields(param)

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed: replaced strings.Split(param, " ") with strings.Fields(param) which handles multiple spaces and leading/trailing whitespace correctly. Fixed in 4bfa654.

Comment on lines +285 to 292
func (e *emailSender) newAuth(authList []string) smtp.Auth {
for _, v := range authList {
switch v {
case "PLAIN":
auth := sasl.NewPlainClient("", e.conf.User, e.conf.Password)
return auth
return smtp.PlainAuth("", e.conf.User, e.conf.Password, e.conf.SMTPAddr)
case "LOGIN":
auth := sasl.NewLoginClient(e.conf.User, e.conf.Password)
return auth
return &loginAuth{username: e.conf.User, password: e.conf.Password}
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newAuth matches auth mechanism names case-sensitively ("PLAIN"/"LOGIN"), but SMTP extension tokens are case-insensitive and some servers may advertise these as e.g. "login". Consider normalizing (e.g., strings.ToUpper(v) or strings.EqualFold) before switching so auth negotiation doesn’t fail unexpectedly.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid point - RFC 4954 specifies that SASL mechanism names are case-insensitive. Fixed: newAuth now uses strings.ToUpper(v) for the switch, and both loginAuth.Start and the new plainAuth.Start use strings.EqualFold when scanning server.Auth. Fixed in 4bfa654.

Comment on lines 288 to 290
case "PLAIN":
auth := sasl.NewPlainClient("", e.conf.User, e.conf.Password)
return auth
return smtp.PlainAuth("", e.conf.User, e.conf.Password, e.conf.SMTPAddr)
case "LOGIN":
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using smtp.PlainAuth here changes behavior for TLSMode "None" (or any plaintext connection): stdlib PlainAuth refuses to send credentials unless server.TLS is true (except localhost), so AUTH PLAIN may now fail even when users intentionally configured plaintext. If preserving the previous behavior is required, consider either (1) skipping auth on plaintext, (2) allowing plaintext auth only when TLSMode == "None" via a dedicated opt-in path, or (3) implementing an explicit insecure plain auth implementation for that mode.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent catch. You are right that stdlib smtp.PlainAuth refuses to send credentials on non-TLS connections (except localhost), which would break users who intentionally configured TLSMode: "None".

Fixed: replaced smtp.PlainAuth with a custom plainAuth implementation that mirrors the same guard pattern as loginAuth - it allows PLAIN auth on non-TLS connections if the server explicitly advertises the PLAIN mechanism. This preserves behavioral parity with the original go-smtp library that this PR replaces.

The custom plainAuth still constructs the SASL PLAIN response correctly (\x00username\x00password) and warns on non-TLS when the mechanism is not explicitly advertised. Fixed in 4bfa654.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +64 to +68
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
auth := &loginAuth{username: "user@example.com", password: "s3cret"}
got, err := auth.Next([]byte(tt.fromServer), tt.more)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In TestLoginAuthNext, the loop variable tt is captured by the subtest closure while each subtest calls t.Parallel(). In Go this can cause subtests to read the wrong tt values (flaky/incorrect tests). Rebind tt := tt (or pass tt as an argument) inside the loop before t.Run so each parallel subtest gets its own copy.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go 1.22+ (this project uses Go 1.26) fixed the loop variable capture issue, so tt := tt is no longer needed. No change made.

Comment on lines +21 to +23
type plainAuth struct {
identity, username, password, host string
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plainAuth includes a host field but it’s never read. Either remove it to reduce confusion, or use it for an explicit server name check (e.g., compare against server.Name/configured host) so the field has a purpose.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Removed the unused host field from plainAuth struct and the corresponding argument in newAuth. Fixed in 7898f1e.

Comment on lines +25 to +46
func (a *plainAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if !server.TLS {
advertised := false
for _, mech := range server.Auth {
if strings.EqualFold(mech, "PLAIN") {
advertised = true
break
}
}
if !advertised {
return "", nil, xerrors.New("unencrypted connection: PLAIN auth requires TLS or explicit server advertisement")
}
}
resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
return "PLAIN", resp, nil
}

func (a *plainAuth) Next(_ []byte, more bool) ([]byte, error) {
if more {
return nil, xerrors.New("unexpected server challenge")
}
return nil, nil
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new plainAuth behavior isn’t covered by tests (only loginAuth is). Since this auth implementation is security- and interoperability-sensitive, add unit tests for plainAuth.Start (TLS vs non-TLS + advertised mechanisms) and plainAuth.Next (unexpected challenge when more==true).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestPlainAuthStart and TestPlainAuthNext covering TLS/non-TLS paths, identity inclusion in response, and challenge handling. Fixed in 7898f1e.

case strings.Contains(prompt, "password"):
return []byte(a.password), nil
default:
return nil, fmt.Errorf("unexpected server challenge: %q", fromServer)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loginAuth.Next returns errors via fmt.Errorf while the rest of this file consistently uses xerrors.New/xerrors.Errorf. For consistency (and to keep error formatting/wrapping uniform), consider switching this to xerrors.Errorf.

Suggested change
return nil, fmt.Errorf("unexpected server challenge: %q", fromServer)
return nil, xerrors.Errorf("unexpected server challenge: %q", fromServer)

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched loginAuth.Next from fmt.Errorf to xerrors.Errorf for consistency with the rest of the file. Fixed in 7898f1e.

- Remove unused host field from plainAuth struct
- Switch loginAuth.Next error from fmt.Errorf to xerrors.Errorf
- Add unit tests for plainAuth.Start and plainAuth.Next

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants