Compare commits

...

17 commits

Author SHA1 Message Date
2d98c40cb6
Add concurrent send tests for Client
Introduced TestClient_DialSendConcurrent_online and TestClient_DialSendConcurrent_local to validate concurrent sending of messages. These tests ensure that the Client's send functionality works correctly under concurrent conditions, both in an online environment and using a local test server.
2024-09-27 14:04:02 +02:00
253d065c83
Move mutex lock to sendSingleMsg method
Mutex locking was relocated from the Send method in client_120.go and client_119.go to sendSingleMsg in client.go. This ensures thread-safety specifically during the message transmission process.
2024-09-27 14:03:50 +02:00
6bd9a9c735
Refactor mutex usage for connection safety
This commit revises locking mechanism usage around connection operations to avoid potential deadlocks and improve code clarity. Specifically, defer statements were removed and explicit unlocks were added to ensure that mutexes are properly released after critical sections. This change affects several methods, including `Close`, `cmd`, `TLSConnectionState`, `UpdateDeadline`, and newly introduced locking for concurrent data writes and reads in `dataCloser`.
2024-09-27 14:03:26 +02:00
f59aa23ed8
Add mutex locking to SetTLSConfig
This change ensures that the SetTLSConfig method is thread-safe by adding a mutex lock. The lock is acquired before any changes to the TLS configuration and released afterward to prevent concurrent access issues.
2024-09-27 11:58:08 +02:00
2234f0c5bc
Remove connection field from Client struct
This commit removes the 'connection' field from the 'Client' struct and updates the related test logic accordingly. By using 'smtpClient.HasConnection()' to check for connections, code readability and maintainability are improved. All necessary test cases have been adjusted to reflect this change.
2024-09-27 11:43:22 +02:00
fdb80ad9dd
Add mutex to Client for thread-safe operations
This commit introduces a RWMutex to the Client struct in the smtp package to ensure thread-safe access to shared resources. Critical sections in methods like Close, StartTLS, and cmd are now protected with appropriate locking mechanisms. This change helps prevent potential race conditions, ensuring consistent and reliable behavior in concurrent environments.
2024-09-27 11:10:23 +02:00
fec2f2075a
Update build tags to support future Go versions
Modified the build tags to exclude Go 1.20 and above instead of targeting only Go 1.19. This change ensures the code is compatible with future versions of Go by not restricting it to a specific minor version.
2024-09-27 10:52:30 +02:00
2084526c77
Refactor Client struct to improve organization and clarity
Rearranged and grouped struct fields more logically within Client. Introduced the dialContextFunc and fallbackPort fields to enhance connection flexibility. Minor code style adjustments were also made for better readability.
2024-09-27 10:36:09 +02:00
23c71d608f
Lock mutex before checking connection in Send method
Added mutex locking in the `Send` method for both `client_120.go` and `client_119.go`. This ensures thread-safe access to the connection checks and prevents potential race conditions.
2024-09-27 10:33:28 +02:00
371b950bc7
Refactor Client struct for better readability and organization
Reordered and grouped fields in the Client struct for clarity. The reorganization separates logical groups of fields, making it easier to understand and maintain the code. This includes proper grouping of TLS parameters, DSN options, and debug settings.
2024-09-27 10:33:19 +02:00
b2c4b533d7
Merge branch 'main' into feature/269_goroutineconcurrency-safety 2024-09-27 10:26:39 +02:00
077c85bea0
Merge pull request #305 from wneessen/dependabot/github_actions/sonarsource/sonarqube-scan-action-884b79409bbd464b2a59edc326a4b77dc56b2195
Bump sonarsource/sonarqube-scan-action from f885e52a7572cf7943f28637e75730227df2dbf2 to 884b79409bbd464b2a59edc326a4b77dc56b2195
2024-09-25 16:00:56 +02:00
909e699b99
Merge pull request #306 from wneessen/dependabot/github_actions/github/codeql-action-3.26.9
Bump github/codeql-action from 3.26.8 to 3.26.9
2024-09-25 16:00:44 +02:00
dependabot[bot]
b97073db19
Bump github/codeql-action from 3.26.8 to 3.26.9
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.8 to 3.26.9.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](294a9d9291...461ef6c76d)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 13:58:44 +00:00
dependabot[bot]
d75d990124
Bump sonarsource/sonarqube-scan-action
Bumps [sonarsource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action) from f885e52a7572cf7943f28637e75730227df2dbf2 to 884b79409bbd464b2a59edc326a4b77dc56b2195.
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](f885e52a75...884b79409b)

---
updated-dependencies:
- dependency-name: sonarsource/sonarqube-scan-action
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-25 13:58:36 +00:00
0676d99f20
Merge pull request #304 from wneessen/dependabot/github_actions/sonarsource/sonarqube-scan-action-f885e52a7572cf7943f28637e75730227df2dbf2
Bump sonarsource/sonarqube-scan-action from 0c0f3958d90fc466625f1d1af1f47bddd4cc6bd1 to f885e52a7572cf7943f28637e75730227df2dbf2
2024-09-24 15:50:50 +02:00
dependabot[bot]
d6725b2d63
Bump sonarsource/sonarqube-scan-action
Bumps [sonarsource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action) from 0c0f3958d90fc466625f1d1af1f47bddd4cc6bd1 to f885e52a7572cf7943f28637e75730227df2dbf2.
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](0c0f3958d9...f885e52a75)

---
updated-dependencies:
- dependency-name: sonarsource/sonarqube-scan-action
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-24 13:48:36 +00:00
10 changed files with 295 additions and 94 deletions

View file

@ -54,7 +54,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 uses: github/codeql-action/init@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@ -65,7 +65,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 uses: github/codeql-action/autobuild@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@ -79,4 +79,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 uses: github/codeql-action/analyze@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9

View file

@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
with: with:
sarif_file: results.sarif sarif_file: results.sarif

View file

@ -44,7 +44,7 @@ jobs:
run: | run: |
go test -v -race --coverprofile=./cov.out ./... go test -v -race --coverprofile=./cov.out ./...
- uses: sonarsource/sonarqube-scan-action@0c0f3958d90fc466625f1d1af1f47bddd4cc6bd1 # master - uses: sonarsource/sonarqube-scan-action@884b79409bbd464b2a59edc326a4b77dc56b2195 # master
env: env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

View file

@ -88,13 +88,12 @@ type DialContextFunc func(ctx context.Context, network, address string) (net.Con
// Client is the SMTP client struct // Client is the SMTP client struct
type Client struct { type Client struct {
mutex sync.RWMutex
// connection is the net.Conn that the smtp.Client is based on
connection net.Conn
// Timeout for the SMTP server connection // Timeout for the SMTP server connection
connTimeout time.Duration connTimeout time.Duration
// dialContextFunc is a custom DialContext function to dial target SMTP server
dialContextFunc DialContextFunc
// dsn indicates that we want to use DSN for the Client // dsn indicates that we want to use DSN for the Client
dsn bool dsn bool
@ -104,11 +103,9 @@ type Client struct {
// dsnrntype defines the DSNRcptNotifyOption in case DSN is enabled // dsnrntype defines the DSNRcptNotifyOption in case DSN is enabled
dsnrntype []string dsnrntype []string
// isEncrypted indicates if a Client connection is encrypted or not // fallbackPort is used as an alternative port number in case the primary port is unavailable or
isEncrypted bool // fails to bind.
fallbackPort int
// noNoop indicates the Noop is to be skipped
noNoop bool
// HELO/EHLO string for the greeting the target SMTP server // HELO/EHLO string for the greeting the target SMTP server
helo string helo string
@ -116,12 +113,24 @@ type Client struct {
// Hostname of the target SMTP server to connect to // Hostname of the target SMTP server to connect to
host string host string
// isEncrypted indicates if a Client connection is encrypted or not
isEncrypted bool
// logger is a logger that implements the log.Logger interface
logger log.Logger
// mutex is used to synchronize access to shared resources, ensuring that only one goroutine can
// modify them at a time.
mutex sync.RWMutex
// noNoop indicates the Noop is to be skipped
noNoop bool
// pass is the corresponding SMTP AUTH password // pass is the corresponding SMTP AUTH password
pass string pass string
// Port of the SMTP server to connect to // port specifies the network port number on which the server listens for incoming connections.
port int port int
fallbackPort int
// smtpAuth is a pointer to smtp.Auth // smtpAuth is a pointer to smtp.Auth
smtpAuth smtp.Auth smtpAuth smtp.Auth
@ -132,26 +141,20 @@ type Client struct {
// smtpClient is the smtp.Client that is set up when using the Dial*() methods // smtpClient is the smtp.Client that is set up when using the Dial*() methods
smtpClient *smtp.Client smtpClient *smtp.Client
// Use SSL for the connection
useSSL bool
// tlspolicy sets the client to use the provided TLSPolicy for the STARTTLS protocol // tlspolicy sets the client to use the provided TLSPolicy for the STARTTLS protocol
tlspolicy TLSPolicy tlspolicy TLSPolicy
// tlsconfig represents the tls.Config setting for the STARTTLS connection // tlsconfig represents the tls.Config setting for the STARTTLS connection
tlsconfig *tls.Config tlsconfig *tls.Config
// user is the SMTP AUTH username
user string
// useDebugLog enables the debug logging on the SMTP client // useDebugLog enables the debug logging on the SMTP client
useDebugLog bool useDebugLog bool
// logger is a logger that implements the log.Logger interface // user is the SMTP AUTH username
logger log.Logger user string
// dialContextFunc is a custom DialContext function to dial target SMTP server // Use SSL for the connection
dialContextFunc DialContextFunc useSSL bool
} }
// Option returns a function that can be used for grouping Client options // Option returns a function that can be used for grouping Client options
@ -552,6 +555,9 @@ func (c *Client) SetLogger(logger log.Logger) {
// SetTLSConfig overrides the current *tls.Config with the given *tls.Config value // SetTLSConfig overrides the current *tls.Config with the given *tls.Config value
func (c *Client) SetTLSConfig(tlsconfig *tls.Config) error { func (c *Client) SetTLSConfig(tlsconfig *tls.Config) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if tlsconfig == nil { if tlsconfig == nil {
return ErrInvalidTLSConfig return ErrInvalidTLSConfig
} }
@ -795,6 +801,9 @@ func (c *Client) auth() error {
// sendSingleMsg sends out a single message and returns an error if the transmission/delivery fails. // sendSingleMsg sends out a single message and returns an error if the transmission/delivery fails.
// It is invoked by the public Send methods // It is invoked by the public Send methods
func (c *Client) sendSingleMsg(message *Msg) error { func (c *Client) sendSingleMsg(message *Msg) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if message.encoding == NoEncoding { if message.encoding == NoEncoding {
if ok, _ := c.smtpClient.Extension("8BITMIME"); !ok { if ok, _ := c.smtpClient.Extension("8BITMIME"); !ok {
return &SendError{Reason: ErrNoUnencoded, isTemp: false, affectedMsg: message} return &SendError{Reason: ErrNoUnencoded, isTemp: false, affectedMsg: message}

View file

@ -13,8 +13,6 @@ import (
// Send sends out the mail message // Send sends out the mail message
func (c *Client) Send(messages ...*Msg) (returnErr error) { func (c *Client) Send(messages ...*Msg) (returnErr error) {
c.mutex.Lock()
defer c.mutex.Unlock()
if err := c.checkConn(); err != nil { 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)}
return return

View file

@ -15,6 +15,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@ -623,11 +624,12 @@ func TestClient_DialWithContext(t *testing.T) {
t.Errorf("failed to dial with context: %s", err) t.Errorf("failed to dial with context: %s", err)
return return
} }
if c.connection == nil {
t.Errorf("DialWithContext didn't fail but no connection found.")
}
if c.smtpClient == nil { if c.smtpClient == nil {
t.Errorf("DialWithContext didn't fail but no SMTP client found.") t.Errorf("DialWithContext didn't fail but no SMTP client found.")
return
}
if !c.smtpClient.HasConnection() {
t.Errorf("DialWithContext didn't fail but no connection found.")
} }
if err := c.Close(); err != nil { if err := c.Close(); err != nil {
t.Errorf("failed to close connection: %s", err) t.Errorf("failed to close connection: %s", err)
@ -644,17 +646,18 @@ func TestClient_DialWithContext_Fallback(t *testing.T) {
c.SetTLSPortPolicy(TLSOpportunistic) c.SetTLSPortPolicy(TLSOpportunistic)
c.port = 999 c.port = 999
ctx := context.Background() ctx := context.Background()
if err := c.DialWithContext(ctx); err != nil { if err = c.DialWithContext(ctx); err != nil {
t.Errorf("failed to dial with context: %s", err) t.Errorf("failed to dial with context: %s", err)
return return
} }
if c.connection == nil {
t.Errorf("DialWithContext didn't fail but no connection found.")
}
if c.smtpClient == nil { if c.smtpClient == nil {
t.Errorf("DialWithContext didn't fail but no SMTP client found.") t.Errorf("DialWithContext didn't fail but no SMTP client found.")
return
} }
if err := c.Close(); err != nil { if !c.smtpClient.HasConnection() {
t.Errorf("DialWithContext didn't fail but no connection found.")
}
if err = c.Close(); err != nil {
t.Errorf("failed to close connection: %s", err) t.Errorf("failed to close connection: %s", err)
} }
@ -674,18 +677,19 @@ func TestClient_DialWithContext_Debug(t *testing.T) {
t.Skipf("failed to create test client: %s. Skipping tests", err) t.Skipf("failed to create test client: %s. Skipping tests", err)
} }
ctx := context.Background() ctx := context.Background()
if err := c.DialWithContext(ctx); err != nil { if err = c.DialWithContext(ctx); err != nil {
t.Errorf("failed to dial with context: %s", err) t.Errorf("failed to dial with context: %s", err)
return return
} }
if c.connection == nil {
t.Errorf("DialWithContext didn't fail but no connection found.")
}
if c.smtpClient == nil { if c.smtpClient == nil {
t.Errorf("DialWithContext didn't fail but no SMTP client found.") t.Errorf("DialWithContext didn't fail but no SMTP client found.")
return
}
if !c.smtpClient.HasConnection() {
t.Errorf("DialWithContext didn't fail but no connection found.")
} }
c.SetDebugLog(true) c.SetDebugLog(true)
if err := c.Close(); err != nil { if err = c.Close(); err != nil {
t.Errorf("failed to close connection: %s", err) t.Errorf("failed to close connection: %s", err)
} }
} }
@ -698,19 +702,20 @@ func TestClient_DialWithContext_Debug_custom(t *testing.T) {
t.Skipf("failed to create test client: %s. Skipping tests", err) t.Skipf("failed to create test client: %s. Skipping tests", err)
} }
ctx := context.Background() ctx := context.Background()
if err := c.DialWithContext(ctx); err != nil { if err = c.DialWithContext(ctx); err != nil {
t.Errorf("failed to dial with context: %s", err) t.Errorf("failed to dial with context: %s", err)
return return
} }
if c.connection == nil {
t.Errorf("DialWithContext didn't fail but no connection found.")
}
if c.smtpClient == nil { if c.smtpClient == nil {
t.Errorf("DialWithContext didn't fail but no SMTP client found.") t.Errorf("DialWithContext didn't fail but no SMTP client found.")
return
}
if !c.smtpClient.HasConnection() {
t.Errorf("DialWithContext didn't fail but no connection found.")
} }
c.SetDebugLog(true) c.SetDebugLog(true)
c.SetLogger(log.New(os.Stderr, log.LevelDebug)) c.SetLogger(log.New(os.Stderr, log.LevelDebug))
if err := c.Close(); err != nil { if err = c.Close(); err != nil {
t.Errorf("failed to close connection: %s", err) t.Errorf("failed to close connection: %s", err)
} }
} }
@ -722,10 +727,9 @@ func TestClient_DialWithContextInvalidHost(t *testing.T) {
if err != nil { if err != nil {
t.Skipf("failed to create test client: %s. Skipping tests", err) t.Skipf("failed to create test client: %s. Skipping tests", err)
} }
c.connection = nil
c.host = "invalid.addr" c.host = "invalid.addr"
ctx := context.Background() ctx := context.Background()
if err := c.DialWithContext(ctx); err == nil { if err = c.DialWithContext(ctx); err == nil {
t.Errorf("dial succeeded but was supposed to fail") t.Errorf("dial succeeded but was supposed to fail")
return return
} }
@ -738,10 +742,9 @@ func TestClient_DialWithContextInvalidHELO(t *testing.T) {
if err != nil { if err != nil {
t.Skipf("failed to create test client: %s. Skipping tests", err) t.Skipf("failed to create test client: %s. Skipping tests", err)
} }
c.connection = nil
c.helo = "" c.helo = ""
ctx := context.Background() ctx := context.Background()
if err := c.DialWithContext(ctx); err == nil { if err = c.DialWithContext(ctx); err == nil {
t.Errorf("dial succeeded but was supposed to fail") t.Errorf("dial succeeded but was supposed to fail")
return return
} }
@ -758,7 +761,7 @@ func TestClient_DialWithContextInvalidAuth(t *testing.T) {
c.pass = "invalid" c.pass = "invalid"
c.SetSMTPAuthCustom(smtp.LoginAuth("invalid", "invalid", "invalid")) c.SetSMTPAuthCustom(smtp.LoginAuth("invalid", "invalid", "invalid"))
ctx := context.Background() ctx := context.Background()
if err := c.DialWithContext(ctx); err == nil { if err = c.DialWithContext(ctx); err == nil {
t.Errorf("dial succeeded but was supposed to fail") t.Errorf("dial succeeded but was supposed to fail")
return return
} }
@ -770,8 +773,7 @@ func TestClient_checkConn(t *testing.T) {
if err != nil { if err != nil {
t.Skipf("failed to create test client: %s. Skipping tests", err) t.Skipf("failed to create test client: %s. Skipping tests", err)
} }
c.connection = nil if err = c.checkConn(); err == nil {
if err := c.checkConn(); err == nil {
t.Errorf("connCheck() should fail but succeeded") t.Errorf("connCheck() should fail but succeeded")
} }
} }
@ -802,21 +804,23 @@ func TestClient_DialWithContextOptions(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
if err := c.DialWithContext(ctx); err != nil && !tt.sf { if err = c.DialWithContext(ctx); err != nil && !tt.sf {
t.Errorf("failed to dial with context: %s", err) t.Errorf("failed to dial with context: %s", err)
return return
} }
if !tt.sf { if !tt.sf {
if c.connection == nil && !tt.sf {
t.Errorf("DialWithContext didn't fail but no connection found.")
}
if c.smtpClient == nil && !tt.sf { if c.smtpClient == nil && !tt.sf {
t.Errorf("DialWithContext didn't fail but no SMTP client found.") t.Errorf("DialWithContext didn't fail but no SMTP client found.")
return
} }
if err := c.Reset(); err != nil { if !c.smtpClient.HasConnection() && !tt.sf {
t.Errorf("DialWithContext didn't fail but no connection found.")
return
}
if err = c.Reset(); err != nil {
t.Errorf("failed to reset connection: %s", err) t.Errorf("failed to reset connection: %s", err)
} }
if err := c.Close(); err != nil { if err = c.Close(); err != nil {
t.Errorf("failed to close connection: %s", err) t.Errorf("failed to close connection: %s", err)
} }
} }
@ -1011,17 +1015,15 @@ func TestClient_DialSendCloseBroken(t *testing.T) {
} }
if tt.closestart { if tt.closestart {
_ = c.smtpClient.Close() _ = c.smtpClient.Close()
_ = c.connection.Close()
} }
if err := c.Send(m); err != nil && !tt.sf { if err = c.Send(m); err != nil && !tt.sf {
t.Errorf("Send() failed: %s", err) t.Errorf("Send() failed: %s", err)
return return
} }
if tt.closeearly { if tt.closeearly {
_ = c.smtpClient.Close() _ = c.smtpClient.Close()
_ = c.connection.Close()
} }
if err := c.Close(); err != nil && !tt.sf { if err = c.Close(); err != nil && !tt.sf {
t.Errorf("Close() failed: %s", err) t.Errorf("Close() failed: %s", err)
return return
} }
@ -1071,17 +1073,15 @@ func TestClient_DialSendCloseBrokenWithDSN(t *testing.T) {
} }
if tt.closestart { if tt.closestart {
_ = c.smtpClient.Close() _ = c.smtpClient.Close()
_ = c.connection.Close()
} }
if err := c.Send(m); err != nil && !tt.sf { if err = c.Send(m); err != nil && !tt.sf {
t.Errorf("Send() failed: %s", err) t.Errorf("Send() failed: %s", err)
return return
} }
if tt.closeearly { if tt.closeearly {
_ = c.smtpClient.Close() _ = c.smtpClient.Close()
_ = c.connection.Close()
} }
if err := c.Close(); err != nil && !tt.sf { if err = c.Close(); err != nil && !tt.sf {
t.Errorf("Close() failed: %s", err) t.Errorf("Close() failed: %s", err)
return return
} }
@ -1728,6 +1728,114 @@ func TestClient_SendErrorReset(t *testing.T) {
} }
} }
func TestClient_DialSendConcurrent_online(t *testing.T) {
if os.Getenv("TEST_ALLOW_SEND") == "" {
t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test")
}
client, err := getTestConnection(true)
if err != nil {
t.Errorf("unable to create new client: %s", err)
}
var messages []*Msg
for i := 0; i < 10; i++ {
message := NewMsg()
if err := message.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")); err != nil {
t.Errorf("failed to set FROM address: %s", err)
return
}
if err := message.To(TestRcpt); err != nil {
t.Errorf("failed to set TO address: %s", err)
return
}
message.Subject(fmt.Sprintf("Test subject for mail %d", i))
message.SetBodyString(TypeTextPlain, fmt.Sprintf("This is the test body of the mail no. %d", i))
message.SetMessageID()
messages = append(messages, message)
}
if err = client.DialWithContext(context.Background()); err != nil {
t.Errorf("failed to dial to test server: %s", err)
}
wg := sync.WaitGroup{}
for id, message := range messages {
wg.Add(1)
go func(curMsg *Msg, curID int) {
defer wg.Done()
if goroutineErr := client.Send(curMsg); err != nil {
t.Errorf("failed to send message with ID %d: %s", curID, goroutineErr)
}
}(message, id)
}
wg.Wait()
if err = client.Close(); err != nil {
t.Errorf("failed to close server connection: %s", err)
}
}
func TestClient_DialSendConcurrent_local(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
serverPort := TestServerPortBase + 20
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 500)
client, err := NewClient(TestServerAddr, WithPort(serverPort),
WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain),
WithUsername("toni@tester.com"),
WithPassword("V3ryS3cr3t+"))
if err != nil {
t.Errorf("unable to create new client: %s", err)
}
var messages []*Msg
for i := 0; i < 50; i++ {
message := NewMsg()
if err := message.From("valid-from@domain.tld"); err != nil {
t.Errorf("failed to set FROM address: %s", err)
return
}
if err := message.To("valid-to@domain.tld"); err != nil {
t.Errorf("failed to set TO address: %s", err)
return
}
message.Subject("Test subject")
message.SetBodyString(TypeTextPlain, "Test body")
message.SetMessageIDWithValue("this.is.a.message.id")
messages = append(messages, message)
}
if err = client.DialWithContext(context.Background()); err != nil {
t.Errorf("failed to dial to test server: %s", err)
}
wg := sync.WaitGroup{}
for id, message := range messages {
wg.Add(1)
go func(curMsg *Msg, curID int) {
defer wg.Done()
if goroutineErr := client.Send(curMsg); err != nil {
t.Errorf("failed to send message with ID %d: %s", curID, goroutineErr)
}
}(message, id)
}
wg.Wait()
if err = client.Close(); err != nil {
t.Errorf("failed to close server connection: %s", err)
}
}
// getTestConnection takes environment variables to establish a connection to a real // getTestConnection takes environment variables to establish a connection to a real
// SMTP server to test all functionality that requires a connection // SMTP server to test all functionality that requires a connection
func getTestConnection(auth bool) (*Client, error) { func getTestConnection(auth bool) (*Client, error) {
@ -2100,6 +2208,7 @@ func handleTestServerConnection(connection net.Conn, featureSet string, failRese
if err != nil { if err != nil {
break break
} }
time.Sleep(time.Millisecond)
var datastring string var datastring string
data = strings.TrimSpace(data) data = strings.TrimSpace(data)

View file

@ -2,8 +2,8 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//go:build go1.19 && !go1.20 //go:build !go1.20
// +build go1.19,!go1.20 // +build !go1.20
package mail package mail

View file

@ -30,6 +30,7 @@ import (
"net/textproto" "net/textproto"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
"github.com/wneessen/go-mail/log" "github.com/wneessen/go-mail/log"
@ -37,28 +38,49 @@ import (
// A Client represents a client connection to an SMTP server. // A Client represents a client connection to an SMTP server.
type Client struct { type Client struct {
// Text is the textproto.Conn used by the Client. It is exported to allow for // Text is the textproto.Conn used by the Client. It is exported to allow for clients to add extensions.
// clients to add extensions.
Text *textproto.Conn Text *textproto.Conn
// keep a reference to the connection so it can be used to create a TLS
// connection later // auth supported auth mechanisms
conn net.Conn
// whether the Client is using TLS
tls bool
serverName string
// map of supported extensions
ext map[string]string
// supported auth mechanisms
auth []string auth []string
// keep a reference to the connection so it can be used to create a TLS connection later
conn net.Conn
// debug logging is enabled
debug bool
// didHello indicates whether we've said HELO/EHLO
didHello bool
// dsnmrtype defines the mail return option in case DSN is enabled
dsnmrtype string
// dsnrntype defines the recipient notify option in case DSN is enabled
dsnrntype string
// ext is a map of supported extensions
ext map[string]string
// helloError is the error from the hello
helloError error
// localName is the name to use in HELO/EHLO
localName string // the name to use in HELO/EHLO localName string // the name to use in HELO/EHLO
didHello bool // whether we've said HELO/EHLO
helloError error // the error from the hello // logger will be used for debug logging
// debug logging logger log.Logger
debug bool // debug logging is enabled
logger log.Logger // logger will be used for debug logging // mutex is used to synchronize access to shared resources, ensuring that only one goroutine can access
// DSN support // the resource at a time.
dsnmrtype string // dsnmrtype defines the mail return option in case DSN is enabled mutex sync.RWMutex
dsnrntype string // dsnrntype defines the recipient notify option in case DSN is enabled
// tls indicates whether the Client is using TLS
tls bool
// serverName denotes the name of the server to which the application will connect. Used for
// identification and routing.
serverName string
} }
// Dial returns a new [Client] connected to an SMTP server at addr. // Dial returns a new [Client] connected to an SMTP server at addr.
@ -95,7 +117,10 @@ func NewClient(conn net.Conn, host string) (*Client, error) {
// Close closes the connection. // Close closes the connection.
func (c *Client) Close() error { func (c *Client) Close() error {
return c.Text.Close() c.mutex.Lock()
err := c.Text.Close()
c.mutex.Unlock()
return err
} }
// hello runs a hello exchange if needed. // hello runs a hello exchange if needed.
@ -122,28 +147,39 @@ func (c *Client) Hello(localName string) error {
if c.didHello { if c.didHello {
return errors.New("smtp: Hello called after other methods") return errors.New("smtp: Hello called after other methods")
} }
c.mutex.Lock()
c.localName = localName c.localName = localName
c.mutex.Unlock()
return c.hello() return c.hello()
} }
// cmd is a convenience function that sends a command and returns the response // cmd is a convenience function that sends a command and returns the response
func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
c.mutex.Lock()
c.debugLog(log.DirClientToServer, format, args...) c.debugLog(log.DirClientToServer, format, args...)
id, err := c.Text.Cmd(format, args...) id, err := c.Text.Cmd(format, args...)
if err != nil { if err != nil {
c.mutex.Unlock()
return 0, "", err return 0, "", err
} }
c.Text.StartResponse(id) c.Text.StartResponse(id)
defer c.Text.EndResponse(id)
code, msg, err := c.Text.ReadResponse(expectCode) code, msg, err := c.Text.ReadResponse(expectCode)
c.debugLog(log.DirServerToClient, "%d %s", code, msg) c.debugLog(log.DirServerToClient, "%d %s", code, msg)
c.Text.EndResponse(id)
c.mutex.Unlock()
return code, msg, err return code, msg, err
} }
// helo sends the HELO greeting to the server. It should be used only when the // helo sends the HELO greeting to the server. It should be used only when the
// server does not support ehlo. // server does not support ehlo.
func (c *Client) helo() error { func (c *Client) helo() error {
c.mutex.Lock()
c.ext = nil c.ext = nil
c.mutex.Unlock()
_, _, err := c.cmd(250, "HELO %s", c.localName) _, _, err := c.cmd(250, "HELO %s", c.localName)
return err return err
} }
@ -158,9 +194,13 @@ func (c *Client) StartTLS(config *tls.Config) error {
if err != nil { if err != nil {
return err return err
} }
c.mutex.Lock()
c.conn = tls.Client(c.conn, config) c.conn = tls.Client(c.conn, config)
c.Text = textproto.NewConn(c.conn) c.Text = textproto.NewConn(c.conn)
c.tls = true c.tls = true
c.mutex.Unlock()
return c.ehlo() return c.ehlo()
} }
@ -168,11 +208,15 @@ func (c *Client) StartTLS(config *tls.Config) error {
// The return values are their zero values if [Client.StartTLS] did // The return values are their zero values if [Client.StartTLS] did
// not succeed. // not succeed.
func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) {
c.mutex.RLock()
tc, ok := c.conn.(*tls.Conn) tc, ok := c.conn.(*tls.Conn)
if !ok { if !ok {
return return
} }
return tc.ConnectionState(), true state, ok = tc.ConnectionState(), true
c.mutex.RUnlock()
return
} }
// Verify checks the validity of an email address on the server. // Verify checks the validity of an email address on the server.
@ -258,6 +302,8 @@ func (c *Client) Mail(from string) error {
return err return err
} }
cmdStr := "MAIL FROM:<%s>" cmdStr := "MAIL FROM:<%s>"
c.mutex.RLock()
if c.ext != nil { if c.ext != nil {
if _, ok := c.ext["8BITMIME"]; ok { if _, ok := c.ext["8BITMIME"]; ok {
cmdStr += " BODY=8BITMIME" cmdStr += " BODY=8BITMIME"
@ -270,6 +316,8 @@ func (c *Client) Mail(from string) error {
cmdStr += fmt.Sprintf(" RET=%s", c.dsnmrtype) cmdStr += fmt.Sprintf(" RET=%s", c.dsnmrtype)
} }
} }
c.mutex.RUnlock()
_, _, err := c.cmd(250, cmdStr, from) _, _, err := c.cmd(250, cmdStr, from)
return err return err
} }
@ -281,7 +329,11 @@ func (c *Client) Rcpt(to string) error {
if err := validateLine(to); err != nil { if err := validateLine(to); err != nil {
return err return err
} }
c.mutex.RLock()
_, ok := c.ext["DSN"] _, ok := c.ext["DSN"]
c.mutex.RUnlock()
if ok && c.dsnrntype != "" { if ok && c.dsnrntype != "" {
_, _, err := c.cmd(25, "RCPT TO:<%s> NOTIFY=%s", to, c.dsnrntype) _, _, err := c.cmd(25, "RCPT TO:<%s> NOTIFY=%s", to, c.dsnrntype)
return err return err
@ -295,12 +347,23 @@ type dataCloser struct {
io.WriteCloser io.WriteCloser
} }
// Close releases the lock, closes the WriteCloser, waits for a response, and then returns any error encountered.
func (d *dataCloser) Close() error { func (d *dataCloser) Close() error {
d.c.mutex.Lock()
_ = d.WriteCloser.Close() _ = d.WriteCloser.Close()
_, _, err := d.c.Text.ReadResponse(250) _, _, err := d.c.Text.ReadResponse(250)
d.c.mutex.Unlock()
return err return err
} }
// Write writes data to the underlying WriteCloser while ensuring thread-safety by locking and unlocking a mutex.
func (d *dataCloser) Write(p []byte) (n int, err error) {
d.c.mutex.Lock()
n, err = d.WriteCloser.Write(p)
d.c.mutex.Unlock()
return
}
// Data issues a DATA command to the server and returns a writer that // Data issues a DATA command to the server and returns a writer that
// can be used to write the mail headers and body. The caller should // can be used to write the mail headers and body. The caller should
// close the writer before calling any more methods on c. A call to // close the writer before calling any more methods on c. A call to
@ -310,7 +373,14 @@ func (c *Client) Data() (io.WriteCloser, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &dataCloser{c, c.Text.DotWriter()}, nil datacloser := &dataCloser{}
c.mutex.Lock()
datacloser.c = c
datacloser.WriteCloser = c.Text.DotWriter()
c.mutex.Unlock()
return datacloser, nil
} }
var testHookStartTLS func(*tls.Config) // nil, except for tests var testHookStartTLS func(*tls.Config) // nil, except for tests
@ -406,7 +476,10 @@ func (c *Client) Extension(ext string) (bool, string) {
return false, "" return false, ""
} }
ext = strings.ToUpper(ext) ext = strings.ToUpper(ext)
c.mutex.RLock()
param, ok := c.ext[ext] param, ok := c.ext[ext]
c.mutex.RUnlock()
return ok, param return ok, param
} }
@ -439,7 +512,11 @@ func (c *Client) Quit() error {
if err != nil { if err != nil {
return err return err
} }
return c.Text.Close() c.mutex.Lock()
err = c.Text.Close()
c.mutex.Unlock()
return err
} }
// SetDebugLog enables the debug logging for incoming and outgoing SMTP messages // SetDebugLog enables the debug logging for incoming and outgoing SMTP messages
@ -480,9 +557,11 @@ func (c *Client) HasConnection() bool {
} }
func (c *Client) UpdateDeadline(timeout time.Duration) error { func (c *Client) UpdateDeadline(timeout time.Duration) error {
c.mutex.Lock()
if err := c.conn.SetDeadline(time.Now().Add(timeout)); err != nil { if err := c.conn.SetDeadline(time.Now().Add(timeout)); err != nil {
return fmt.Errorf("smtp: failed to update deadline: %w", err) return fmt.Errorf("smtp: failed to update deadline: %w", err)
} }
c.mutex.Unlock()
return nil return nil
} }

View file

@ -25,6 +25,9 @@ func (c *Client) ehlo() error {
if err != nil { if err != nil {
return err return err
} }
c.mutex.Lock()
defer c.mutex.Unlock()
ext := make(map[string]string) ext := make(map[string]string)
extList := strings.Split(msg, "\n") extList := strings.Split(msg, "\n")
if len(extList) > 1 { if len(extList) > 1 {

View file

@ -28,6 +28,9 @@ func (c *Client) ehlo() error {
if err != nil { if err != nil {
return err return err
} }
c.mutex.Lock()
defer c.mutex.Unlock()
ext := make(map[string]string) ext := make(map[string]string)
extList := strings.Split(msg, "\n") extList := strings.Split(msg, "\n")
if len(extList) > 1 { if len(extList) > 1 {