diff --git a/client_119.go b/client_119.go index f5b942c..7fb25df 100644 --- a/client_119.go +++ b/client_119.go @@ -7,89 +7,118 @@ package mail -import ( - "fmt" -) - // Send sends out the mail message func (c *Client) Send(ml ...*Msg) error { - var errs []error - if err := c.checkConn(); err != nil { - return fmt.Errorf("failed to send mail: %w", err) + if cerr := c.checkConn(); cerr != nil { + return &SendError{Reason: ErrConnCheck, errlist: []error{cerr}, isTemp: isTempError(cerr)} } + var errs []*SendError for _, m := range ml { + m.sendError = nil if m.encoding == NoEncoding { if ok, _ := c.sc.Extension("8BITMIME"); !ok { - errs = append(errs, ErrServerNoUnencoded) + se := &SendError{Reason: ErrNoUnencoded, isTemp: false} + m.sendError = se + errs = append(errs, se) continue } } f, err := m.GetSender(false) if err != nil { - errs = append(errs, err) + se := &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)} + m.sendError = se + errs = append(errs, se) continue } rl, err := m.GetRecipients() if err != nil { - errs = append(errs, err) + se := &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)} + m.sendError = se + errs = append(errs, se) continue } 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)} 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 } failed := false + 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.Reason = ErrSMTPRcptTo + rse.errlist = append(rse.errlist, err) + rse.rcpt = append(rse.rcpt, r) + rse.isTemp = isTempError(err) failed = true } } if failed { if reserr := c.sc.Reset(); reserr != nil { - errs = append(errs, reserr) + rse.errlist = append(rse.errlist, err) } + m.sendError = rse + errs = append(errs, rse) continue } w, err := c.sc.Data() 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 = se + errs = append(errs, se) continue } _, err = m.WriteTo(w) 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 = se + errs = append(errs, se) continue } 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 = se + errs = append(errs, se) continue } 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 = se + errs = append(errs, se) continue } 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 = se + errs = append(errs, se) continue } } if len(errs) > 0 { - errtxt := "" - for i := range errs { - errtxt += fmt.Sprintf("%s", errs[i]) - if i < len(errs) { - errtxt += "\n" + if len(errs) > 1 { + re := &SendError{Reason: ErrAmbiguous} + for i := range errs { + re.errlist = append(re.errlist, errs[i].errlist...) + re.rcpt = append(re.rcpt, errs[i].rcpt...) } + + // We assume that the isTemp flag 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 } diff --git a/client_120.go b/client_120.go index 8990737..33be7e3 100644 --- a/client_120.go +++ b/client_120.go @@ -9,44 +9,54 @@ package mail import ( "errors" - "fmt" ) // Send sends out the mail message func (c *Client) Send(ml ...*Msg) (rerr error) { 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 } for _, m := range ml { + m.sendError = nil if m.encoding == NoEncoding { if ok, _ := c.sc.Extension("8BITMIME"); !ok { - rerr = errors.Join(rerr, ErrServerNoUnencoded) + m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false} + rerr = errors.Join(rerr, m.sendError) continue } } f, err := m.GetSender(false) if err != nil { - rerr = errors.Join(rerr, err) + m.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)} + rerr = errors.Join(rerr, m.sendError) continue } rl, err := m.GetRecipients() if err != nil { - rerr = errors.Join(rerr, err) + m.sendError = &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)} + rerr = errors.Join(rerr, m.sendError) continue } 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)} + rerr = errors.Join(rerr, m.sendError) if reserr := c.sc.Reset(); reserr != nil { rerr = errors.Join(rerr, reserr) } continue } failed := false + 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.Reason = ErrSMTPRcptTo + rse.errlist = append(rse.errlist, err) + rse.rcpt = append(rse.rcpt, r) + rse.isTemp = isTempError(err) failed = true } } @@ -54,30 +64,37 @@ func (c *Client) Send(ml ...*Msg) (rerr error) { if reserr := c.sc.Reset(); reserr != nil { rerr = errors.Join(rerr, reserr) } + m.sendError = rse + rerr = errors.Join(rerr, m.sendError) continue } w, err := c.sc.Data() 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)} + rerr = errors.Join(rerr, m.sendError) continue } _, err = m.WriteTo(w) 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)} + rerr = errors.Join(rerr, m.sendError) continue } 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)} + rerr = errors.Join(rerr, m.sendError) continue } 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)} + rerr = errors.Join(rerr, m.sendError) continue } 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)} + rerr = errors.Join(rerr, m.sendError) } } diff --git a/client_test.go b/client_test.go index f674d4f..d7ef11c 100644 --- a/client_test.go +++ b/client_test.go @@ -7,6 +7,7 @@ package mail import ( "context" "crypto/tls" + "errors" "fmt" "net/smtp" "os" @@ -989,6 +990,93 @@ func TestValidateLine(t *testing.T) { } } +// TestClient_Send_MsgSendError tests the Client.Send method with a broken recipient and verifies +// that the SendError type works properly +func TestClient_Send_MsgSendError(t *testing.T) { + if os.Getenv("TEST_ALLOW_SEND") == "" { + t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") + } + var msgs []*Msg + rcpts := []string{"invalid@domain.tld", "invalid@address.invalid"} + for _, rcpt := range rcpts { + m := NewMsg() + _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) + _ = m.To(rcpt) + 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") + msgs = append(msgs, m) + } + + 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() + if err := c.DialWithContext(ctx); err != nil { + t.Errorf("failed to dial to sending server: %s", err) + } + if err := c.Send(msgs...); err == nil { + t.Errorf("sending messages with broken recipients was supposed to fail but didn't") + } + if err := c.Close(); err != nil { + t.Errorf("failed to close client connection: %s", err) + } + for _, m := range msgs { + if !m.HasSendError() { + t.Errorf("message was expected to have a send 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") + } + } +} + +// 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 // SMTP server to test all functionality that requires a connection func getTestConnection(auth bool) (*Client, error) { diff --git a/doc.go b/doc.go index a9b96de..f2bef6c 100644 --- a/doc.go +++ b/doc.go @@ -6,4 +6,4 @@ package mail // VERSION is used in the default user agent string -const VERSION = "0.3.6" +const VERSION = "0.3.7" diff --git a/msg.go b/msg.go index 608e080..6fe7f5a 100644 --- a/msg.go +++ b/msg.go @@ -90,6 +90,9 @@ type Msg struct { // middlewares is the list of middlewares to apply to the Msg before sending in FIFO order middlewares []Middleware + + // sendError holds the SendError in case a Msg could not be delivered during the Client.Send operation + sendError error } // SendmailPath is the default system path to the sendmail binary @@ -957,6 +960,27 @@ func (m *Msg) UpdateReader(r *Reader) { r.err = err } +// HasSendError returns true if the Msg experienced an error during the message delivery and the +// sendError field of the Msg is not nil +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 { + var e *SendError + if errors.As(m.sendError, &e) { + return e.isTemp + } + return false +} + +// SendError returns the sendError field of the Msg +func (m *Msg) SendError() error { + return m.sendError +} + // encodeString encodes a string based on the configured message encoder and the corresponding // charset for the Msg func (m *Msg) encodeString(s string) string { diff --git a/senderror.go b/senderror.go new file mode 100644 index 0000000..da9402a --- /dev/null +++ b/senderror.go @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2022 Winni Neessen +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "errors" + "strings" +) + +// List of SendError reasons +const ( + // ErrGetSender is returned if the Msg.GetSender method fails during a Client.Send + ErrGetSender SendErrReason = iota + + // ErrGetRcpts is returned if the Msg.GetRecipients method fails during a Client.Send + ErrGetRcpts + + // ErrSMTPMailFrom is returned if the Msg delivery failed when sending the MAIL FROM command + // to the sending SMTP server + ErrSMTPMailFrom + + // ErrSMTPRcptTo is returned if the Msg delivery failed when sending the RCPT TO command + // to the sending SMTP server + ErrSMTPRcptTo + + // ErrSMTPData is returned if the Msg delivery failed when sending the DATA command + // to the sending SMTP server + ErrSMTPData + + // ErrSMTPDataClose is returned if the Msg delivery failed when trying to close the + // Client data writer + ErrSMTPDataClose + + // ErrSMTPReset is returned if the Msg delivery failed when sending the RSET command + // to the sending SMTP server + ErrSMTPReset + + // ErrWriteContent is returned if the Msg delivery failed when sending Msg content + // to the Client writer + ErrWriteContent + + // ErrConnCheck is returned if the Msg delivery failed when checking if the SMTP + // server connection is still working + 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 + + // 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 +type SendError struct { + 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 { + if e.Reason > 10 { + return "unknown reason" + } + + var em strings.Builder + em.WriteString(e.Reason.String()) + 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(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 { + var t *SendError + if errors.As(et, &t) { + return e.Reason == t.Reason && e.isTemp == t.isTemp + } + 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 +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() + case ErrAmbiguous: + return "ambiguous reason, check Msg.SendError for message specific reasons" + } + 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 { + return e.Error()[0] == '4' +} diff --git a/senderror_test.go b/senderror_test.go new file mode 100644 index 0000000..b72a14e --- /dev/null +++ b/senderror_test.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2022 Winni Neessen +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "errors" + "fmt" + "strings" + "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}, + {"ErrAmbiguous/temp", ErrAmbiguous, true}, + {"ErrAmbiguous/perm", ErrAmbiguous, 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) + } + if !strings.Contains(fmt.Sprintf("%s", err), tt.r.String()) { + t.Errorf("error string mismatch, expected: %s, got: %s", + tt.r.String(), fmt.Sprintf("%s", err)) + } + } + }) + } +} + +func TestSendError_IsTemp(t *testing.T) { + var se *SendError + err1 := returnSendError(ErrAmbiguous, true) + if !errors.As(err1, &se) { + t.Errorf("error mismatch, expected error to be of type *SendError") + return + } + if errors.As(err1, &se) && !se.IsTemp() { + t.Errorf("error mismatch, expected temporary error") + return + } + err2 := returnSendError(ErrAmbiguous, false) + if !errors.As(err2, &se) { + t.Errorf("error mismatch, expected error to be of type *SendError") + return + } + if errors.As(err2, &se) && se.IsTemp() { + t.Errorf("error mismatch, expected non-temporary error") + return + } +} + +// 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} +}