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
29 changes: 21 additions & 8 deletions notify/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
}

// wrapSMTPErr formats err with the given context message and, if err
// carries an SMTP reply code, wraps the result in a notify.ErrorWithReason
// so the failure surfaces in Alertmanager's per-reason notification metrics
// the same way HTTP-based notifiers already do.
func wrapSMTPErr(context string, err error) error {
wrapped := fmt.Errorf("%s: %w", context, err)

if tpErr, ok := errors.AsType[*textproto.Error](err); ok {
return notify.NewErrorWithReason(notify.GetFailureReasonFromSMTPCode(tpErr.Code), wrapped)
}
return wrapped
}

// auth resolves a string of authentication mechanisms.
func (n *Email) auth(mechs string) (smtp.Auth, error) {
username := n.conf.AuthUsername
Expand Down Expand Up @@ -165,7 +178,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
c, err = smtp.NewClient(conn, n.conf.Smarthost.Host)
if err != nil {
conn.Close()
return true, fmt.Errorf("create SMTP client: %w", err)
return true, wrapSMTPErr("create SMTP client", err)
}
defer func() {
// Try to clean up after ourselves but don't log anything if something has failed.
Expand All @@ -177,7 +190,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
if n.conf.Hello != "" {
err = c.Hello(n.conf.Hello)
if err != nil {
return true, fmt.Errorf("send EHLO command: %w", err)
return true, wrapSMTPErr("send EHLO command", err)
}
}

Expand All @@ -196,7 +209,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
}

if err := c.StartTLS(tlsConf); err != nil {
return true, fmt.Errorf("send STARTTLS command: %w", err)
return true, wrapSMTPErr("send STARTTLS command", err)
}
}

Expand All @@ -207,7 +220,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return true, fmt.Errorf("%T auth: %w", auth, err)
return true, wrapSMTPErr(fmt.Sprintf("%T auth", auth), err)
}
}
}
Expand All @@ -234,22 +247,22 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
return false, fmt.Errorf("must be exactly one 'from' address (got: %d)", len(addrs))
}
if err = c.Mail(addrs[0].Address); err != nil {
return true, fmt.Errorf("send MAIL command: %w", err)
return true, wrapSMTPErr("send MAIL command", err)
}
addrs, err = mail.ParseAddressList(to)
if err != nil {
return false, fmt.Errorf("parse 'to' addresses: %w", err)
}
for _, addr := range addrs {
if err = c.Rcpt(addr.Address); err != nil {
return true, fmt.Errorf("send RCPT command: %w", err)
return true, wrapSMTPErr("send RCPT command", err)
}
}

// Send the email headers and body.
message, err := c.Data()
if err != nil {
return true, fmt.Errorf("send DATA command: %w", err)
return true, wrapSMTPErr("send DATA command", err)
}
closeOnce := sync.OnceValue(func() error {
return message.Close()
Expand Down Expand Up @@ -375,7 +388,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {

// Complete the message and await response.
if err = closeOnce(); err != nil {
return true, fmt.Errorf("delivery failure: %w", err)
return true, wrapSMTPErr("delivery failure", err)
}

success = true
Expand Down
58 changes: 58 additions & 0 deletions notify/email/email_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,14 @@ func TestEmailRejected(t *testing.T) {
require.ErrorContains(t, err, "501")
require.ErrorContains(t, err, "5.5.4")
require.True(t, retry)

// A 501 (5xx) SMTP reply is a permanent failure, which should surface
// as ClientErrorReason, mirroring how HTTP-based notifiers report 4xx
// responses (SMTP's 4xx/5xx split is the inverse of HTTP's).
var reasonErr *notify.ErrorWithReason
require.ErrorAs(t, err, &reasonErr, "expected error to carry a notify.ErrorWithReason")
require.Equal(t, notify.ClientErrorReason, reasonErr.Reason)

require.NoError(t, srv.Shutdown(ctx))

require.Eventuallyf(t, func() bool {
Expand All @@ -744,6 +752,56 @@ func TestEmailRejected(t *testing.T) {
}, time.Second*10, time.Millisecond*100, "mock SMTP server goroutine failed to close in time")
}

// TestEmailGreetingRejected simulates a server that rejects the connection at the initial SMTP
// greeting (before any session is established), which net/smtp.NewClient surfaces directly.
func TestEmailGreetingRejected(t *testing.T) {
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
t.Cleanup(func() { _ = l.Close() })

done := make(chan any, 1)
go func() {
conn, err := l.Accept()
if err != nil {
close(done)
return
}
// A 421 greeting means the service is temporarily unavailable; net/smtp.NewClient
// returns this as a *textproto.Error before any session/session commands happen.
_, _ = conn.Write([]byte("421 Service not available, closing transmission channel\r\n"))
_ = conn.Close()
close(done)
}()

require.IsType(t, &net.TCPAddr{}, l.Addr())
addr := l.Addr().(*net.TCPAddr)
cfg := &config.EmailConfig{
Smarthost: config.HostPort{Host: addr.IP.String(), Port: strconv.Itoa(addr.Port)},
Hello: "localhost",
Headers: make(map[string]string),
From: "alertmanager@system",
To: "sre@company",
}
tmpl, firingAlert, err := prepare(cfg)
require.NoError(t, err)

e := New(cfg, tmpl, promslog.NewNopLogger())

retry, err := e.Notify(context.Background(), firingAlert)
require.ErrorContains(t, err, "421")
require.True(t, retry)

// A 421 (4xx) greeting is a temporary failure, which should surface as ServerErrorReason.
var reasonErr *notify.ErrorWithReason
require.ErrorAs(t, err, &reasonErr, "expected error to carry a notify.ErrorWithReason")
require.Equal(t, notify.ServerErrorReason, reasonErr.Reason)

require.Eventuallyf(t, func() bool {
<-done
return true
}, time.Second*10, time.Millisecond*100, "mock listener goroutine failed to close in time")
}

func mockSMTPServer(t *testing.T) (*smtp.Server, net.Listener, error) {
t.Helper()

Expand Down
21 changes: 21 additions & 0 deletions notify/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,24 @@ func GetFailureReasonFromStatusCode(statusCode int) Reason {

return DefaultReason
}

// GetFailureReasonFromSMTPCode returns the reason for the failure based on
// the SMTP reply code provided. Note that SMTP's 4xx/5xx split is the
// inverse of HTTP's: an SMTP 4xx is a transient failure (the server is
// asking the client to retry later), while a 5xx is permanent (the server
// is rejecting the request outright). This mirrors the retry semantics
// already used elsewhere in Alertmanager, not the HTTP status code ranges.
func GetFailureReasonFromSMTPCode(code int) Reason {
if code == 535 {
// RFC 4954: 535 is the standard reply for authentication failure.
return AuthErrorReason
}
if code/100 == 4 {
return ServerErrorReason
}
if code/100 == 5 {
return ClientErrorReason
}

return DefaultReason
}
20 changes: 20 additions & 0 deletions notify/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,23 @@ func TestGetFailureReasonFromStatusCode(t *testing.T) {
})
}
}

func TestGetFailureReasonFromSMTPCode(t *testing.T) {
for _, tc := range []struct {
name string
code int
expected Reason
}{
{"AuthenticationFailed", 535, AuthErrorReason},
{"TransientMailboxUnavailable", 450, ServerErrorReason},
{"ServiceNotAvailable", 421, ServerErrorReason},
{"MailboxUnavailable", 550, ClientErrorReason},
{"SyntaxError", 501, ClientErrorReason},
{"Success", 250, DefaultReason},
{"IntermediateReply", 354, DefaultReason},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, GetFailureReasonFromSMTPCode(tc.code))
})
}
}