-
Notifications
You must be signed in to change notification settings - Fork 0
SMTP Connection Pool
Pool a MailKit SMTP connection so a high-volume mailer reuses authenticated connections instead of paying the connect-and-authenticate cost per message — and stays a step ahead of the per-connection limits real servers enforce. This recipe builds the connection wrapper, the factory, and the preparation strategy, then wires them together. It is the worked version of Item factories and Preparation strategies, drawn from the Smtp.Pool sample.
An SMTP connection is expensive to open — a TCP connect, a TLS handshake, and an authentication round trip — and a server caps how long it stays open: a total age, an idle window, and a message count, whichever comes first. A naive pool would hand out a connection the server has already dropped. The fix is a connection that knows its own limits and a preparation strategy that recycles it before the server does.
Wrap the replaceable IMailTransport in a stable pooled identity. The wrapper tracks the lifetime signals the server cares about and exposes a single SendAsync for callers:
public sealed class SmtpConnection : IDisposable
{
private IMailTransport transport;
private DateTimeOffset? connectedAt;
private DateTimeOffset lastActivityAt;
private int messageCount;
public async Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default)
{
_ = await transport.SendAsync(message, cancellationToken);
++messageCount;
lastActivityAt = timeProvider.GetUtcNow();
}
// True once any one configured limit is reached: message count, total age, or idle time.
internal bool ShouldRecycle(DateTimeOffset now) =>
connectedAt is { } since
&& (options.MaxMessagesPerConnection > 0 && messageCount >= options.MaxMessagesPerConnection
|| options.MaxConnectionLifetime > TimeSpan.Zero && now - since >= options.MaxConnectionLifetime
|| options.MaxIdleLifetime > TimeSpan.Zero && now - lastActivityAt >= options.MaxIdleLifetime);
}The pool leases the connection exclusively for the duration of a lease, so the wrapper needs no internal locking. Dispose sends a graceful QUIT and disposes the transport; the pool calls it when an item idles out or the pool is cleared.
Construction is pure — no I/O — so the expensive work happens later in preparation. The factory builds a configured transport and hands the connection a delegate to rebuild one on recycle:
internal sealed class SmtpConnectionFactory(
IOptions<SmtpClientOptions> clientOptions,
IOptions<SmtpHostOptions> hostOptions,
TimeProvider timeProvider)
: IItemFactory<SmtpConnection>
{
public SmtpConnection CreateItem() => new(CreateTransport, clientOptions.Value, timeProvider);
internal IMailTransport CreateTransport() => new SmtpClient
{
Timeout = clientOptions.Value.TimeoutMilliseconds,
CheckCertificateRevocation = hostOptions.Value.CheckCertificateRevocation,
};
}This is where staleness is handled. IsReadyAsync runs on every lease and stays cheap — it trusts a recently used connection, probes an idle one with a NOOP, and rejects an aged-out one. PrepareAsync reconnects, recycling the transport when the connection has hit a lifetime limit:
public async ValueTask<bool> IsReadyAsync(SmtpConnection item, CancellationToken cancellationToken)
{
if (item is null || !item.IsConnected || !item.IsAuthenticated)
return false;
var now = timeProvider.GetUtcNow();
if (item.ShouldRecycle(now))
return false; // aged out — PrepareAsync reconnects
if (item.IdleFor(now) < clientOptions.ProbeAfter)
return true; // used recently — trust it, skip the round trip
return await item.PingAsync(cancellationToken); // idle a while — NOOP to confirm it is alive
}
public async Task PrepareAsync(SmtpConnection item, CancellationToken cancellationToken)
{
if (item.ShouldRecycle(timeProvider.GetUtcNow()))
await item.RecycleAsync(cancellationToken); // fresh transport for an aged-out connection
else if (item.IsConnected)
await item.DisconnectAsync(quit: false, cancellationToken); // clean a half-open socket first
await item.ConnectAsync(hostOptions, cancellationToken);
await item.AuthenticateAsync(credentials, cancellationToken);
}The recency gate matters: a NOOP on every lease would add a round trip to every send. ProbeAfter skips the probe for connections used within the window and only pays for it once a connection has been idle.
Register the factory, the strategy, and the pool. Binding the four options sections from configuration keeps host, credential, client, and pool settings out of code:
public static IServiceCollection AddSmtpClientPool(
this IServiceCollection services,
IConfiguration configuration,
Action<PoolOptions>? configureOptions = null)
{
services.AddOptions<SmtpHostOptions>().Bind(configuration.GetSection(nameof(SmtpHostOptions)))
.ValidateDataAnnotations().ValidateOnStart();
services.AddOptions<SmtpClientCredentials>().Bind(configuration.GetSection(nameof(SmtpClientCredentials)))
.ValidateDataAnnotations().ValidateOnStart();
services.AddOptions<SmtpClientOptions>().Bind(configuration.GetSection(nameof(SmtpClientOptions)));
services.TryAddSingleton(TimeProvider.System);
return services
.AddPoolItemFactory<SmtpConnection, SmtpConnectionFactory>()
.AddPreparationStrategy<SmtpConnection, SmtpConnectionPreparationStrategy>()
.AddPool<SmtpConnection>(configuration, configureOptions);
}A matching appsettings.json sets the host, credentials, lifetime limits, and pool size:
{
"SmtpHostOptions": {
"Host": "smtp.example.com",
"Port": 587,
"Security": "StartTls"
},
"SmtpClientCredentials": {
"UserName": "mailer@example.com",
"Password": "from-a-secret-store"
},
"SmtpClientOptions": {
"MaxConnectionLifetime": "00:20:00",
"MaxIdleLifetime": "00:10:00",
"MaxMessagesPerConnection": 100,
"ProbeAfter": "00:00:30"
},
"PoolOptions": {
"MinSize": 2,
"MaxSize": 10
}
}Match the lifetime limits to your provider's published numbers — the defaults above mirror common Exchange-style limits. In production, source the credentials from a secret store rather than a configuration file.
Inject the pool, lease a connection with a scoped lease, and send within the using:
public sealed class Mailer(IPool<SmtpConnection> pool)
{
public async Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default)
{
using var lease = await pool.LeaseScopeAsync(cancellationToken);
await lease.Item.SendAsync(message, cancellationToken);
}
}The lease runs the preparation strategy before handing over the connection, so lease.Item is always connected and authenticated. On scope exit the connection returns to the pool, ready for the next send.
- Preparation strategies — the readiness/prepare contract this recipe applies.
- Avoiding footguns — what happens when preparation throws, and how to retry.