Architecture
Pragmatic.Email is built around four core concepts: transports, middleware, MIME generation, and security signing.
Transport abstraction
Section titled “Transport abstraction”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:
| Transport | Purpose | Package |
|---|---|---|
SmtpTransport | Production SMTP with connection pooling | Pragmatic.Email |
NullTransport | No-op, always succeeds | Pragmatic.Email |
InMemoryTransport | Records emails for test assertions | Pragmatic.Email.Testing |
FileTransport | Writes .eml files to disk | Pragmatic.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).
Custom transports
Section titled “Custom transports”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>());Middleware pipeline
Section titled “Middleware pipeline”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.
Execution flow
Section titled “Execution flow”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) -> EmailResultSince 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);Built-in middleware order
Section titled “Built-in middleware order”| Order | Middleware | Purpose |
|---|---|---|
| 100 | DkimMiddleware | DKIM-Signature header |
| 200 | SmimeMiddleware | S/MIME detached signature |
Use Order < 100 for content transformations (branding, tracking pixels) so they run before signing.
MIME generation
Section titled “MIME generation”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.
Connection pooling
Section titled “Connection pooling”SmtpConnectionPool manages a bounded pool of SmtpConnection instances using Channel<SmtpConnection>:
- Acquire: Try to read from the channel. If available and usable, return it. Otherwise, create a new connection (bounded by
SemaphoreSlimatMaxConnections). - Usability check: A connection is usable if it is still connected, hasn’t exceeded
MaxMessagesPerConnection, and hasn’t been idle longer thanIdleTimeoutSeconds. - Release: If usable, write back to the channel. If the channel is full or the connection is stale, dispose it and release the semaphore.
Connection lifecycle
Section titled “Connection lifecycle”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 closeSMTP authentication
Section titled “SMTP authentication”SmtpAuthMethod.Auto selects the method based on configured credentials:
| Credentials present | Method selected |
|---|---|
OAuth2Token set | XOAUTH2 |
Username + Password set | PLAIN |
| Neither set | No authentication |
Explicit override via SmtpAuthMethod.Plain, SmtpAuthMethod.Login, or SmtpAuthMethod.XOAuth2.
Security
Section titled “Security”DKIM signing (RFC 6376)
Section titled “DKIM signing (RFC 6376)”DkimSigner generates DKIM-Signature headers using RSA-SHA256:
- Compute body hash (
bh=) using relaxed body canonicalization. - Build the DKIM-Signature template with
v=1; a=rsa-sha256; c=relaxed/relaxed; d=...; s=...; h=from:to:subject:date:message-id; bh=.... - Canonicalize signed headers using relaxed header canonicalization (lowercase name, compress whitespace, trim).
- Sign the canonicalized headers + DKIM-Signature template with the RSA private key.
- 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.
S/MIME signing
Section titled “S/MIME signing”SmimeSigner generates CMS/PKCS#7 detached signatures:
- Convert MIME body to bytes.
- Create
SignedCmswithdetached: true. - Sign with
CmsSignerusing the configuredX509Certificate2. - Return encoded CMS bytes (stored in
X-Pragmatic-Smime-Signatureheader as base64).
The signing certificate can optionally include the full chain (IncludeCertificate = true) for recipients to validate the signature.