Proposal change for #90

Did a complete overhaul of the senderror.go.

- the list of `errors.New()` has been replaced with constant itoa error reasons as `SendErrReason`. Instead, the `Error()` method now reports the corresponding error message based on the reason.
- The `SendError` received a `Is()` method so that we can use `errors.Is()` for very specific error checking. I.e. we can check for `&SendErrors{Reason: ErrSMTPMailFrom, isTemp: true}`. This provides much more flexibility in the error checking capabilities
- A `isTemp` field has been added to the `SendError` type, indicating whether the received error is temporary and can be retried or not. Accordingly, the `*Msg` now has a `SendErrorIsTemp()` method indicating the same. The decision is based on the first 3 characters returned from the SMTP server. If the error code is within the 4xx range, the error is seen as temporary
- A test for the SendError type has been added
This commit is contained in:
Winni Neessen 2023-01-01 14:20:13 +01:00
parent 42a1fde51f
commit 78df991399
Signed by: wneessen
GPG key ID: 385AC9889632126E
6 changed files with 189 additions and 49 deletions

View file

@ -22,41 +22,42 @@ func (c *Client) Send(ml ...*Msg) error {
if m.encoding == NoEncoding { if m.encoding == NoEncoding {
if ok, _ := c.sc.Extension("8BITMIME"); !ok { if ok, _ := c.sc.Extension("8BITMIME"); !ok {
errs = append(errs, ErrServerNoUnencoded) errs = append(errs, ErrServerNoUnencoded)
m.sendError = SendError{Err: ErrServerNoUnencoded} m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false}
continue continue
} }
} }
f, err := m.GetSender(false) f, err := m.GetSender(false)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
m.sendError = SendError{Err: ErrGetSender, details: []error{err}} m.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
rl, err := m.GetRecipients() rl, err := m.GetRecipients()
if err != nil { if err != nil {
m.sendError = SendError{Err: ErrGetRcpts, details: []error{err}} m.sendError = &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)}
errs = append(errs, err) errs = append(errs, err)
continue continue
} }
if err := c.mail(f); err != nil { if err := c.mail(f); err != nil {
errs = append(errs, fmt.Errorf("sending MAIL FROM command failed: %w", err)) errs = append(errs, fmt.Errorf("sending MAIL FROM command failed: %w", err))
m.sendError = SendError{Err: ErrSMTPMailFrom, details: []error{err}} m.sendError = &SendError{Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err)}
if reserr := c.sc.Reset(); reserr != nil { if reserr := c.sc.Reset(); reserr != nil {
errs = append(errs, reserr) errs = append(errs, reserr)
} }
continue continue
} }
failed := false failed := false
rse := SendError{} rse := &SendError{}
rse.details = make([]error, 0) rse.errlist = make([]error, 0)
rse.rcpt = make([]string, 0) rse.rcpt = make([]string, 0)
for _, r := range rl { for _, r := range rl {
if err := c.rcpt(r); err != nil { if err := c.rcpt(r); err != nil {
errs = append(errs, fmt.Errorf("sending RCPT TO command failed: %w", err)) errs = append(errs, fmt.Errorf("sending RCPT TO command failed: %w", err))
rse.Err = ErrSMTPRcptTo rse.Reason = ErrSMTPRcptTo
rse.details = append(rse.details, err) rse.errlist = append(rse.errlist, err)
rse.rcpt = append(rse.rcpt, r) rse.rcpt = append(rse.rcpt, r)
rse.isTemp = isTempError(err)
failed = true failed = true
} }
} }
@ -70,30 +71,30 @@ func (c *Client) Send(ml ...*Msg) error {
w, err := c.sc.Data() w, err := c.sc.Data()
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("sending DATA command failed: %w", err)) errs = append(errs, fmt.Errorf("sending DATA command failed: %w", err))
m.sendError = SendError{Err: ErrSMTPData, details: []error{err}} m.sendError = &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
_, err = m.WriteTo(w) _, err = m.WriteTo(w)
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("sending mail content failed: %w", err)) errs = append(errs, fmt.Errorf("sending mail content failed: %w", err))
m.sendError = SendError{Err: ErrWriteContent, details: []error{err}} m.sendError = &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
if err := w.Close(); err != nil { if err := w.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close DATA writer: %w", err)) errs = append(errs, fmt.Errorf("failed to close DATA writer: %w", err))
m.sendError = SendError{Err: ErrSMTPDataClose, details: []error{err}} m.sendError = &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
if err := c.Reset(); err != nil { if err := c.Reset(); err != nil {
errs = append(errs, fmt.Errorf("sending RSET command failed: %w", err)) errs = append(errs, fmt.Errorf("sending RSET command failed: %w", err))
m.sendError = SendError{Err: ErrSMTPReset, details: []error{err}} m.sendError = &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
if err := c.checkConn(); err != nil { if err := c.checkConn(); err != nil {
errs = append(errs, fmt.Errorf("failed to check server connection: %w", err)) errs = append(errs, fmt.Errorf("failed to check server connection: %w", err))
m.sendError = SendError{Err: ErrConnCheck, details: []error{err}} m.sendError = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
} }

View file

@ -23,41 +23,42 @@ func (c *Client) Send(ml ...*Msg) (rerr error) {
if m.encoding == NoEncoding { if m.encoding == NoEncoding {
if ok, _ := c.sc.Extension("8BITMIME"); !ok { if ok, _ := c.sc.Extension("8BITMIME"); !ok {
rerr = errors.Join(rerr, ErrServerNoUnencoded) rerr = errors.Join(rerr, ErrServerNoUnencoded)
m.sendError = SendError{Err: ErrServerNoUnencoded} m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false}
continue continue
} }
} }
f, err := m.GetSender(false) f, err := m.GetSender(false)
if err != nil { if err != nil {
rerr = errors.Join(rerr, err) rerr = errors.Join(rerr, err)
m.sendError = SendError{Err: ErrGetSender, details: []error{err}} m.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
rl, err := m.GetRecipients() rl, err := m.GetRecipients()
if err != nil { if err != nil {
rerr = errors.Join(rerr, err) rerr = errors.Join(rerr, err)
m.sendError = SendError{Err: ErrGetRcpts, details: []error{err}} m.sendError = &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
if err := c.mail(f); err != nil { if err := c.mail(f); err != nil {
rerr = errors.Join(rerr, fmt.Errorf("sending MAIL FROM command failed: %w", err)) rerr = errors.Join(rerr, fmt.Errorf("sending MAIL FROM command failed: %w", err))
m.sendError = SendError{Err: ErrSMTPMailFrom, details: []error{err}} m.sendError = &SendError{Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err)}
if reserr := c.sc.Reset(); reserr != nil { if reserr := c.sc.Reset(); reserr != nil {
rerr = errors.Join(rerr, reserr) rerr = errors.Join(rerr, reserr)
} }
continue continue
} }
failed := false failed := false
rse := SendError{} rse := &SendError{}
rse.details = make([]error, 0) rse.errlist = make([]error, 0)
rse.rcpt = make([]string, 0) rse.rcpt = make([]string, 0)
for _, r := range rl { for _, r := range rl {
if err := c.rcpt(r); err != nil { if err := c.rcpt(r); err != nil {
rerr = errors.Join(rerr, fmt.Errorf("sending RCPT TO command failed: %w", err)) rerr = errors.Join(rerr, fmt.Errorf("sending RCPT TO command failed: %w", err))
rse.Err = ErrSMTPRcptTo rse.Reason = ErrSMTPRcptTo
rse.details = append(rse.details, err) rse.errlist = append(rse.errlist, err)
rse.rcpt = append(rse.rcpt, r) rse.rcpt = append(rse.rcpt, r)
rse.isTemp = isTempError(err)
failed = true failed = true
} }
} }
@ -71,30 +72,30 @@ func (c *Client) Send(ml ...*Msg) (rerr error) {
w, err := c.sc.Data() w, err := c.sc.Data()
if err != nil { if err != nil {
rerr = errors.Join(rerr, fmt.Errorf("sending DATA command failed: %w", err)) rerr = errors.Join(rerr, fmt.Errorf("sending DATA command failed: %w", err))
m.sendError = SendError{Err: ErrSMTPData, details: []error{err}} m.sendError = &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
_, err = m.WriteTo(w) _, err = m.WriteTo(w)
if err != nil { if err != nil {
rerr = errors.Join(rerr, fmt.Errorf("sending mail content failed: %w", err)) rerr = errors.Join(rerr, fmt.Errorf("sending mail content failed: %w", err))
m.sendError = SendError{Err: ErrWriteContent, details: []error{err}} m.sendError = &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
if err := w.Close(); err != nil { if err := w.Close(); err != nil {
rerr = errors.Join(rerr, fmt.Errorf("failed to close DATA writer: %w", err)) rerr = errors.Join(rerr, fmt.Errorf("failed to close DATA writer: %w", err))
m.sendError = SendError{Err: ErrSMTPDataClose, details: []error{err}} m.sendError = &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
if err := c.Reset(); err != nil { if err := c.Reset(); err != nil {
rerr = errors.Join(rerr, fmt.Errorf("sending RSET command failed: %w", err)) rerr = errors.Join(rerr, fmt.Errorf("sending RSET command failed: %w", err))
m.sendError = SendError{Err: ErrSMTPReset, details: []error{err}} m.sendError = &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err)}
continue continue
} }
if err := c.checkConn(); err != nil { if err := c.checkConn(); err != nil {
rerr = errors.Join(rerr, fmt.Errorf("failed to check server connection: %w", err)) rerr = errors.Join(rerr, fmt.Errorf("failed to check server connection: %w", err))
m.sendError = SendError{Err: ErrConnCheck, details: []error{err}} m.sendError = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
} }
} }

View file

@ -1030,9 +1030,12 @@ func TestClient_Send_MsgSendError(t *testing.T) {
if !m.HasSendError() { if !m.HasSendError() {
t.Errorf("message was expected to have a send error, but didn't") t.Errorf("message was expected to have a send error, but didn't")
} }
se := SendError{Err: ErrSMTPRcptTo} se := &SendError{Reason: ErrSMTPRcptTo}
if !errors.As(m.SendError(), &se) { if !errors.Is(m.SendError(), se) {
t.Errorf("message with broken recipient was expected to return a ErrSMTPRcptTo error, but didn't") t.Errorf("error mismatch, expected: %s, got: %s", se, m.SendError())
}
if m.SendErrorIsTemp() {
t.Errorf("message was not expected to be a temporary error, but reported as such")
} }
} }
} }

10
msg.go
View file

@ -966,6 +966,16 @@ func (m *Msg) HasSendError() bool {
return m.sendError != nil return m.sendError != nil
} }
// SendErrorIsTemp returns true if the Msg experienced an error during the message delivery and the
// corresponding error was of temporary nature and should be retried later
func (m *Msg) SendErrorIsTemp() bool {
e, ok := m.sendError.(*SendError)
if !ok {
return false
}
return e.isTemp
}
// SendError returns the senderror field of the Msg // SendError returns the senderror field of the Msg
func (m *Msg) SendError() error { func (m *Msg) SendError() error {
return m.sendError return m.sendError

View file

@ -5,68 +5,138 @@
package mail package mail
import ( import (
"errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
) )
// List of SendError errors // List of SendError reasons
var ( const (
// ErrGetSender is returned if the Msg.GetSender method fails during a Client.Send // ErrGetSender is returned if the Msg.GetSender method fails during a Client.Send
ErrGetSender = errors.New("getting sender address") ErrGetSender SendErrReason = iota
// ErrGetRcpts is returned if the Msg.GetRecipients method fails during a Client.Send // ErrGetRcpts is returned if the Msg.GetRecipients method fails during a Client.Send
ErrGetRcpts = errors.New("getting recipient addresses") ErrGetRcpts
// ErrSMTPMailFrom is returned if the Msg delivery failed when sending the MAIL FROM command // ErrSMTPMailFrom is returned if the Msg delivery failed when sending the MAIL FROM command
// to the sending SMTP server // to the sending SMTP server
ErrSMTPMailFrom = errors.New("sending SMTP MAIL FROM command") ErrSMTPMailFrom
// ErrSMTPRcptTo is returned if the Msg delivery failed when sending the RCPT TO command // ErrSMTPRcptTo is returned if the Msg delivery failed when sending the RCPT TO command
// to the sending SMTP server // to the sending SMTP server
ErrSMTPRcptTo = errors.New("sending SMTP RCPT TO command") ErrSMTPRcptTo
// ErrSMTPData is returned if the Msg delivery failed when sending the DATA command // ErrSMTPData is returned if the Msg delivery failed when sending the DATA command
// to the sending SMTP server // to the sending SMTP server
ErrSMTPData = errors.New("sending SMTP DATA command") ErrSMTPData
// ErrSMTPDataClose is returned if the Msg delivery failed when trying to close the // ErrSMTPDataClose is returned if the Msg delivery failed when trying to close the
// Client data writer // Client data writer
ErrSMTPDataClose = errors.New("closing SMTP DATA writer") ErrSMTPDataClose
// ErrSMTPReset is returned if the Msg delivery failed when sending the RSET command // ErrSMTPReset is returned if the Msg delivery failed when sending the RSET command
// to the sending SMTP server // to the sending SMTP server
ErrSMTPReset = errors.New("sending SMTP RESET command") ErrSMTPReset
// ErrWriteContent is returned if the Msg delivery failed when sending Msg content // ErrWriteContent is returned if the Msg delivery failed when sending Msg content
// to the Client writer // to the Client writer
ErrWriteContent = errors.New("sending message content") ErrWriteContent
// ErrConnCheck is returned if the Msg delivery failed when checking if the SMTP // ErrConnCheck is returned if the Msg delivery failed when checking if the SMTP
// server connection is still working // server connection is still working
ErrConnCheck = errors.New("checking SMTP connection") ErrConnCheck
// ErrNoUnencoded is returned if the Msg delivery failed when the Msg is configured for
// unencoded delivery but the server does not support this
ErrNoUnencoded
) )
// SendError is an error wrapper for delivery errors of the Msg // SendError is an error wrapper for delivery errors of the Msg
type SendError struct { type SendError struct {
Err error Reason SendErrReason
details []error isTemp bool
errlist []error
rcpt []string rcpt []string
} }
// SendErrReason represents a comparable reason on why the delivery failed
type SendErrReason int
// Error implements the error interface for the SendError type // Error implements the error interface for the SendError type
func (e SendError) Error() string { func (e *SendError) Error() string {
if e.Reason > 9 {
return "client_send: unknown error"
}
var em strings.Builder var em strings.Builder
_, _ = fmt.Fprintf(&em, "client_send: %s", e.Err) _, _ = fmt.Fprintf(&em, "client_send: %s", e.Reason)
if len(e.details) > 0 { if len(e.errlist) > 0 {
for i := range e.details { em.WriteRune(':')
em.WriteString(fmt.Sprintf(", error_details: %s", e.details[i])) for i := range e.errlist {
em.WriteRune(' ')
em.WriteString(e.errlist[i].Error())
if i != len(e.errlist)-1 {
em.WriteString(", ")
}
} }
} }
if len(e.rcpt) > 0 { if len(e.rcpt) > 0 {
em.WriteString(", affected recipient(s): ")
for i := range e.rcpt { for i := range e.rcpt {
em.WriteString(fmt.Sprintf(", rcpt: %s", e.rcpt[i])) em.WriteString(e.rcpt[i])
if i != len(e.rcpt)-1 {
em.WriteString(", ")
}
} }
} }
return em.String() return em.String()
} }
// Is implements the errors.Is functionality and compares the SendErrReason
func (e *SendError) Is(et error) bool {
t, ok := et.(*SendError)
if !ok {
return false
}
return e.Reason == t.Reason && e.isTemp == t.isTemp
}
// String implements the Stringer interface for the SendErrReason
func (r SendErrReason) String() string {
switch r {
case ErrGetSender:
return "getting sender address"
case ErrGetRcpts:
return "getting recipient addresses"
case ErrSMTPMailFrom:
return "sending SMTP MAIL FROM command"
case ErrSMTPRcptTo:
return "sending SMTP RCPT TO command"
case ErrSMTPData:
return "sending SMTP DATA command"
case ErrSMTPDataClose:
return "closing SMTP DATA writer"
case ErrSMTPReset:
return "sending SMTP RESET command"
case ErrWriteContent:
return "sending message content"
case ErrConnCheck:
return "checking SMTP connection"
case ErrNoUnencoded:
return ErrServerNoUnencoded.Error()
}
return "unknown reason"
}
// isTempError checks the given SMTP error and returns true if the given error is of temporary nature
// and should be retried
func isTempError(e error) bool {
ec, err := strconv.Atoi(e.Error()[:3])
if err != nil {
return false
}
if ec >= 400 && ec <= 500 {
return true
}
return false
}

55
senderror_test.go Normal file
View file

@ -0,0 +1,55 @@
package mail
import (
"errors"
"testing"
)
// TestSendError_Error tests the SendError and SendErrReason error handling methods
func TestSendError_Error(t *testing.T) {
tl := []struct {
n string
r SendErrReason
te bool
}{
{"ErrGetSender/temp", ErrGetSender, true},
{"ErrGetSender/perm", ErrGetSender, false},
{"ErrGetRcpts/temp", ErrGetRcpts, true},
{"ErrGetRcpts/perm", ErrGetRcpts, false},
{"ErrSMTPMailFrom/temp", ErrSMTPMailFrom, true},
{"ErrSMTPMailFrom/perm", ErrSMTPMailFrom, false},
{"ErrSMTPRcptTo/temp", ErrSMTPRcptTo, true},
{"ErrSMTPRcptTo/perm", ErrSMTPRcptTo, false},
{"ErrSMTPData/temp", ErrSMTPData, true},
{"ErrSMTPData/perm", ErrSMTPData, false},
{"ErrSMTPDataClose/temp", ErrSMTPDataClose, true},
{"ErrSMTPDataClose/perm", ErrSMTPDataClose, false},
{"ErrSMTPReset/temp", ErrSMTPReset, true},
{"ErrSMTPReset/perm", ErrSMTPReset, false},
{"ErrWriteContent/temp", ErrWriteContent, true},
{"ErrWriteContent/perm", ErrWriteContent, false},
{"ErrConnCheck/temp", ErrConnCheck, true},
{"ErrConnCheck/perm", ErrConnCheck, false},
{"ErrNoUnencoded/temp", ErrNoUnencoded, true},
{"ErrNoUnencoded/perm", ErrNoUnencoded, false},
{"Unknown/temp", 9999, true},
{"Unknown/perm", 9999, false},
}
for _, tt := range tl {
t.Run(tt.n, func(t *testing.T) {
if err := returnSendError(tt.r, tt.te); err != nil {
exp := &SendError{Reason: tt.r, isTemp: tt.te}
if !errors.Is(err, exp) {
t.Errorf("error mismatch, expected: %s (temp: %t), got: %s (temp: %t)", tt.r, tt.te,
exp.Error(), exp.isTemp)
}
}
})
}
}
// returnSendError is a helper method to retunr a SendError with a specific reason
func returnSendError(r SendErrReason, t bool) error {
return &SendError{Reason: r, isTemp: t}
}