What It Solves
- Per-request SMTP connect and dispose
- TIME_WAIT and ephemeral port pressure
- Reuse of broken SMTP sessions
- Reconnect storms during outages
- Unbounded waiting when the pool is exhausted
- Blind retry when delivery may already be ambiguous
MailKit.Pooling
Reuse SMTP connections safely, avoid per-request client churn, bound waiting, classify failures, and suppress reconnect storms.
Register the pool once in DI, then send through ISmtpSender.
The default path is sender-oriented. Application code should not new SmtpClient() for each request.
using PooledMailKit.Abstractions;
using PooledMailKit.DependencyInjection;
using PooledMailKit.Options;
using Microsoft.Extensions.DependencyInjection;
using MimeKit;
var services = new ServiceCollection();
services.AddMailKitPooling(options =>
{
options.Hosts.Add(new SmtpHostOptions
{
Host = "smtp-primary.example.com",
Port = 587,
SecureSocketOptions = "StartTls",
UserName = "smtp-user",
Password = "smtp-password",
Priority = 0,
Weight = 3,
});
options.Hosts.Add(new SmtpHostOptions
{
Host = "smtp-secondary.example.com",
Port = 587,
SecureSocketOptions = "StartTls",
UserName = "smtp-user",
Password = "smtp-password",
Priority = 10,
Weight = 1,
});
options.MinPoolSize = 0;
options.MinPoolRefillDelay = TimeSpan.Zero;
options.MaxPoolSize = 8;
options.AcquireTimeout = TimeSpan.FromSeconds(15);
options.IdleTimeout = TimeSpan.FromMinutes(2);
options.KeepAliveInterval = TimeSpan.FromMinutes(1);
options.ConnectTimeout = TimeSpan.FromSeconds(15);
options.AuthenticateTimeout = TimeSpan.FromSeconds(15);
options.SmtpSendTimeout = TimeSpan.FromSeconds(30);
options.ReconnectCooldown = TimeSpan.FromSeconds(30);
options.MaxRetryAttempts = 1;
options.RetryBaseDelay = TimeSpan.FromSeconds(2);
});
var provider = services.BuildServiceProvider();
var sender = provider.GetRequiredService<ISmtpSender>();
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse("from@example.com"));
message.To.Add(MailboxAddress.Parse("to@example.com"));
message.Subject = "Hello";
message.Body = new TextPart("plain") { Text = "Hello from MailKit.Pooling" };
var result = await sender.SendAsync(message);
Console.WriteLine($"Sent via {result.EndpointKey} in {result.Attempts} attempt(s).");
If you only have one SMTP endpoint, options.Host still works as a compatibility path.
New configuration should prefer options.Hosts. Lower Priority values are preferred first.
Weight applies within hosts that share the same Priority.
The pool leases a single SMTP connection to one caller at a time.
ISmtpSender sends one message through that lease and returns or invalidates the connection.
Failures are classified. Retry, host-level cooldown, and failover decisions follow the classification and stage.
The NuGet-facing API is intentionally narrow. Most pool, factory, adapter, clock, metrics, and classifier implementation types are internal.
ISmtpSenderSmtpPoolOptions and SmtpHostOptionsSmtpSendResultSmtpSendFailedExceptionSmtpFailureClassification, SmtpFailureKind, and SmtpSendStageServiceCollectionExtensions
ISmtpSender.SendAsync() returns SmtpSendResult on success.
On failure, the main application-facing exception is SmtpSendFailedException.
SmtpSendFailedException: inspect Classification.Kind, Classification.Stage, and Attempts. The original exception is preserved as InnerException.OperationCanceledException: caller cancellation is returned as-is and is not wrapped.try
{
await sender.SendAsync(message, cancellationToken);
}
catch (SmtpSendFailedException ex) when (ex.Classification.Kind == SmtpFailureKind.PoolExhausted)
{
// Pool wait exceeded AcquireTimeout.
}
catch (SmtpSendFailedException ex) when (ex.Classification.Kind == SmtpFailureKind.UnknownAfterData)
{
// Delivery may already be ambiguous. Do not blindly resend.
}
MailKit itself is not thread-safe. This library does not try to make a single
SmtpClient concurrently usable. Instead, it prevents concurrent reuse by design:
one lease maps to one connection, and one connection is never leased to more than one caller at the same time.
The safe path is ISmtpSender. Advanced callers should treat a leased client as exclusive ownership until return or invalidation.
The fuller boundary is documented in docs/design/use-cases.md.
The Quick Start values are starting points, not universal defaults. Tune them from concurrency, SMTP latency, outage behavior, and caller wait tolerance.
MaxPoolSize from allowed concurrent sends, not CPU count.MinPoolSize at 0 unless steady traffic justifies warm idle connections.MinPoolRefillDelay only when discarded connections should not be replaced immediately.AcquireTimeout from how long callers are allowed to wait.ConnectTimeout, AuthenticateTimeout, and SmtpSendTimeout from real SMTP latency.ReconnectCooldown long enough to suppress reconnect storms, but short enough to recover promptly.MaxRetryAttempts small; this library is not a durable retry engine.
The fuller tuning guide is documented in docs/design/option-tuning.md.
| Option | Purpose |
|---|---|
MinPoolSize | Warm idle connections to keep ready. |
MinPoolRefillDelay | Delay before restoring MinPoolSize after discarded connections. |
MaxPoolSize | Hard upper bound for simultaneously live connections. |
AcquireTimeout | Maximum wait for a lease before explicit failure. |
IdleTimeout | Discard stale idle connections instead of keeping them forever. |
KeepAliveInterval | Issue NOOP on stale idle connections before reuse. |
ConnectTimeout | Bound connect duration. |
AuthenticateTimeout | Bound authenticate duration. |
SmtpSendTimeout | Bound a single SMTP send operation. |
ReconnectCooldown | Suppress immediate connection recreation after failure. |
MaxRetryAttempts | Retry budget for explicitly retryable failures only. |
RetryBaseDelay | Base delay used for retry backoff. |
EnableMetrics | Emit metrics through the configured metrics implementation. |
SmtpHostOptions.Priority | Prefer lower-priority hosts first for active-standby failover. |
SmtpHostOptions.Weight | Distribute connections across hosts that share the same priority. |
pooledmailkit.pool.host.cooldown.active: host-scoped gauge showing whether new connection creation is currently suppressed by cooldown.pooledmailkit.pool.host.available: host-scoped gauge showing whether a host is currently eligible for new connection creation.pooledmailkit.pool.lease.duration: histogram showing how long pooled leases stay checked out before return or invalidation.pooledmailkit.pool.keepalive.failure.count: counter for idle keepalive NOOP failures before reuse.pooledmailkit.send.definitely_not_accepted.count: counter for failed sends that remained on the safe side of the SMTP ambiguity boundary.
The fuller telemetry contract, including existing pooledmailkit.pool.* and pooledmailkit.send.* metrics, is documented in docs/operation/metrics-and-logging.md.
PoolExhausted: bounded wait expired before a lease became available.RetryableBeforeSend: safe to retry on a fresh connection before the DATA ambiguity boundary.RetryableTemporaryFailure: SMTP temporary failure within retry budget.PermanentFailure: do not retry automatically.UnknownAfterData: delivery outcome may already be ambiguous; do not blindly retry.The repository currently includes:
smtp4dev integration testsMAILKIT_POOLING_RUN_STRESS=1docs/verification/Latest recorded verification also includes Docker-backed multi-host checks:
localhost:2525 to localhost:25263:1 host splitsmtp4dev stop and restoresmtp4dev stop/start cyclesRecent manual observations on Windows against smtp4dev:
ss, pooled 231 ms with TIME_WAIT 0 to 0 via ssThese are environment-specific measurements, not universal guarantees.
Benchmark and performance-oriented documentation should stay evidence-based and should only be updated when new measurements are collected on the intended target environment.
System.Diagnostics.Metrics with the current pooledmailkit.* contract; the internal implementation is intentional, while public customization and long-term compatibility policy remain intentionally narrow.
The intended telemetry contract is documented in docs/operation/metrics-and-logging.md.