More changes in regard to #90

As proposed by @iwittkau the `SendError` type now has a `IsTemp()` method as well indicating to the user if the delivery error is retryable or not.

Since we want to use it in the error response from the Client functions like `Send` or `DialAndSend` we need to return the SendError type not only as part of the `*Msg` but also as return value for these methods. Hence, the changes made for #85 been overhauled to return the new error type instead. In the pre Go1.20 version of the `Send()` method we need to return an accumulated version of the SendError type, since we don't have `errors.Join()` and therefore, if more than one error occurred during the delivery we return an ambiguous error reason since we can't tell which of the captured errors is main error. For more details the user can always check the `*Msg.SendError`
This commit is contained in:
Winni Neessen 2023-01-02 12:14:14 +01:00
parent 43efd6b3a8
commit 2c7ea3e532
Signed by: wneessen
GPG key ID: 385AC9889632126E
4 changed files with 110 additions and 49 deletions

View file

@ -7,44 +7,44 @@
package mail package mail
import (
"fmt"
)
// Send sends out the mail message // Send sends out the mail message
func (c *Client) Send(ml ...*Msg) error { func (c *Client) Send(ml ...*Msg) error {
var errs []error if cerr := c.checkConn(); cerr != nil {
if err := c.checkConn(); err != nil { return &SendError{Reason: ErrConnCheck, errlist: []error{cerr}, isTemp: isTempError(cerr)}
return fmt.Errorf("failed to send mail: %w", err)
} }
var errs []*SendError
for _, m := range ml { for _, m := range ml {
m.sendError = nil m.sendError = nil
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) se := &SendError{Reason: ErrNoUnencoded, isTemp: false}
m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false} m.sendError = se
errs = append(errs, se)
continue continue
} }
} }
f, err := m.GetSender(false) f, err := m.GetSender(false)
if err != nil { if err != nil {
errs = append(errs, err) se := &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)}
m.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = se
errs = append(errs, se)
continue continue
} }
rl, err := m.GetRecipients() rl, err := m.GetRecipients()
if err != nil { if err != nil {
m.sendError = &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)} se := &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)}
errs = append(errs, err) m.sendError = se
errs = append(errs, se)
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)) se := &SendError{Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(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) se.errlist = append(se.errlist, reserr)
} }
m.sendError = se
errs = append(errs, se)
continue continue
} }
failed := false failed := false
@ -53,7 +53,6 @@ func (c *Client) Send(ml ...*Msg) error {
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))
rse.Reason = ErrSMTPRcptTo rse.Reason = ErrSMTPRcptTo
rse.errlist = append(rse.errlist, err) rse.errlist = append(rse.errlist, err)
rse.rcpt = append(rse.rcpt, r) rse.rcpt = append(rse.rcpt, r)
@ -63,51 +62,67 @@ func (c *Client) Send(ml ...*Msg) error {
} }
if failed { if failed {
if reserr := c.sc.Reset(); reserr != nil { if reserr := c.sc.Reset(); reserr != nil {
errs = append(errs, reserr) rse.errlist = append(rse.errlist, err)
} }
m.sendError = rse m.sendError = rse
errs = append(errs, rse)
continue continue
} }
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)) se := &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err)}
m.sendError = &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = se
errs = append(errs, se)
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)) se := &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err)}
m.sendError = &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = se
errs = append(errs, se)
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)) se := &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err)}
m.sendError = &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = se
errs = append(errs, se)
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)) se := &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err)}
m.sendError = &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = se
errs = append(errs, se)
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)) se := &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
m.sendError = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = se
errs = append(errs, se)
continue continue
} }
} }
if len(errs) > 0 { if len(errs) > 0 {
errtxt := "" if len(errs) > 1 {
for i := range errs { re := &SendError{Reason: ErrAmbiguous}
errtxt += fmt.Sprintf("%s", errs[i]) for i := range errs {
if i < len(errs) { for _, e := range errs[i].errlist {
errtxt += "\n" re.errlist = append(re.errlist, e)
}
for _, r := range errs[i].rcpt {
re.rcpt = append(re.rcpt, r)
}
} }
// We assume that the isTemp flage from the last error we received should be the
// indicator for the returned isTemp flag as well
re.isTemp = errs[len(errs)-1].isTemp
return re
} }
return fmt.Errorf("%s", errtxt) return errs[0]
} }
return nil return nil
} }

View file

@ -9,40 +9,39 @@ package mail
import ( import (
"errors" "errors"
"fmt"
) )
// Send sends out the mail message // Send sends out the mail message
func (c *Client) Send(ml ...*Msg) (rerr error) { func (c *Client) Send(ml ...*Msg) (rerr error) {
if err := c.checkConn(); err != nil { if err := c.checkConn(); err != nil {
rerr = fmt.Errorf("failed to send mail: %w", err) rerr = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
return return
} }
for _, m := range ml { for _, m := range ml {
m.sendError = nil m.sendError = nil
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)
m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false} m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false}
rerr = errors.Join(rerr, m.sendError)
continue continue
} }
} }
f, err := m.GetSender(false) f, err := m.GetSender(false)
if err != nil { if err != nil {
rerr = errors.Join(rerr, err)
m.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)}
rerr = errors.Join(rerr, m.sendError)
continue continue
} }
rl, err := m.GetRecipients() rl, err := m.GetRecipients()
if err != nil { if err != nil {
rerr = errors.Join(rerr, err)
m.sendError = &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)}
rerr = errors.Join(rerr, m.sendError)
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))
m.sendError = &SendError{Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = &SendError{Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err)}
rerr = errors.Join(rerr, m.sendError)
if reserr := c.sc.Reset(); reserr != nil { if reserr := c.sc.Reset(); reserr != nil {
rerr = errors.Join(rerr, reserr) rerr = errors.Join(rerr, reserr)
} }
@ -54,7 +53,6 @@ func (c *Client) Send(ml ...*Msg) (rerr error) {
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))
rse.Reason = ErrSMTPRcptTo rse.Reason = ErrSMTPRcptTo
rse.errlist = append(rse.errlist, err) rse.errlist = append(rse.errlist, err)
rse.rcpt = append(rse.rcpt, r) rse.rcpt = append(rse.rcpt, r)
@ -67,35 +65,36 @@ func (c *Client) Send(ml ...*Msg) (rerr error) {
rerr = errors.Join(rerr, reserr) rerr = errors.Join(rerr, reserr)
} }
m.sendError = rse m.sendError = rse
rerr = errors.Join(rerr, m.sendError)
continue continue
} }
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))
m.sendError = &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err)}
rerr = errors.Join(rerr, m.sendError)
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))
m.sendError = &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err)}
rerr = errors.Join(rerr, m.sendError)
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))
m.sendError = &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err)}
rerr = errors.Join(rerr, m.sendError)
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))
m.sendError = &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err)}
rerr = errors.Join(rerr, m.sendError)
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))
m.sendError = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)} m.sendError = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
rerr = errors.Join(rerr, m.sendError)
} }
} }

View file

@ -1040,6 +1040,43 @@ func TestClient_Send_MsgSendError(t *testing.T) {
} }
} }
// TestClient_DialAndSendWithContext_withSendError tests the Client.DialAndSendWithContext method
// with a broken recipient to make sure that the returned error satisfies the Msg.SendError type
func TestClient_DialAndSendWithContext_withSendError(t *testing.T) {
if os.Getenv("TEST_ALLOW_SEND") == "" {
t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test")
}
m := NewMsg()
_ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM"))
_ = m.To("invalid@domain.tld")
m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION))
m.SetBulk()
m.SetDate()
m.SetMessageID()
m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library")
c, err := getTestConnection(true)
if err != nil {
t.Skipf("failed to create test client: %s. Skipping tests", err)
}
ctx, cfn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cfn()
err = c.DialAndSendWithContext(ctx, m)
if err == nil {
t.Errorf("expected DialAndSendWithContext with broken mail recipient to fail, but didn't")
return
}
var se *SendError
if !errors.As(err, &se) {
t.Errorf("expected *SendError type as returned error, but didn't")
return
}
if se.IsTemp() {
t.Errorf("expected permanent error but IsTemp() returned true")
return
}
}
// getTestConnection takes environment variables to establish a connection to a real // getTestConnection takes environment variables to establish a connection to a real
// SMTP server to test all functionality that requires a connection // SMTP server to test all functionality that requires a connection
func getTestConnection(auth bool) (*Client, error) { func getTestConnection(auth bool) (*Client, error) {

View file

@ -6,7 +6,6 @@ package mail
import ( import (
"errors" "errors"
"fmt"
"strconv" "strconv"
"strings" "strings"
) )
@ -50,6 +49,10 @@ const (
// ErrNoUnencoded is returned if the Msg delivery failed when the Msg is configured for // ErrNoUnencoded is returned if the Msg delivery failed when the Msg is configured for
// unencoded delivery but the server does not support this // unencoded delivery but the server does not support this
ErrNoUnencoded ErrNoUnencoded
// ErrAmbiguous is a generalized delivery error for the SendError type that is
// returned if the exact reason for the delivery failure is ambiguous
ErrAmbiguous
) )
// SendError is an error wrapper for delivery errors of the Msg // SendError is an error wrapper for delivery errors of the Msg
@ -65,12 +68,12 @@ 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 { if e.Reason > 10 {
return "client_send: unknown error" return "client_send: unknown error"
} }
var em strings.Builder var em strings.Builder
_, _ = fmt.Fprintf(&em, "client_send: %s", e.Reason) em.WriteString(e.Reason.String())
if len(e.errlist) > 0 { if len(e.errlist) > 0 {
em.WriteRune(':') em.WriteRune(':')
for i := range e.errlist { for i := range e.errlist {
@ -102,6 +105,11 @@ func (e *SendError) Is(et error) bool {
return false return false
} }
// IsTemp returns true if the delivery error is of temporary nature and can be retried
func (e *SendError) IsTemp() bool {
return e.isTemp
}
// String implements the Stringer interface for the SendErrReason // String implements the Stringer interface for the SendErrReason
func (r SendErrReason) String() string { func (r SendErrReason) String() string {
switch r { switch r {
@ -125,6 +133,8 @@ func (r SendErrReason) String() string {
return "checking SMTP connection" return "checking SMTP connection"
case ErrNoUnencoded: case ErrNoUnencoded:
return ErrServerNoUnencoded.Error() return ErrServerNoUnencoded.Error()
case ErrAmbiguous:
return "ambiguous reason, check Msg.SendError for message specific reasons"
} }
return "unknown reason" return "unknown reason"
} }