diff --git a/client_119.go b/client_119.go index f5b942c..ed27216 100644 --- a/client_119.go +++ b/client_119.go @@ -21,31 +21,41 @@ 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} continue } } f, err := m.GetSender(false) if err != nil { errs = append(errs, err) + m.sendError = SendError{Err: ErrGetSender, details: []error{err}} continue } rl, err := m.GetRecipients() if err != nil { + m.sendError = SendError{Err: ErrGetRcpts, details: []error{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}} if reserr := c.sc.Reset(); reserr != nil { errs = append(errs, reserr) } continue } failed := false + rse := SendError{} + rse.details = 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.rcpt = append(rse.rcpt, r) failed = true } } @@ -53,30 +63,36 @@ func (c *Client) Send(ml ...*Msg) error { if reserr := c.sc.Reset(); reserr != nil { errs = append(errs, reserr) } + m.sendError = rse continue } 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}} 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}} 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}} 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}} 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}} continue } } diff --git a/client_120.go b/client_120.go index 8990737..568c21d 100644 --- a/client_120.go +++ b/client_120.go @@ -22,31 +22,41 @@ 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} continue } } f, err := m.GetSender(false) if err != nil { rerr = errors.Join(rerr, err) + m.sendError = SendError{Err: ErrGetSender, details: []error{err}} continue } rl, err := m.GetRecipients() if err != nil { rerr = errors.Join(rerr, err) + m.sendError = SendError{Err: ErrGetRcpts, details: []error{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}} if reserr := c.sc.Reset(); reserr != nil { rerr = errors.Join(rerr, reserr) } continue } failed := false + rse := SendError{} + rse.details = 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.rcpt = append(rse.rcpt, r) failed = true } } @@ -54,30 +64,36 @@ func (c *Client) Send(ml ...*Msg) (rerr error) { if reserr := c.sc.Reset(); reserr != nil { rerr = errors.Join(rerr, reserr) } + m.sendError = rse continue } 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}} 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}} 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}} 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}} 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}} } } diff --git a/client_test.go b/client_test.go index f674d4f..edf850b 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,53 @@ func TestValidateLine(t *testing.T) { } } +// TestClient_Send_MsgSendError tests the Send() method of Client with a broken 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{Err: ErrSMTPRcptTo} + if !errors.As(m.SendError(), &se) { + t.Errorf("message with broken recipient was expected to return a ErrSMTPRcptTo error, but didn't") + } + } +} + // 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/msg.go b/msg.go index 608e080..82e4739 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,17 @@ 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 +} + +// 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..4ed78bb --- /dev/null +++ b/senderror.go @@ -0,0 +1,68 @@ +package mail + +import ( + "errors" + "fmt" + "strings" +) + +// List of SendError errors +var ( + // ErrGetSender is returned if the Msg.GetSender method fails during a Client.Send + ErrGetSender = errors.New("getting sender address") + + // ErrGetRcpts is returned if the Msg.GetRecipients method fails during a Client.Send + ErrGetRcpts = errors.New("getting recipient addresses") + + // 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") + + // 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") + + // 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") + + // ErrSMTPDataClose is returned if the Msg delivery failed when trying to close the + // Client data writer + ErrSMTPDataClose = errors.New("closing SMTP DATA writer") + + // 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") + + // ErrWriteContent is returned if the Msg delivery failed when sending Msg content + // to the Client writer + ErrWriteContent = errors.New("sending message content") + + // ErrConnCheck is returned if the Msg delivery failed when checking if the SMTP + // server connection is still working + ErrConnCheck = errors.New("checking SMTP connection") +) + +// SendError is an error wrapper for delivery errors of the Msg +type SendError struct { + Err error + details []error + rcpt []string +} + +// Error implements the error interface for the SendError type +func (e SendError) Error() string { + 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])) + } + } + if len(e.rcpt) > 0 { + for i := range e.rcpt { + em.WriteString(fmt.Sprintf(", rcpt: %s", e.rcpt[i])) + } + } + return em.String() +}