diff --git a/client.go b/client.go index fbb28f5..d64c172 100644 --- a/client.go +++ b/client.go @@ -1190,6 +1190,7 @@ func (c *Client) auth() error { func (c *Client) sendSingleMsg(message *Msg) error { c.mutex.Lock() defer c.mutex.Unlock() + escSupport, _ := c.smtpClient.Extension("ENHANCEDSTATUSCODES") if message.encoding == NoEncoding { if ok, _ := c.smtpClient.Extension("8BITMIME"); !ok { @@ -1200,14 +1201,16 @@ func (c *Client) sendSingleMsg(message *Msg) error { if err != nil { return &SendError{ Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err), - affectedMsg: message, + affectedMsg: message, errcode: getErrorCode(err), + enhancedStatusCode: getEnhancedStatusCode(err, escSupport), } } rcpts, err := message.GetRecipients() if err != nil { return &SendError{ Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err), - affectedMsg: message, + affectedMsg: message, errcode: getErrorCode(err), + enhancedStatusCode: getEnhancedStatusCode(err, escSupport), } } @@ -1219,7 +1222,8 @@ func (c *Client) sendSingleMsg(message *Msg) error { if err = c.smtpClient.Mail(from); err != nil { retError := &SendError{ Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err), - affectedMsg: message, + affectedMsg: message, errcode: getErrorCode(err), + enhancedStatusCode: getEnhancedStatusCode(err, escSupport), } if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil { retError.errlist = append(retError.errlist, resetSendErr) @@ -1238,6 +1242,8 @@ func (c *Client) sendSingleMsg(message *Msg) error { rcptSendErr.errlist = append(rcptSendErr.errlist, err) rcptSendErr.rcpt = append(rcptSendErr.rcpt, rcpt) rcptSendErr.isTemp = isTempError(err) + rcptSendErr.errcode = getErrorCode(err) + rcptSendErr.enhancedStatusCode = getEnhancedStatusCode(err, escSupport) hasError = true } } @@ -1251,20 +1257,23 @@ func (c *Client) sendSingleMsg(message *Msg) error { if err != nil { return &SendError{ Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err), - affectedMsg: message, + affectedMsg: message, errcode: getErrorCode(err), + enhancedStatusCode: getEnhancedStatusCode(err, escSupport), } } _, err = message.WriteTo(writer) if err != nil { return &SendError{ Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err), - affectedMsg: message, + affectedMsg: message, errcode: getErrorCode(err), + enhancedStatusCode: getEnhancedStatusCode(err, escSupport), } } if err = writer.Close(); err != nil { return &SendError{ Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err), - affectedMsg: message, + affectedMsg: message, errcode: getErrorCode(err), + enhancedStatusCode: getEnhancedStatusCode(err, escSupport), } } message.isDelivered = true @@ -1272,7 +1281,8 @@ func (c *Client) sendSingleMsg(message *Msg) error { if err = c.Reset(); err != nil { return &SendError{ Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err), - affectedMsg: message, + affectedMsg: message, errcode: getErrorCode(err), + enhancedStatusCode: getEnhancedStatusCode(err, escSupport), } } return nil diff --git a/client_test.go b/client_test.go index 39bd7e0..2687611 100644 --- a/client_test.go +++ b/client_test.go @@ -3148,6 +3148,59 @@ func TestClient_sendSingleMsg(t *testing.T) { t.Errorf("expected ErrSMTPDataClose, got %s", sendErr.Reason) } }) + t.Run("error code and enhanced status code support", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-ENHANCEDSTATUSCODES\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnMailFrom: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Error("expected mail delivery to fail") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Fatalf("expected SendError, got %s", err) + } + if sendErr.errcode != 500 { + t.Errorf("expected error code 500, got %d", sendErr.errcode) + } + if !strings.EqualFold(sendErr.enhancedStatusCode, "5.5.2") { + t.Errorf("expected enhanced status code 5.5.2, got %s", sendErr.enhancedStatusCode) + } + }) } func TestClient_checkConn(t *testing.T) { diff --git a/senderror.go b/senderror.go index 4471208..a371c6e 100644 --- a/senderror.go +++ b/senderror.go @@ -6,6 +6,8 @@ package mail import ( "errors" + "regexp" + "strconv" "strings" ) @@ -60,11 +62,13 @@ const ( // details about the affected message, a list of errors, the recipient list, and whether // the error is temporary or permanent. It also includes a reason code for the error. type SendError struct { - affectedMsg *Msg - errlist []error - isTemp bool - rcpt []string - Reason SendErrReason + affectedMsg *Msg + errcode int + enhancedStatusCode string + errlist []error + isTemp bool + rcpt []string + Reason SendErrReason } // SendErrReason represents a comparable reason on why the delivery failed @@ -175,6 +179,27 @@ func (e *SendError) Msg() *Msg { return e.affectedMsg } +// EnhancedStatusCode returns the enhanced status code of the server response if the +// server supports it, as described in RFC 2034. +// +// This function retrieves the enhanced status code of an error returned by the server. This +// requires that the receiving server supports this SMTP extension as described in RFC 2034. +// Since this is the SendError interface, we only collect status codes for error responses, +// meaning 4xx or 5xx. If the server does not support the ENHANCEDSTATUSCODES extension or +// the error did not include an enhanced status code, it will return an empty string. +// +// Returns: +// - The enhanced status code as returned by the server, or an empty string is not supported. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2034 +func (e *SendError) EnhancedStatusCode() string { + if e == nil { + return "" + } + return e.enhancedStatusCode +} + // String satisfies the fmt.Stringer interface for the SendErrReason type. // // This function converts the SendErrReason into a human-readable string representation based @@ -224,3 +249,39 @@ func (r SendErrReason) String() string { func isTempError(err error) bool { return err.Error()[0] == '4' } + +func getErrorCode(err error) int { + rootErr := errors.Unwrap(err) + if rootErr != nil { + err = rootErr + } + firstrune := err.Error()[0] + if firstrune < 52 || firstrune > 53 { + return 0 + } + code := err.Error()[0:3] + errcode, cerr := strconv.Atoi(code) + if cerr != nil { + return 0 + } + return errcode +} + +func getEnhancedStatusCode(err error, supported bool) string { + if err == nil || !supported { + return "" + } + rootErr := errors.Unwrap(err) + if rootErr != nil { + err = rootErr + } + firstrune := err.Error()[0] + if firstrune != 50 && firstrune != 52 && firstrune != 53 { + return "" + } + re, rerr := regexp.Compile(`\b([245])\.\d{1,3}\.\d{1,3}\b`) + if rerr != nil { + return "" + } + return re.FindString(err.Error()) +}