Skip to content

Commit

Permalink
Merge pull request #368 from wneessen/feature/365_expose-error-code-i…
Browse files Browse the repository at this point in the history
…n-senderror

Expose error code in SendError
  • Loading branch information
wneessen authored Nov 14, 2024
2 parents 29ad32e + 2bde340 commit da6bf26
Show file tree
Hide file tree
Showing 8 changed files with 357 additions and 19 deletions.
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
PERFORM_ONLINE_TEST: ${{ vars.PERFORM_ONLINE_TEST }}
PERFORM_UNIX_OPEN_WRITE_TESTS: ${{ vars.PERFORM_UNIX_OPEN_WRITE_TESTS }}
PERFORM_SENDMAIL_TESTS: ${{ vars.PERFORM_SENDMAIL_TESTS }}
TEST_BASEPORT: ${{ vars.TEST_BASEPORT }}
TEST_BASEPORT_SMTP: ${{ vars.TEST_BASEPORT_SMTP }}
TEST_HOST: ${{ secrets.TEST_HOST }}
TEST_USER: ${{ secrets.TEST_USER }}
TEST_PASS: ${{ secrets.TEST_PASS }}
Expand Down Expand Up @@ -126,6 +128,9 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go: ['1.19', '1.20', '1.21', '1.22', '1.23']
env:
TEST_BASEPORT: ${{ vars.TEST_BASEPORT }}
TEST_BASEPORT_SMTP: ${{ vars.TEST_BASEPORT_SMTP }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
Expand All @@ -149,6 +154,9 @@ jobs:
strategy:
matrix:
osver: ['14.1', '14.0', 13.4']
env:
TEST_BASEPORT: ${{ vars.TEST_BASEPORT }}
TEST_BASEPORT_SMTP: ${{ vars.TEST_BASEPORT_SMTP }}
steps:
- name: Checkout Code
uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master
Expand Down Expand Up @@ -189,6 +197,8 @@ jobs:
go: ['1.23']
env:
PERFORM_ONLINE_TEST: ${{ vars.PERFORM_ONLINE_TEST }}
TEST_BASEPORT: ${{ vars.TEST_BASEPORT }}
TEST_BASEPORT_SMTP: ${{ vars.TEST_BASEPORT_SMTP }}
TEST_HOST: ${{ secrets.TEST_HOST }}
TEST_USER: ${{ secrets.TEST_USER }}
TEST_PASS: ${{ secrets.TEST_PASS }}
Expand Down
24 changes: 17 additions & 7 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
}
}

Expand All @@ -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)
Expand All @@ -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
}
}
Expand All @@ -1251,28 +1257,32 @@ 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

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
Expand Down
13 changes: 11 additions & 2 deletions client_119.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@ 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 {
Expand All @@ -50,9 +57,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
}
Expand Down
9 changes: 8 additions & 1 deletion client_120.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@ import (
// Returns:
// - An error that aggregates any SendErrors encountered during the sending process; otherwise, returns nil.
func (c *Client) Send(messages ...*Msg) (returnErr error) {
escSupport := false
if c.smtpClient != nil {
escSupport, _ = c.smtpClient.Extension("ENHANCEDSTATUSCODES")
}
if err := c.checkConn(); err != nil {
returnErr = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
returnErr = &SendError{
Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err),
errcode: errorCode(err), enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
return
}

Expand Down
71 changes: 69 additions & 2 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"net/mail"
"os"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
Expand All @@ -34,14 +35,15 @@ const (
TestServerProto = "tcp"
// 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
// TestSenderValid is a test sender email address considered valid for sending test emails.
TestSenderValid = "[email protected]"
// TestRcptValid is a test recipient email address considered valid for sending test emails.
TestRcptValid = "[email protected]"
)

// TestServerPortBase is the base port for the simple SMTP test server
var TestServerPortBase int32 = 30025

// PortAdder is an atomic counter used to increment port numbers for the test SMTP server instances.
var PortAdder atomic.Int32

Expand Down Expand Up @@ -98,6 +100,18 @@ type logData struct {
Lines []logLine `json:"lines"`
}

func init() {
testPort := os.Getenv("TEST_BASEPORT")
if testPort == "" {
return
}
if port, err := strconv.Atoi(testPort); err == nil {
if port <= 65000 && port > 1023 {
TestServerPortBase = int32(port)
}
}
}

func TestNewClient(t *testing.T) {
t.Run("create new Client", func(t *testing.T) {
client, err := NewClient(DefaultHost)
Expand Down Expand Up @@ -3148,6 +3162,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) {
Expand Down
86 changes: 81 additions & 5 deletions senderror.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package mail

import (
"errors"
"regexp"
"strconv"
"strings"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
}
Loading

0 comments on commit da6bf26

Please sign in to comment.