Skip to content

Architecture

Pragmatic.Email is built around four core concepts: transports, middleware, MIME generation, and security signing.

IEmailTransport is the pluggable interface for delivering emails:

public interface IEmailTransport : IAsyncDisposable
{
string Name { get; }
Task<EmailResult> SendAsync(EmailMessage message, CancellationToken ct = default);
}

The library ships with three implementations:

TransportPurposePackage
SmtpTransportProduction SMTP with connection poolingPragmatic.Email
NullTransportNo-op, always succeedsPragmatic.Email
InMemoryTransportRecords emails for test assertionsPragmatic.Email.Testing
FileTransportWrites .eml files to diskPragmatic.Email.Testing

Only one transport is active at a time. The last registered transport wins. NullTransport is the default when no transport is explicitly configured (via TryAddSingleton).

Implement IEmailTransport and register via the builder:

public sealed class SesTransport : IEmailTransport
{
public string Name => "SES";
public async Task<EmailResult> SendAsync(EmailMessage message, CancellationToken ct)
{
// Send via AWS SES SDK
return EmailResult.Succeeded(message.MessageId);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
services.AddPragmaticEmail(email => email.UseTransport<SesTransport>());

IEmailMiddleware provides an ordered chain-of-responsibility pipeline. Each middleware can transform the message, add headers, or short-circuit the pipeline:

public interface IEmailMiddleware
{
int Order => 0;
Task<EmailMessage> ProcessAsync(
EmailMessage message,
Func<EmailMessage, Task<EmailMessage>> next,
CancellationToken ct);
}

The EmailPipeline sorts middleware by Order (ascending) and chains them. Each middleware calls next(message) to pass to the next step, or returns directly to short-circuit.

EmailSender.SendAsync(message)
-> EmailPipeline.ExecuteAsync(message)
-> Middleware[0].ProcessAsync (Order=10, e.g. BrandingMiddleware)
-> Middleware[1].ProcessAsync (Order=100, DkimMiddleware)
-> Middleware[2].ProcessAsync (Order=200, SmimeMiddleware)
-> returns processed message
-> IEmailTransport.SendAsync(processedMessage)
-> EmailResult

Since EmailMessage is an immutable record, middleware creates copies using with {}:

var enriched = message with
{
Headers = new Dictionary<string, string>(message.Headers)
{
["X-Campaign-Id"] = campaignId,
},
};
return next(enriched);
OrderMiddlewarePurpose
100DkimMiddlewareDKIM-Signature header
200SmimeMiddlewareS/MIME detached signature

Use Order < 100 for content transformations (branding, tracking pixels) so they run before signing.

MimeWriter is an internal static class that generates RFC 2045/2046 compliant MIME from EmailMessage. It handles the full multipart hierarchy:

multipart/mixed
├── multipart/related
│ ├── multipart/alternative
│ │ ├── text/plain
│ │ └── text/html
│ └── inline image (Content-ID)
└── attachment (base64)

Key behaviors:

  • Multipart selection: The writer picks the right multipart structure automatically based on what the message contains (text only, HTML only, both, attachments, inline images).
  • RFC 2047 B-encoding: Non-ASCII header values are encoded as =?utf-8?B?...?=.
  • SMTP dot-stuffing: Lines starting with . are prefixed with .. to avoid premature DATA termination.
  • Base64 line wrapping: Binary attachments are encoded at 76-character line width per RFC 2045.
  • Boundary generation: Uses Guid.CreateVersion7() for unique MIME boundaries.

SmtpConnectionPool manages a bounded pool of SmtpConnection instances using Channel<SmtpConnection>:

  1. Acquire: Try to read from the channel. If available and usable, return it. Otherwise, create a new connection (bounded by SemaphoreSlim at MaxConnections).
  2. Usability check: A connection is usable if it is still connected, hasn’t exceeded MaxMessagesPerConnection, and hasn’t been idle longer than IdleTimeoutSeconds.
  3. Release: If usable, write back to the channel. If the channel is full or the connection is stale, dispose it and release the semaphore.
New connection:
TCP connect -> Read 220 greeting -> EHLO -> Parse capabilities
-> STARTTLS (if UseSsl && server supports it)
-> Re-EHLO (after TLS upgrade, capabilities may change)
-> AUTH (PLAIN / LOGIN / XOAUTH2, auto-detected)
Per message:
MAIL FROM:<sender> -> RCPT TO:<recipient>... -> DATA -> MIME body + "\r\n." -> 250 OK
End of life (MaxMessagesPerConnection reached or idle timeout):
QUIT -> TCP close

SmtpAuthMethod.Auto selects the method based on configured credentials:

Credentials presentMethod selected
OAuth2Token setXOAUTH2
Username + Password setPLAIN
Neither setNo authentication

Explicit override via SmtpAuthMethod.Plain, SmtpAuthMethod.Login, or SmtpAuthMethod.XOAuth2.

DkimSigner generates DKIM-Signature headers using RSA-SHA256:

  1. Compute body hash (bh=) using relaxed body canonicalization.
  2. Build the DKIM-Signature template with v=1; a=rsa-sha256; c=relaxed/relaxed; d=...; s=...; h=from:to:subject:date:message-id; bh=....
  3. Canonicalize signed headers using relaxed header canonicalization (lowercase name, compress whitespace, trim).
  4. Sign the canonicalized headers + DKIM-Signature template with the RSA private key.
  5. Append the base64 signature as b=.

Canonicalization follows RFC 6376 section 3.4:

  • Relaxed header: lowercase name, unfold continuation lines, compress whitespace to single space, trim.
  • Relaxed body: strip trailing whitespace per line, compress whitespace, remove trailing empty lines.

SmimeSigner generates CMS/PKCS#7 detached signatures:

  1. Convert MIME body to bytes.
  2. Create SignedCms with detached: true.
  3. Sign with CmsSigner using the configured X509Certificate2.
  4. Return encoded CMS bytes (stored in X-Pragmatic-Smime-Signature header as base64).

The signing certificate can optionally include the full chain (IncludeCertificate = true) for recipients to validate the signature.