Invalidate SmtpClient connection when Host or Port changes - #132770
Invalidate SmtpClient connection when Host or Port changes#132770rzikm wants to merge 8 commits into
Conversation
SmtpClient caches a live SmtpConnection in its transport and reuses it across Send calls, but the Host and Port setters only cleared the legacy _servicePoint and never dropped the cached connection. As a result, changing Host or Port between sends kept delivering mail to the original server. Release the cached connection when Host or Port actually changes so the next send establishes a fresh connection to the new target. Add regression tests across the sync Send, SendAsync, and SendMailAsync paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @karelz, @dotnet/ncl |
There was a problem hiding this comment.
Pull request overview
This PR updates System.Net.Mail.SmtpClient so that changing Host or Port invalidates any cached SMTP transport connection, ensuring subsequent sends connect to the newly configured endpoint rather than reusing a connection established for the previous host/port.
Changes:
- Release the cached transport connection when
SmtpClient.Hostchanges. - Release the cached transport connection when
SmtpClient.Portchanges. - Add functional regression tests to verify host/port changes result in new connections and correct delivery.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpClient.cs | Releases the cached SMTP transport connection when Host/Port changes. |
| src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs | Adds regression tests ensuring host/port changes establish a new connection and deliver to the intended server. |
Extend the SmtpClient connection-reuse fix beyond Host/Port to cover the other properties that change how a connection is established: Credentials, UseDefaultCredentials, EnableSsl, and TargetName. Changing any of these now invalidates the cached connection so the next send establishes a fresh one. Move the potentially blocking connection shutdown off the property setters: setters only mark the transport stale (InvalidateCachedConnection), and the graceful close of the old connection happens lazily on the send path inside GetConnectionAsync. IsConnected reports false while stale so EnsureConnection falls through to reconnect. Generalize the host regression test into a theory covering Host, Credentials, and TargetName, and add a TLS test verifying that enabling EnableSsl after an initial plaintext send establishes a new encrypted connection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
Addresses review feedback: the default TargetName is derived once from the
host ("SMTPSVC/<host>"). When Host changed, the cached connection was
invalidated but TargetName kept targeting the previous host, causing a
Negotiate/NTLM SPN mismatch on the new connection.
Now, when Host changes and TargetName still holds the host-derived default,
TargetName is updated to match the new host. A TargetName explicitly set by
the caller is left untouched. Add test coverage for both the default-follows-
host and explicit-preservation cases.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
When GetConnectionAsync releases a previously cached SmtpConnection, only gracefully QUIT one that was actually established; abort (force-close) a connection whose connect attempt failed, since it may have no initialized stream and the graceful path would dereference it and mask the original failure. Also switch the connection test credential literal to the "foo"/"bar" convention used throughout the mail tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs:23
_staleis written from property setters (no lock) and read fromIsConnectedon the send path; without synchronization this can be lost/seen late across threads, causing an old connection to be reused even after an invalidating configuration change. Marking the fieldvolatilemakes the invalidation reliably observable without widening locking.
private readonly SmtpClient _client;
private ICredentialsByHost? _credentials;
private bool _shouldAbort;
private bool _stale;
Now that TargetName is connection-affecting and invalidates the cached connection, guard its setter with the same _inCall check used by Host, Port, Credentials, and Timeout so it throws SmtpInvalidOperationDuringSend rather than mutating connection settings mid-send. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
The stale flag is written from property setters without holding the transport lock and read from IsConnected on the send path. Marking it volatile makes an invalidating configuration change reliably observable across threads without widening locking, so a stale connection is not reused after invalidation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs:105
GetConnectionAsyncperforms potentially blocking shutdown work (ReleaseConnection()/Abort()) while holding theSmtpTransportlock. This increases lock hold times across network I/O (QUIT) and can delaySmtpTransport.Abort()(used by the send timeout path). Consider clearing_connectionunder the lock, releasing/aborting the previous connection outside the lock, then reacquiring the lock to create the new connection (soAbort()during shutdown can still flow through_shouldAbort).
{
lock (this)
{
// Release any previously cached connection (for example one that became stale
// after a configuration change, or one whose connect attempt failed) before
EnableSsl is connection-affecting and invalidates the cached connection, so guard its setter with the same _inCall check used by Host, Port, Credentials, Timeout, and TargetName to prevent toggling SSL mid-send. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
GetConnectionAsync gracefully releasing a previously cached connection performs a blocking QUIT over the network. Holding the transport lock across that I/O delayed a concurrent Abort() (for example from the send-timeout path). Detach the previous connection under the lock, shut it down outside the lock, then reacquire the lock to create the new connection so an Abort() during shutdown still flows through _shouldAbort. Sends are serialized by SmtpClient._inCall, so no other GetConnectionAsync runs concurrently, and ShutdownConnection is idempotent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
Why
SmtpClientcaches a liveSmtpConnectionin its transport and reuses it acrossSendcalls. However, theHostandPortsetters only cleared the legacy_servicePointfield and never dropped the cached connection. As a result, changingHostorPortbetween sends kept delivering mail to the original server instead of the newly configured one.What
When
HostorPortactually changes, release the cached transport connection so the next send re-establishes a fresh connection to the new target. Normal connection reuse (when the value is unchanged) is preserved, and the setters already throw while a send is in progress, so this never runs mid-send.ReleaseConnectionis null-safe when no connection was ever opened.Tests
Added regression tests in
SmtpClientConnectionTest, which run across all three send paths (Send,SendAsync,SendMailAsync):ChangingPort_DoesNotReuseConnectionToPreviousServer- points the client at a second loopback server and asserts the next message reaches the new server and not the original.ChangingHost_EstablishesNewConnection- asserts a new connection is opened after the host changes.Verified both tests fail without the fix and pass with it; the full
System.Net.Mailfunctional suite (457 tests) passes.Note
This PR was authored by GitHub Copilot.