Skip to content

Commit

Permalink
Merge pull request #91 from wneessen/feature/90_provide-a-way-of-know…
Browse files Browse the repository at this point in the history
…ing-which-emails-failedsucceeded-in-sending

Introduction of new error type for sending errors
  • Loading branch information
wneessen authored Jan 3, 2023
2 parents f454ae8 + 3d0939b commit 3b3c1e6
Show file tree
Hide file tree
Showing 7 changed files with 430 additions and 38 deletions.
79 changes: 54 additions & 25 deletions client_119.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
41 changes: 29 additions & 12 deletions client_120.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,75 +9,92 @@ 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
}
}
if failed {
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)
}
}

Expand Down
88 changes: 88 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package mail
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/smtp"
"os"
Expand Down Expand Up @@ -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{"[email protected]", "[email protected]"}
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("[email protected]")
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) {
Expand Down
2 changes: 1 addition & 1 deletion doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
24 changes: 24 additions & 0 deletions msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 3b3c1e6

Please sign in to comment.