diff --git a/client_119.go b/client_119.go index bcb8ec4..0f06c6c 100644 --- a/client_119.go +++ b/client_119.go @@ -22,41 +22,42 @@ func (c *Client) Send(ml ...*Msg) error { if m.encoding == NoEncoding { if ok, _ := c.sc.Extension("8BITMIME"); !ok { errs = append(errs, ErrServerNoUnencoded) - m.sendError = SendError{Err: ErrServerNoUnencoded} + m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false} continue } } f, err := m.GetSender(false) if err != nil { errs = append(errs, err) - m.sendError = SendError{Err: ErrGetSender, details: []error{err}} + m.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)} continue } rl, err := m.GetRecipients() 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) continue } if err := c.mail(f); err != nil { 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 { errs = append(errs, reserr) } continue } failed := false - rse := SendError{} - rse.details = make([]error, 0) + rse := &SendError{} + rse.errlist = make([]error, 0) rse.rcpt = make([]string, 0) for _, r := range rl { if err := c.rcpt(r); err != nil { errs = append(errs, fmt.Errorf("sending RCPT TO command failed: %w", err)) - rse.Err = ErrSMTPRcptTo - rse.details = append(rse.details, err) + rse.Reason = ErrSMTPRcptTo + rse.errlist = append(rse.errlist, err) rse.rcpt = append(rse.rcpt, r) + rse.isTemp = isTempError(err) failed = true } } @@ -70,30 +71,30 @@ func (c *Client) Send(ml ...*Msg) error { w, err := c.sc.Data() if err != nil { 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 } _, err = m.WriteTo(w) if err != nil { 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 } if err := w.Close(); err != nil { 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 } if err := c.Reset(); err != nil { 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 } if err := c.checkConn(); err != nil { 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 } } diff --git a/client_120.go b/client_120.go index 42b744d..d2efc72 100644 --- a/client_120.go +++ b/client_120.go @@ -23,41 +23,42 @@ func (c *Client) Send(ml ...*Msg) (rerr error) { if m.encoding == NoEncoding { if ok, _ := c.sc.Extension("8BITMIME"); !ok { rerr = errors.Join(rerr, ErrServerNoUnencoded) - m.sendError = SendError{Err: ErrServerNoUnencoded} + m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false} continue } } f, err := m.GetSender(false) if err != nil { 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 } rl, err := m.GetRecipients() if err != nil { 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 } if err := c.mail(f); err != nil { 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 { rerr = errors.Join(rerr, reserr) } continue } failed := false - rse := SendError{} - rse.details = make([]error, 0) + rse := &SendError{} + rse.errlist = make([]error, 0) rse.rcpt = make([]string, 0) for _, r := range rl { if err := c.rcpt(r); err != nil { rerr = errors.Join(rerr, fmt.Errorf("sending RCPT TO command failed: %w", err)) - rse.Err = ErrSMTPRcptTo - rse.details = append(rse.details, err) + rse.Reason = ErrSMTPRcptTo + rse.errlist = append(rse.errlist, err) rse.rcpt = append(rse.rcpt, r) + rse.isTemp = isTempError(err) failed = true } } @@ -71,30 +72,30 @@ func (c *Client) Send(ml ...*Msg) (rerr error) { w, err := c.sc.Data() if err != nil { 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 } _, err = m.WriteTo(w) if err != nil { 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 } if err := w.Close(); err != nil { 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 } if err := c.Reset(); err != nil { 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 } if err := c.checkConn(); err != nil { 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)} } } diff --git a/client_test.go b/client_test.go index edf850b..ae3bcce 100644 --- a/client_test.go +++ b/client_test.go @@ -1030,9 +1030,12 @@ func TestClient_Send_MsgSendError(t *testing.T) { if !m.HasSendError() { t.Errorf("message was expected to have a send error, but didn't") } - se := SendError{Err: ErrSMTPRcptTo} - if !errors.As(m.SendError(), &se) { - t.Errorf("message with broken recipient was expected to return a ErrSMTPRcptTo error, but didn't") + se := &SendError{Reason: ErrSMTPRcptTo} + if !errors.Is(m.SendError(), se) { + 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") } } } diff --git a/msg.go b/msg.go index 82e4739..16b9c9b 100644 --- a/msg.go +++ b/msg.go @@ -966,6 +966,16 @@ func (m *Msg) HasSendError() bool { 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 func (m *Msg) SendError() error { return m.sendError diff --git a/senderror.go b/senderror.go index f093852..b4fb404 100644 --- a/senderror.go +++ b/senderror.go @@ -5,68 +5,138 @@ package mail import ( - "errors" "fmt" + "strconv" "strings" ) -// List of SendError errors -var ( +// List of SendError reasons +const ( // 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 = errors.New("getting recipient addresses") + ErrGetRcpts // ErrSMTPMailFrom is returned if the Msg delivery failed when sending the MAIL FROM command // 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 // 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 // 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 // Client data writer - ErrSMTPDataClose = errors.New("closing SMTP DATA writer") + ErrSMTPDataClose // ErrSMTPReset is returned if the Msg delivery failed when sending the RSET command // 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 // to the Client writer - ErrWriteContent = errors.New("sending message content") + ErrWriteContent // ErrConnCheck is returned if the Msg delivery failed when checking if the SMTP // 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 type SendError struct { - Err error - details []error + Reason SendErrReason + isTemp bool + errlist []error rcpt []string } +// SendErrReason represents a comparable reason on why the delivery failed +type SendErrReason int + // 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 - _, _ = fmt.Fprintf(&em, "client_send: %s", e.Err) - if len(e.details) > 0 { - for i := range e.details { - em.WriteString(fmt.Sprintf(", error_details: %s", e.details[i])) + _, _ = fmt.Fprintf(&em, "client_send: %s", e.Reason) + if len(e.errlist) > 0 { + em.WriteRune(':') + 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 { + em.WriteString(", affected recipient(s): ") 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() } + +// 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 +} diff --git a/senderror_test.go b/senderror_test.go new file mode 100644 index 0000000..0a63f07 --- /dev/null +++ b/senderror_test.go @@ -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} +}