MailKit.Pooling

SMTP connection control for .NET on top of MailKit

Reuse SMTP connections safely, avoid per-request client churn, bound waiting, classify failures, and suppress reconnect storms.

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

What It Does Not Do

  • No template rendering
  • No notification orchestration
  • No durable queue or guaranteed delivery layer
  • No non-SMTP transport abstraction

Quick Start

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.

Usage Model

Acquire

The pool leases a single SMTP connection to one caller at a time.

Send

ISmtpSender sends one message through that lease and returns or invalidates the connection.

Recover

Failures are classified. Retry, host-level cooldown, and failover decisions follow the classification and stage.

Intended Public Surface

The NuGet-facing API is intentionally narrow. Most pool, factory, adapter, clock, metrics, and classifier implementation types are internal.

Send Failures

ISmtpSender.SendAsync() returns SmtpSendResult on success. On failure, the main application-facing exception is SmtpSendFailedException.

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.
}

Thread Safety

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.

Intended Use Cases

Good Fits

  • ASP.NET Core APIs that send transactional email inside request handling
  • background workers or outbox processors that send steady SMTP traffic
  • systems where SMTP connection churn causes TIME_WAIT or reconnect pressure
  • multi-host SMTP relay setups that need application-side priority and weight

Not The Goal

  • template rendering or notification orchestration
  • durable delivery guarantees or deduplication storage
  • non-SMTP transport abstraction such as Graph or SES APIs
  • bulk marketing delivery platforms

The fuller boundary is documented in docs/design/use-cases.md.

Choosing Option Values

The Quick Start values are starting points, not universal defaults. Tune them from concurrency, SMTP latency, outage behavior, and caller wait tolerance.

The fuller tuning guide is documented in docs/design/option-tuning.md.

Important Options

Option Purpose
MinPoolSizeWarm idle connections to keep ready.
MinPoolRefillDelayDelay before restoring MinPoolSize after discarded connections.
MaxPoolSizeHard upper bound for simultaneously live connections.
AcquireTimeoutMaximum wait for a lease before explicit failure.
IdleTimeoutDiscard stale idle connections instead of keeping them forever.
KeepAliveIntervalIssue NOOP on stale idle connections before reuse.
ConnectTimeoutBound connect duration.
AuthenticateTimeoutBound authenticate duration.
SmtpSendTimeoutBound a single SMTP send operation.
ReconnectCooldownSuppress immediate connection recreation after failure.
MaxRetryAttemptsRetry budget for explicitly retryable failures only.
RetryBaseDelayBase delay used for retry backoff.
EnableMetricsEmit metrics through the configured metrics implementation.
SmtpHostOptions.PriorityPrefer lower-priority hosts first for active-standby failover.
SmtpHostOptions.WeightDistribute connections across hosts that share the same priority.

Telemetry Highlights

The fuller telemetry contract, including existing pooledmailkit.pool.* and pooledmailkit.send.* metrics, is documented in docs/operation/metrics-and-logging.md.

Failure Semantics

Verification Snapshot

The repository currently includes:

Latest recorded verification also includes Docker-backed multi-host checks:

Recent manual observations on Windows against smtp4dev:

These 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.

Current Limits

The intended telemetry contract is documented in docs/operation/metrics-and-logging.md.