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
27 changes: 20 additions & 7 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 @@ -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
8 changes: 8 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 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))
})
}
}