Compare commits

...

9 commits

Author SHA1 Message Date
a5ac7c3370
Update error handling to include error code and status
Previously, only the isTemp flag was considered when aggregating errors. Now, the error code and enhanced status code from the last error are also included. This ensures more comprehensive error reporting and handling.
2024-11-13 23:04:39 +01:00
719e5b217c
Enhance error handling with ENHANCEDSTATUSCODES check
Added a check for the ENHANCEDSTATUSCODES extension and included error code and enhanced status code information in SendError. This helps in providing more detailed error reporting and troubleshooting.
2024-11-13 23:02:30 +01:00
f367db0278
Refactor error code functions and add enhanced status code tests
Renamed `getErrorCode` function to `errorCode` for consistency. Added new tests for the `enhancedStatusCode` function to validate its behavior with various error scenarios.
2024-11-13 22:53:18 +01:00
6268acac44
Refactor error handling by renaming functions.
Renamed `getErrorCode` to `errorCode` and `getEnhancedStatusCode` to `enhancedStatusCode` for consistency. Updated all references in `client.go` and `senderror.go` accordingly, improving readability and maintaining uniformity across the codebase.
2024-11-13 22:47:53 +01:00
e8fb977afe
Add tests for getErrorCode function
Introduce a suite of unit tests for the getErrorCode function to validate its behavior with various error types, including go-mail errors, permanent and temporary errors, wrapper errors, non-4xx/5xx errors, and non-3-digit codes.
2024-11-13 21:48:24 +01:00
615155bfc2
Add tests for SendError's enhanced status and error codes
Implemented new unit tests for SendError to validate the enhanced status code and error codes in various scenarios, including nil SendError cases, errors with no enhanced status code, and errors with both permanent and temporary error codes. This ensures the correctness of the error handling behavior across different conditions.
2024-11-13 21:27:27 +01:00
ad265cac57
Add ErrorCode method to SendError
Implemented ErrorCode method to retrieve the error code from the server response in SendError. This method distinguishes between server-generated errors and client-generated errors, returning 0 for errors generated by the client.
2024-11-13 21:26:11 +01:00
b9d9449252
Change test server port base for SMTP client tests
Updated the TestServerPortBase from 12025 to 30025 to avoid port conflicts with other services running on the common 12025 port. This adjustment aims to ensure that the tests run reliably in diverse environments.
2024-11-13 21:25:39 +01:00
6809084e80
Add enhanced status code and error code to SendError
Enhance error handling by adding error code and enhanced status code to the SendError struct. This allows for better troubleshooting and debugging by providing more detailed SMTP server responses.
2024-11-13 16:11:28 +01:00
5 changed files with 306 additions and 15 deletions

View file

@ -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: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
rcpts, err := message.GetRecipients()
if err != nil {
return &SendError{
Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(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: errorCode(err),
enhancedStatusCode: enhancedStatusCode(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 = errorCode(err)
rcptSendErr.enhancedStatusCode = enhancedStatusCode(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: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
_, err = message.WriteTo(writer)
if err != nil {
return &SendError{
Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
if err = writer.Close(); err != nil {
return &SendError{
Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(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: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
return nil

View file

@ -27,8 +27,13 @@ import "errors"
// - An error that represents the sending result, which may include multiple SendErrors if
// any occurred; otherwise, returns nil.
func (c *Client) Send(messages ...*Msg) error {
escSupport := false
if c.smtpClient != nil {
escSupport, _ = c.smtpClient.Extension("ENHANCEDSTATUSCODES")
}
if err := c.checkConn(); err != nil {
return &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
return &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err),
errcode: errorCode(err), enhancedStatusCode: enhancedStatusCode(err, escSupport)}
}
var errs []*SendError
for id, message := range messages {
@ -50,9 +55,11 @@ func (c *Client) Send(messages ...*Msg) error {
returnErr.rcpt = append(returnErr.rcpt, errs[i].rcpt...)
}
// We assume that the isTemp flag from the last error we received should be the
// We assume that the error codes and flags from the last error we received should be the
// indicator for the returned isTemp flag as well
returnErr.isTemp = errs[len(errs)-1].isTemp
returnErr.errcode = errs[len(errs)-1].errcode
returnErr.enhancedStatusCode = errs[len(errs)-1].enhancedStatusCode
return returnErr
}

View file

@ -35,7 +35,7 @@ const (
// TestServerAddr is the address the simple SMTP test server listens on
TestServerAddr = "127.0.0.1"
// TestServerPortBase is the base port for the simple SMTP test server
TestServerPortBase = 12025
TestServerPortBase = 30025
// TestSenderValid is a test sender email address considered valid for sending test emails.
TestSenderValid = "valid-from@domain.tld"
// TestRcptValid is a test recipient email address considered valid for sending test emails.
@ -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) {

View file

@ -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,42 @@ 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
}
// ErrorCode returns the error code of the server response.
//
// This function retrieves the error code the error returned by the server. The error code will
// start with 5 on permanent errors and with 4 on a temporary error. If the error is not returned
// by the server, but is generated by go-mail, the code will be 0.
//
// Returns:
// - The error code as returned by the server, or 0 if not a server error.
func (e *SendError) ErrorCode() int {
if e == nil {
return 0
}
return e.errcode
}
// String satisfies the fmt.Stringer interface for the SendErrReason type.
//
// This function converts the SendErrReason into a human-readable string representation based
@ -224,3 +264,39 @@ func (r SendErrReason) String() string {
func isTempError(err error) bool {
return err.Error()[0] == '4'
}
func errorCode(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 enhancedStatusCode(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())
}

View file

@ -6,6 +6,7 @@ package mail
import (
"errors"
"fmt"
"strings"
"testing"
)
@ -218,6 +219,150 @@ func TestSendError_Msg(t *testing.T) {
})
}
func TestSendError_EnhancedStatusCode(t *testing.T) {
t.Run("SendError with no enhanced status code", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
}
if err.EnhancedStatusCode() != "" {
t.Errorf("expected empty enhanced status code, got: %s", err.EnhancedStatusCode())
}
})
t.Run("SendError with enhanced status code", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
enhancedStatusCode: "5.7.1",
}
if err.EnhancedStatusCode() != "5.7.1" {
t.Errorf("expected enhanced status code: %s, got: %s", "5.7.1", err.EnhancedStatusCode())
}
})
t.Run("enhanced status code on nil error should return empty string", func(t *testing.T) {
var err *SendError
if err.EnhancedStatusCode() != "" {
t.Error("expected empty enhanced status code on nil-senderror")
}
})
}
func TestSendError_ErrorCode(t *testing.T) {
t.Run("ErrorCode with a go-mail error should return 0", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
errcode: errorCode(ErrNoRcptAddresses),
}
if err.ErrorCode() != 0 {
t.Errorf("expected error code: %d, got: %d", 0, err.ErrorCode())
}
})
t.Run("SendError with permanent error", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
errcode: errorCode(errors.New("535 5.7.8 Error: authentication failed")),
}
if err.ErrorCode() != 535 {
t.Errorf("expected error code: %d, got: %d", 535, err.ErrorCode())
}
})
t.Run("SendError with temporary error", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
errcode: errorCode(errors.New("441 4.1.0 Server currently unavailable")),
}
if err.ErrorCode() != 441 {
t.Errorf("expected error code: %d, got: %d", 441, err.ErrorCode())
}
})
t.Run("error code on nil error should return 0", func(t *testing.T) {
var err *SendError
if err.ErrorCode() != 0 {
t.Error("expected 0 error code on nil-senderror")
}
})
}
func TestSendError_errorCode(t *testing.T) {
t.Run("errorCode with a go-mail error should return 0", func(t *testing.T) {
code := errorCode(ErrNoRcptAddresses)
if code != 0 {
t.Errorf("expected error code: %d, got: %d", 0, code)
}
})
t.Run("errorCode with permanent error", func(t *testing.T) {
code := errorCode(errors.New("535 5.7.8 Error: authentication failed"))
if code != 535 {
t.Errorf("expected error code: %d, got: %d", 535, code)
}
})
t.Run("errorCode with temporary error", func(t *testing.T) {
code := errorCode(errors.New("443 4.1.0 Server currently unavailable"))
if code != 443 {
t.Errorf("expected error code: %d, got: %d", 443, code)
}
})
t.Run("errorCode with wrapper error", func(t *testing.T) {
code := errorCode(fmt.Errorf("an error occured: %w", errors.New("443 4.1.0 Server currently unavailable")))
if code != 443 {
t.Errorf("expected error code: %d, got: %d", 443, code)
}
})
t.Run("errorCode with non-4xx and non-5xx error", func(t *testing.T) {
code := errorCode(errors.New("220 2.1.0 This is not an error"))
if code != 0 {
t.Errorf("expected error code: %d, got: %d", 0, code)
}
})
t.Run("errorCode with non 3-digit code", func(t *testing.T) {
code := errorCode(errors.New("4xx 4.1.0 The status code is invalid"))
if code != 0 {
t.Errorf("expected error code: %d, got: %d", 0, code)
}
})
}
func TestSendError_enhancedStatusCode(t *testing.T) {
t.Run("enhancedStatusCode with nil error should return empty string", func(t *testing.T) {
code := enhancedStatusCode(nil, true)
if code != "" {
t.Errorf("expected empty enhanced status code, got: %s", code)
}
})
t.Run("enhancedStatusCode with error but no support should return empty string", func(t *testing.T) {
code := enhancedStatusCode(errors.New("553 5.5.3 something went wrong"), false)
if code != "" {
t.Errorf("expected empty enhanced status code, got: %s", code)
}
})
t.Run("enhancedStatusCode with error and support", func(t *testing.T) {
code := enhancedStatusCode(errors.New("553 5.5.3 something went wrong"), true)
if code != "5.5.3" {
t.Errorf("expected enhanced status code: %s, got: %s", "5.5.3", code)
}
})
t.Run("enhancedStatusCode with wrapped error and support", func(t *testing.T) {
code := enhancedStatusCode(fmt.Errorf("this error is wrapped: %w", errors.New("553 5.5.3 something went wrong")), true)
if code != "5.5.3" {
t.Errorf("expected enhanced status code: %s, got: %s", "5.5.3", code)
}
})
t.Run("enhancedStatusCode with 3xx error", func(t *testing.T) {
code := enhancedStatusCode(errors.New("300 3.0.0 i don't know what i'm doing"), true)
if code != "" {
t.Errorf("expected enhanced status code to be empty, got: %s", code)
}
})
}
// returnSendError is a helper method to retunr a SendError with a specific reason
func returnSendError(r SendErrReason, t bool) error {
message := NewMsg()