Krugertech.CSharp-SMTP-Server 2.1.0-preview.2

This is a prerelease version of Krugertech.CSharp-SMTP-Server.
dotnet add package Krugertech.CSharp-SMTP-Server --version 2.1.0-preview.2
                    
NuGet\Install-Package Krugertech.CSharp-SMTP-Server -Version 2.1.0-preview.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Krugertech.CSharp-SMTP-Server" Version="2.1.0-preview.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Krugertech.CSharp-SMTP-Server" Version="2.1.0-preview.2" />
                    
Directory.Packages.props
<PackageReference Include="Krugertech.CSharp-SMTP-Server" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Krugertech.CSharp-SMTP-Server --version 2.1.0-preview.2
                    
#r "nuget: Krugertech.CSharp-SMTP-Server, 2.1.0-preview.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Krugertech.CSharp-SMTP-Server@2.1.0-preview.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Krugertech.CSharp-SMTP-Server&version=2.1.0-preview.2&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Krugertech.CSharp-SMTP-Server&version=2.1.0-preview.2&prerelease
                    
Install as a Cake Tool

Krugertech CSharp-SMTP-Server

Private fork of zabszk/CSharp-SMTP-Server v1.1.6 (released 23 Dec 2023). This repository is private and is not affiliated with or endorsed by the original author. It has been revised specifically to support chain of custody for received mail. The upstream library modified the messages it received; this fork preserves them byte for byte, adds ACK-gated delivery so 250 OK means your handler committed the message, and attaches a per-message evidence record describing how that message arrived. Along with extensive enhancements, updates and bug fixes.

Receive-only SMTP server library for C#, built for capturing messages together with the connection, authentication, and DNS facts observed while receiving them.

Documentation


What is ACK gating and why does it matter?

In the original library the DATA command fires the delivery handler in a background task and immediately returns 250 OK to the sending MTA — a pattern called fire-and-forget. This means the sending MTA considers the message delivered the moment the server acknowledges it, even though your application code has not yet finished (or even started) processing it.

This fork changes that contract:

  • The server awaits your delivery handler before sending any SMTP response.
  • Your handler returns a SmtpDeliveryResult that controls exactly what code the client sees.
  • 250 OK is sent only after your handler returns SmtpDeliveryResult.Ok(...).
  • A transient failure (451) tells the sender to retry later.
  • A permanent failure (554) tells the sender not to retry.
  • An unhandled exception in your handler produces a 451 so the sender retries rather than silently losing the message.

This makes the SMTP 250 OK a true durability guarantee: the sending MTA will not discard its copy of the message until your handler says it has been safely accepted.


Chain of custody

The original library modified the messages it received. This fork does not.

That is the change this fork exists for. Upstream, every DATA line was decoded to a .NET string and re-encoded as UTF-8 on its way into the body store, which had two consequences:

  • Bytes were replaced. A message body is an octet stream — it may be in any charset, or an unlabelled 8-bit body. The round trip through UTF-16 replaced every byte that was not valid UTF-8 with U+FFFD, stored as EF BF BD. What was archived was not what the sender transmitted.
  • Dot-stuffing was never undone. RFC 5321 §4.5.2 requires the receiver to strip the transparency dot a sender prefixes to any body line already starting with one. It was not implemented, so a composed line .text was archived as ..text.

Either one is enough to invalidate a DKIM signature over the body, because DKIM hashes the octets it is handed — so the archived copy could no longer be shown to be what its sender signed. The DATA path is now byte-primary end to end: the wire bytes reach the body store directly, and the transparency dot is stripped. What arrives is what is stored.

On top of that fidelity, three things support reconstructing a capture later:

  • A verifiable copy. GetOriginalDataStream() exposes exactly the octets accepted in DATA, with no server-added headers and no text or MIME conversion. Evidence.Message records their length and SHA-256, so a host can confirm independently that what it wrote to disk is what the server accepted.
  • The circumstances, recorded at receipt. The evidence record captures the connection, TLS, envelope, authentication results and the DNS answers behind them — facts observable only while the message is being received.
  • Contemporaneous key material. The DNS answers used by DKIM verification are kept in the sidecar, so a signature can be re-checked later against the key that actually signed it rather than a selector that may since have been rotated or retired.

ACK gating closes the remaining gap at the handoff: the sender does not discard its copy until your handler has committed this one.

The Integrity/ suite asserts this byte-exactness — folded headers, trailing whitespace, NUL and control bytes, 8-bit and invalid-UTF-8 bodies, leading-dot transparency, and a body crossing the spill boundary — with a negative control proving the comparison fails on a single flipped byte. See docs/server/testing.md.

What this is not. The component signs nothing and timestamps nothing with a third party. The evidence record is this server's own account of what it observed, and is only as trustworthy as the process and storage holding it — integrity comes from your durable-storage controls, not from the sidecar. Nor does the evidence identify a sender: a source-IP match says a connection came from a listed network, SPF and DKIM authenticate an identity and a signing domain, and header values are untrusted message content. The record supports a later decision rather than making one.

What this means if you are migrating from the original library

Original This fork
Task EmailReceived(MailTransaction transaction) Task<SmtpDeliveryResult> EmailReceivedAsync(MailTransaction transaction, CancellationToken cancellationToken = default)
Return value ignored — always sends 250 OK Return value determines the SMTP response sent to the client
Delivery runs in the background Server blocks the SMTP session until delivery completes
Exception in handler is silently swallowed Exception produces 451; sending MTA will retry
No DKIM verification DKIM is verified by default when a resolver is configured
SPF/DMARC failures always reject AuthenticationHandling chooses enforcement or observation; Enforce remains the default

You must rename and update the signature of your EmailReceived implementation. Version 2 also changes message-body lifetime and the address getter results; read docs/server/changelog.md before upgrading an existing consumer.

Note that ValidateDKIM defaults to true, so upgrading a resolver-configured deployment adds DKIM DNS lookups and signature verification inside the window before 250 is sent. MaximumDkimSignatures and DkimEvaluationTimeout bound that work; set ValidateDKIM = false to keep the previous behavior.


Supported features

  • TLS and STARTTLS
  • AUTH LOGIN and AUTH PLAIN
  • ACK-gated delivery (this fork)
  • Per-message evidence record and official JSON sidecar (this fork)
  • SPF, DKIM and DMARC evaluation, with DKIM verified against the exact received bytes
  • Observe-or-enforce authentication policy, so a failure can be recorded instead of rejected
  • Optional host-supplied source-network classification
  • Stream-backed DATA storage with a configurable memory threshold, aggregate budget and byte ceiling
  • RFC 1870 SIZE advertisement and RFC 5321 dot-unstuffing

Compatible with

  • RFC 822 (STANDARD FOR THE FORMAT OF ARPA INTERNET TEXT MESSAGES)
  • RFC 1869 (SMTP Service Extensions)
  • RFC 1870 (SMTP Service Extension for Message Size Declaration)
  • RFC 2554 (SMTP Service Extension for Authentication)
  • RFC 3463 (Enhanced Mail System Status Codes)
  • RFC 4616 (The PLAIN Simple Authentication and Security Layer (SASL) Mechanism)
  • RFC 4954 (SMTP Service Extension for Authentication)
  • RFC 5321 (SMTP Protocol)
  • RFC 6376 (DomainKeys Identified Mail) [Verification only]
  • RFC 7208 (Sender Policy Framework)
  • RFC 7372 (Email Authentication Status Codes)
  • RFC 7489 (Domain-based Message Authentication, Reporting, and Conformance (DMARC)) [Partially Supported]

Basic usage

Server setup

var server = new SMTPServer(new[]
{
    new ListeningParameters(IPAddress.IPv6Any, new ushort[] { 25, 587 }, new ushort[] { 465 }, true)
}, new ServerOptions { ServerName = "My SMTP Server", RequireEncryptionForAuth = false },
   new DeliveryInterface(),
   new LoggerInterface());

// With TLS certificate:
// }, new ServerOptions { ServerName = "My SMTP Server", RequireEncryptionForAuth = true },
//    new DeliveryInterface(), new LoggerInterface(),
//    new X509Certificate2("PathToCertWithKey.pfx"));

server.SetAuthLogin(new AuthenticationInterface());
server.SetFilter(new FilterInterface());
server.Start();

SmtpDeliveryResult

Your delivery handler returns one of three factory results:

// Message accepted — sends 250 OK to the client
SmtpDeliveryResult.Ok()
SmtpDeliveryResult.Ok("Message queued for delivery")

// Transient failure — sends 451; the sending MTA will retry
SmtpDeliveryResult.TemporaryFailure()
SmtpDeliveryResult.TemporaryFailure("Storage unavailable, try again later")

// Permanent failure — sends 554; the sending MTA will not retry
SmtpDeliveryResult.PermanentFailure()
SmtpDeliveryResult.PermanentFailure("Message policy violation")

Delivery interface

class DeliveryInterface : IMailDelivery
{
    public async Task<SmtpDeliveryResult> EmailReceivedAsync(
        MailTransaction transaction,
        CancellationToken cancellationToken = default)
    {
        try
        {
            // Do your durable work here — write to disk, insert to DB, etc.
            // The sending MTA will not receive 250 OK until this method returns.
            await SaveMessageAsync(transaction, cancellationToken);
            return SmtpDeliveryResult.Ok();
        }
        catch (StorageUnavailableException)
        {
            // Transient — ask the sender to retry
            return SmtpDeliveryResult.TemporaryFailure("Storage unavailable, please retry");
        }
        catch (PolicyViolationException ex)
        {
            // Permanent — do not retry
            return SmtpDeliveryResult.PermanentFailure(ex.Message);
        }
        // Any unhandled exception becomes a 451 automatically
    }

    // Called during RCPT TO — return DestinationAddressValid to accept the recipient
    public Task<UserExistsCodes> DoesUserExist(string emailAddress) =>
        Task.FromResult(emailAddress.EndsWith("@example.com", StringComparison.OrdinalIgnoreCase)
            ? UserExistsCodes.DestinationAddressValid
            : UserExistsCodes.BadDestinationSystemAddress);
}

Reading the message body

Use MailTransaction.GetOriginalDataStream() when persisting the exact message accepted through SMTP DATA:

public async Task<SmtpDeliveryResult> EmailReceivedAsync(
    MailTransaction transaction,
    CancellationToken cancellationToken = default)
{
    await using var source = transaction.GetOriginalDataStream();
    await using var destination = File.Create(GetArchivePath(transaction));
    await source.CopyToAsync(destination, cancellationToken);
    await destination.FlushAsync(cancellationToken);

    return SmtpDeliveryResult.Ok();
}

OriginalDataLength returns that stream's byte count without reading it. The original DATA view has SMTP dot transparency reversed and retains content CRLFs, but excludes the DATA command, terminating dot line, responses, and all server-added headers. No text or MIME conversion occurs.

Use GetBodyStream() and BodyLength instead when the stored message should include the server's prepended Received and Authentication-Results headers. RawBody materializes that augmented view as a complete UTF-16 string on every read and should be avoided for large messages. Both streams and parsed-message access are valid only during EmailReceivedAsync; large bodies may live in a temporary file that is released when the handler returns. Each opened stream has an independent read position, including when a large message has spilled to disk.

Per-message evidence

Every transaction that reaches your handler carries MailTransaction.Evidence: an immutable SmtpMessageEvidence record describing how the message arrived. It is complete and frozen before EmailReceivedAsync is called, and records the connection and negotiated TLS, the SMTP envelope, the exact raw length and SHA-256 of the DATA bytes, the effective storage policy, SPF/DKIM/DMARC results, the DNS answers those results were computed from, the source-network classification, and bounded observations of selected original headers.

The record distinguishes observed facts from computed results from untrusted message content. A header value is what the sender wrote; only the authentication results say whether anything was verified. Reading Evidence before DATA capture and evaluation complete throws InvalidOperationException.

Storing the sidecar beside the message is what makes the capture reconstructable later — the raw .eml alone does not record how it arrived, and the DNS answers behind each authentication result cannot be recovered after the fact. See Chain of custody for what that does and does not establish.

SmtpEvidenceJson writes the supported versioned JSON representation. Pair it with the raw message to produce the two files a later processor can consume:

public async Task<SmtpDeliveryResult> EmailReceivedAsync(
    MailTransaction transaction,
    CancellationToken cancellationToken = default)
{
    var captureId = transaction.SmtpTransactionId;

    await using (var source = transaction.GetOriginalDataStream())
    await using (var eml = File.Create($"{captureId}.eml"))
        await source.CopyToAsync(eml, cancellationToken);

    await using (var sidecar = File.Create($"{captureId}.evidence.json"))
        await SmtpEvidenceJson.WriteAsync(sidecar, transaction.Evidence, cancellationToken);

    return SmtpDeliveryResult.Ok();
}

Unlike the body streams, the evidence object is safe to retain after the handler returns — Clone() shares the same instance rather than rebuilding it. Serialize it inside the callback anyway when you are writing it beside the message, so both files commit before you return Ok.

Do not serialize the CLR object graph with your own JSON serializer. SmtpEvidenceJson owns property order, enum spelling, UTC formatting and null semantics, and is the only representation covered by the schema's compatibility guarantees. The normative field-by-field contract, including every size and collection bound, is docs/server/smtp-evidence-schema-v1.md.

Logger interface

class LoggerInterface : ILogger
{
    public void LogError(string text) => Console.WriteLine("[LOG] " + text);
}

Authentication interface

class AuthenticationInterface : IAuthLogin
{
    // 123 is the password for all users — NOT SECURE, DEMO ONLY
    public Task<bool> AuthPlain(string authorizationIdentity, string authenticationIdentity,
        string password, EndPoint remoteEndPoint, bool secureConnection) =>
        Task.FromResult(password == "123");

    public Task<bool> AuthLogin(string login, string password,
        EndPoint remoteEndPoint, bool secureConnection) =>
        Task.FromResult(password == "123");
}

Filter interface

class FilterInterface : IMailFilter
{
    // Allow all connections
    public Task<SmtpResult> IsConnectionAllowed(EndPoint ep) =>
        Task.FromResult(new SmtpResult(SmtpResultType.Success));

    // Block .invalid TLD
    public Task<SmtpResult> IsAllowedSender(string source, EndPoint ep) =>
        Task.FromResult(source.TrimEnd().EndsWith(".invalid")
            ? new SmtpResult(SmtpResultType.PermanentFail)
            : new SmtpResult(SmtpResultType.Success));

    // Reject SPF Softfail
    public Task<SmtpResult> IsAllowedSenderSpfVerified(string source, EndPoint? ep,
        string? username, ValidationResult spfResult) =>
        Task.FromResult(spfResult == ValidationResult.Softfail
            ? new SmtpResult(SmtpResultType.PermanentFail)
            : new SmtpResult(SmtpResultType.Success));

    // Block emails addressed to root@*
    public Task<SmtpResult> CanDeliver(string source, string destination,
        bool authenticated, string? username, EndPoint? ep) =>
        Task.FromResult(destination.TrimStart().StartsWith("root@", StringComparison.OrdinalIgnoreCase)
            ? new SmtpResult(SmtpResultType.PermanentFail)
            : new SmtpResult(SmtpResultType.Success));

    // Reject messages containing "spam"
    public Task<SmtpResult> CanProcessTransaction(MailTransaction transaction) =>
        Task.FromResult(transaction.GetMessageBody() != null &&
                        transaction.GetMessageBody()!.Contains("spam", StringComparison.OrdinalIgnoreCase)
            ? new SmtpResult(SmtpResultType.PermanentFail)
            : new SmtpResult(SmtpResultType.Success));
}

Office 365 journaling relay profile

Journal reports are compliance records. A permanent SMTP rejection can destroy the only remaining copy, while an unlimited internet-facing receiver is also unsafe. Use a finite limit above the largest report Exchange Online can submit and disable sender authentication checks that do not describe the original journaled message:

var options = new ServerOptions(
    validateSPF: true,
    validateDMARC: true,
    resolverMode: DnsResolverMode.System,
    dnsServerEndpoints: null)
{
    ServerName = "journal.example.com",
    MessageCharactersLimit = 200u * 1024 * 1024,
    MessageStoredBytesLimit = 210L * 1024 * 1024,
    MessageMemoryThresholdBytes = 4L * 1024 * 1024,
    AggregateMessageMemoryLimitBytes = 64L * 1024 * 1024,
    MessageTemporaryDirectory = "/var/lib/smtp-spill",
    MessageStorageConfigurationId = "journal-prod-v1",
    RecipientsLimit = 1,
    AuthenticationHandling = AuthenticationHandling.Observe,
    SpfEvaluationTimeout = TimeSpan.FromSeconds(10),
    DkimEvaluationTimeout = TimeSpan.FromSeconds(10),
    PostDataEvidenceTimeout = TimeSpan.FromSeconds(15),
    DeliveryTimeout = TimeSpan.FromMinutes(2),
};

For this deployment:

  • Keep MessageCharactersLimit finite. 0 is unlimited; 200 MB provides headroom above Exchange Online's configurable maximum of 150 MB while still bounding storage. Externally routed messages can have a lower effective limit because of transport encoding; see Microsoft's Exchange Online limits. Despite the historical property name, the counter measures stored DATA bytes after dot-unstuffing and excludes CRLF, so it is not the exact RFC 1870 wire-octet count.
  • RecipientsLimit = 1 keeps the private ingress route unambiguous. The original message's recipient list is inside the journal report, not in multiple archive-envelope recipients. Validate this assumption against production traffic before rollout.
  • AuthenticationHandling.Observe evaluates SPF, DKIM and DMARC but does not reject a safely captured report solely for an authentication result. An explicit IMailFilter rejection still applies.
  • MessageStoredBytesLimit is the exact safety ceiling and includes stored CRLF bytes. The memory thresholds select memory versus transient-file backing; the spill directory must exist or be creatable and writable by the service account.
  • Do not install a rejecting IMailFilter on the journaling listener.
  • Return TemporaryFailure or throw when archive storage is unavailable. Never return Ok until the record is durably stored.
  • During EmailReceivedAsync, persist transaction.GetOriginalDataStream() and call SmtpEvidenceJson.WriteAsync(sidecar, transaction.Evidence, cancellationToken) for the matching .evidence.json file. Both callback-scoped streams must be consumed before returning.
  • Make storage idempotent. Current shutdown stops active sessions rather than draining them, so a commit followed by a lost 250 can cause the sender to retry. See docs/server/known-issues.md.
  • DeliveryTimeout bounds how long a delivery handler may hold a session open before the message is answered 451 4.4.7 instead of accepted. It requires the same idempotent storage as above — a handler that commits the record and only afterwards observes the expired deadline still gets its Ok discarded in favour of 451, so the sender retries a report that is already archived. It also requires the handler's write path to observe its CancellationToken; otherwise the deadline cannot make the handler return any sooner and the session is not actually bounded. Leave it at its default (TimeSpan.Zero, disabled) unless both hold. See docs/server/known-issues.md.

The heavy test tier validates 150 MB delivery, concurrent large messages, bounded memory behavior, and the relay-specific defaults; see docs/server/testing.md.


Third-party services and libraries

  • By default this library resolves SPF and DMARC through the machine's own configured name servers (DnsResolverMode.System). No public resolver is substituted: earlier versions silently fell back to Cloudflare 1.1.1.1, which sent the sending domains of all inbound mail to a third-party operator the deployment never chose. Pass an endpoint for DnsResolverMode.Explicit, or use DnsResolverMode.Disabled to switch validation off entirely.
  • Responses are cached in process (TTL-aware, 5 s floor / 5 min ceiling). Transient DNS failures are deliberately not cached.
  • This library uses DnsClient.NET 1.8.0 by Michael Conrad, licensed under the Apache License 2.0.
  • By default this library downloads the Public Suffix List managed by the Mozilla Foundation from GitHub (licensed under MPL v2.0). The URL can be changed in ServerOptions. The list is not downloaded when the resolver mode is Disabled.
  • This library uses MimeKit 4.17.0 by the .NET Foundation and Contributors, licensed under the MIT License.

Generating a PFX from PEM keys

On Windows, a certificate returned directly by CertificateRequest.CreateSelfSigned() may use an ephemeral private key that SChannel cannot use for server TLS. Export and re-import it as PFX, or generate a PFX from PEM keys:

openssl pkcs12 -export -in public.pem -inkey private.pem -out CertWithKey.pfx
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.1.0-preview.2 52 9/12/2026
2.0.0-krugertech.4 72 9/8/2026
2.0.0-krugertech.3 66 9/8/2026
2.0.0-krugertech.2 69 9/8/2026
1.1.6-krugertech.3 80 6/4/2026
1.1.6-krugertech.2 73 6/4/2026
1.1.6-krugertech.1 75 6/4/2026