Skip to content

Invalidate SmtpClient cached connection when connection-affecting properties change - #132770

Merged
rzikm merged 10 commits into
mainfrom
rzikm/smtpclient-invalidate-connection-on-host
Aug 27, 2026
Merged

Invalidate SmtpClient cached connection when connection-affecting properties change#132770
rzikm merged 10 commits into
mainfrom
rzikm/smtpclient-invalidate-connection-on-host

Conversation

@rzikm

@rzikm rzikm commented Aug 26, 2026

Copy link
Copy Markdown
Member

Why

SmtpClient caches a live SmtpConnection in its transport and reuses it across Send calls. However, the connection-affecting property setters never dropped the cached connection. As a result, changing configuration between sends kept delivering mail over the original connection instead of re-establishing one for the new configuration. The most visible case was Host/Port (mail kept going to the original server), but the same stale-reuse problem applied to every property that determines how the connection is established.

What

When a connection-affecting property actually changes, the cached transport connection is invalidated so the next send re-establishes a fresh connection for the new configuration. This now covers:

  • Host and Port
  • Credentials / UseDefaultCredentials (SMTP AUTH is per-connection)
  • EnableSsl
  • TargetName (the SPN used for authentication)

Changing Host also updates the default TargetName (SMTPSVC/<host>) when the target name was not set explicitly, so authentication targets the new host.

Rather than doing potentially blocking work in a property setter, the setters simply mark the transport as stale (a non-blocking flag). The stale connection is aborted and replaced lazily on the next send inside GetConnectionAsync. Aborting (rather than a graceful QUIT) avoids synchronous network I/O on the async send path. Normal connection reuse is preserved when a value is unchanged, and the setters throw InvalidOperationException (SmtpInvalidOperationDuringSend) if changed while a send is in progress, so invalidation never races an active send.

Tests

Added regression tests in SmtpClientConnectionTest and SmtpClientTlsTest, running across all three send paths (Send, SendAsync, SendMailAsync):

  • ChangingConnectionProperty_EstablishesNewConnection - theory over Host, Credentials, and TargetName; asserts a new connection is established and, for the Host case, that the default TargetName follows the new host.
  • ChangingHost_PreservesExplicitlySetTargetName - an explicitly set TargetName is not overwritten when Host changes.
  • ChangingPort_DoesNotReuseConnectionToPreviousServer - points the client at a second loopback server and asserts the next message reaches the new server, not the original.
  • EnableSsl_ChangedAfterConnect_EstablishesNewEncryptedConnection - toggling EnableSsl after a plaintext connection forces a new encrypted connection.

The targeted SmtpClientConnectionTest and SmtpClientTlsTest classes (60 tests across the three send modes) pass; the connection-property tests were confirmed to fail without the fix and pass with it.

Note

This PR was authored by GitHub Copilot.

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
Copilot AI lite review requested due to automatic review settings August 26, 2026 09:05
@azure-pipelines

Copy link
Copy Markdown
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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.Host changes.
  • Release the cached transport connection when SmtpClient.Port changes.
  • 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.

Comment thread src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpClient.cs
Comment thread src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs Outdated
rzikm and others added 2 commits August 26, 2026 11:57
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
Copilot AI review requested due to automatic review settings August 26, 2026 10:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs Outdated
Comment thread src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs Outdated
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
Copilot AI review requested due to automatic review settings August 26, 2026 10:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • _stale is written from property setters (no lock) and read from IsConnected on 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 field volatile makes the invalidation reliably observable without widening locking.
        private readonly SmtpClient _client;
        private ICredentialsByHost? _credentials;
        private bool _shouldAbort;
        private bool _stale;

Comment thread src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpClient.cs
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
Copilot AI review requested due to automatic review settings August 26, 2026 10:33
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • GetConnectionAsync performs potentially blocking shutdown work (ReleaseConnection() / Abort()) while holding the SmtpTransport lock. This increases lock hold times across network I/O (QUIT) and can delay SmtpTransport.Abort() (used by the send timeout path). Consider clearing _connection under the lock, releasing/aborting the previous connection outside the lock, then reacquiring the lock to create the new connection (so Abort() 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

Copilot AI review requested due to automatic review settings August 26, 2026 10:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs
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
Copilot AI review requested due to automatic review settings August 26, 2026 10:58
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/tests/Functional/SmtpClientConnectionTest.cs:16

  • ConnectionAffectingProperty is only used as test data within this file; making it public unnecessarily expands the surface area of the test assembly. Consider making it internal (or nesting it in the test type) since nothing outside needs it.
    public enum ConnectionAffectingProperty
    {
        Host,
        Credentials,
        TargetName,

Copilot AI review requested due to automatic review settings August 26, 2026 11:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpClient.cs:370

  • This comment says TargetName participates in "TLS", but the TLS handshake uses the host parameter as TargetHost; TargetName is used for authentication/SPN. Update the comment to avoid implying TLS behavior.
                    // The target name participates in connection establishment (authentication
                    // and TLS), so invalidate any cached connection to force a new one on the
                    // next send.

src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs:12

  • This enum is only used within this test file; declaring it as public unnecessarily expands the test assembly's public surface area. Make it non-public (default/internal) to keep scope narrower.
    public enum ConnectionAffectingProperty

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
Copilot AI review requested due to automatic review settings August 26, 2026 11:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs:129

  • GetConnectionAsync<TIOAdapter> may synchronously perform a graceful QUIT (ReleaseConnection) on the previously cached connection before establishing the new one. Since this runs on the SendMailAsync path before the first await, it can block the caller thread (e.g., UI thread) and also can’t be interrupted by Abort() while it’s in the QUIT/close path (because _connection is already cleared). For stale/failed connection invalidation, aborting the old connection is sufficient and avoids synchronous network I/O in an async code path.
                if (previousConnection.IsConnected)
                {
                    previousConnection.ReleaseConnection();
                }

src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs:195

  • The PR title/description focus on invalidating the cached connection when Host/Port change, but the implementation also invalidates on Credentials, EnableSsl, and TargetName changes (as described in this comment). Please update the PR description/title to reflect the broader behavior change so reviewers and backporters don’t miss it.
        // Marks any cached connection as stale without performing blocking work. The connection
        // is gracefully released and replaced the next time one is established (see
        // GetConnectionAsync). This is called when a property that affects how the connection is
        // established (host, port, credentials, SSL settings, target name) changes.
        internal void InvalidateCachedConnection()

A graceful ReleaseConnection() performs a synchronous blocking QUIT over the
network on the async send path before the first await, which can block the
caller thread. A connection being discarded due to a configuration change does
not need a polite QUIT, so abort it instead. Because Abort() is non-blocking,
the previous detach-outside-lock structure is no longer needed and the logic
collapses back into a single lock.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
Copilot AI review requested due to automatic review settings August 26, 2026 11:34
@rzikm rzikm changed the title Invalidate SmtpClient connection when Host or Port changes Invalidate SmtpClient cached connection when connection-affecting properties change Aug 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/tests/Functional/SmtpClientConnectionTest.cs:12

  • The ConnectionAffectingProperty enum is only used within this test file and doesn’t need to be public. Keeping it public unnecessarily expands the test assembly’s surface area (and makes it easier to accidentally depend on from other tests).
    public enum ConnectionAffectingProperty

@rzikm
rzikm requested a review from a team August 26, 2026 14:40
@rzikm

rzikm commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

/ba-g Test failures are unrelated, no SMTP related failures

@rzikm
rzikm merged commit 6fe7c1f into main Aug 27, 2026
79 of 81 checks passed
@rzikm
rzikm deleted the rzikm/smtpclient-invalidate-connection-on-host branch August 27, 2026 09:11
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants