diff --git a/client_119.go b/client_119.go index 0f06c6c..465b142 100644 --- a/client_119.go +++ b/client_119.go @@ -7,44 +7,44 @@ 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) - m.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false} + 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) - m.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(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 { - m.sendError = &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)} - 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)) - m.sendError = &SendError{Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(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 @@ -53,7 +53,6 @@ func (c *Client) Send(ml ...*Msg) error { 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) @@ -63,51 +62,67 @@ func (c *Client) Send(ml ...*Msg) error { } 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)) - m.sendError = &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(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)) - m.sendError = &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(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)) - m.sendError = &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(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)) - m.sendError = &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(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)) - m.sendError = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(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 { + for _, e := range errs[i].errlist { + 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 } diff --git a/client_120.go b/client_120.go index d2efc72..33be7e3 100644 --- a/client_120.go +++ b/client_120.go @@ -9,40 +9,39 @@ 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) } @@ -54,7 +53,6 @@ func (c *Client) Send(ml ...*Msg) (rerr error) { 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) @@ -67,35 +65,36 @@ func (c *Client) Send(ml ...*Msg) (rerr error) { 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 ae3bcce..96fd0c2 100644 --- a/client_test.go +++ b/client_test.go @@ -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 // SMTP server to test all functionality that requires a connection func getTestConnection(auth bool) (*Client, error) { diff --git a/senderror.go b/senderror.go index 264a318..79fdac1 100644 --- a/senderror.go +++ b/senderror.go @@ -6,7 +6,6 @@ package mail import ( "errors" - "fmt" "strconv" "strings" ) @@ -50,6 +49,10 @@ const ( // 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 @@ -65,12 +68,12 @@ type SendErrReason int // Error implements the error interface for the SendError type func (e *SendError) Error() string { - if e.Reason > 9 { + if e.Reason > 10 { return "client_send: unknown error" } var em strings.Builder - _, _ = fmt.Fprintf(&em, "client_send: %s", e.Reason) + em.WriteString(e.Reason.String()) if len(e.errlist) > 0 { em.WriteRune(':') for i := range e.errlist { @@ -102,6 +105,11 @@ func (e *SendError) Is(et error) bool { 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 { @@ -125,6 +133,8 @@ func (r SendErrReason) String() string { return "checking SMTP connection" case ErrNoUnencoded: return ErrServerNoUnencoded.Error() + case ErrAmbiguous: + return "ambiguous reason, check Msg.SendError for message specific reasons" } return "unknown reason" }