Josupeit.Mail.Milter 0.1.11

Prefix Reserved
dotnet add package Josupeit.Mail.Milter --version 0.1.11
                    
NuGet\Install-Package Josupeit.Mail.Milter -Version 0.1.11
                    
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="Josupeit.Mail.Milter" Version="0.1.11" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Josupeit.Mail.Milter" Version="0.1.11" />
                    
Directory.Packages.props
<PackageReference Include="Josupeit.Mail.Milter" />
                    
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 Josupeit.Mail.Milter --version 0.1.11
                    
#r "nuget: Josupeit.Mail.Milter, 0.1.11"
                    
#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 Josupeit.Mail.Milter@0.1.11
                    
#: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=Josupeit.Mail.Milter&version=0.1.11
                    
Install as a Cake Addin
#tool nuget:?package=Josupeit.Mail.Milter&version=0.1.11
                    
Install as a Cake Tool

Josupeit.Mail.Milter

An implementation of the Milter protocol - the mail filter protocol that Postfix and Sendmail speak - for .NET.

An MTA connects to a milter over a socket and streams the SMTP conversation to it as it happens: the client connecting, HELO, MAIL FROM, each RCPT TO, every header, the body. After each event the filter answers, and the answer decides what the MTA does next - carry on, accept the message outright, reject it, defer it, or discard it silently. At the end of the body the filter may also rewrite the message: add or change headers, add or remove recipients, replace the body, or quarantine the whole thing.

This package lets you write that filter in C#. You implement the callbacks for the events you care about; the library handles the framing, the option negotiation and the protocol state machine the MTA expects.

dotnet add package Josupeit.Mail.Milter

Targets net8.0. Speaks wire version 2 of the protocol, which every current Postfix and Sendmail accepts.

Writing a filter

A filter is a partial class implementing IMilterContext. Every callback has a default implementation that answers "continue", so you override only the events you actually want.

using Josupeit.Mail.Milter;
using Action = Josupeit.Mail.Milter.Action;

internal sealed partial class RejectBigSenders : IMilterContext
{
    public ValueTask<CommitAction> OnSender(SenderRecipientInformation info, CancellationToken cancellationToken)
    {
        // AddressString is the raw envelope argument, angle brackets and all: "<user@example.com>".
        return info.AddressString.EndsWith("@spam.example.com>", StringComparison.OrdinalIgnoreCase)
            ? new(CommitActions.Reject)
            : new(CommitActions.Continue);
    }

    public async IAsyncEnumerable<Action> OnEndOfBody()
    {
        yield return ModificationActions.AddHeader("X-Filtered-By", "RejectBigSenders");
        yield return CommitActions.Accept;
    }
}

Then host it on a socket:

using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

socket.Bind(new IPEndPoint(IPAddress.Loopback, 8894));
socket.Listen();

var milter = new Milter<RejectBigSenders>(socket);

milter.Start();

// ... run until shutdown ...

// Stop waits for the sessions still in flight, so a message being filtered right now is not cut off.
await milter.Stop();

and point the MTA at it - for Postfix, in main.cf:

smtpd_milters = inet:127.0.0.1:8894

Pass a factory when the filter needs dependencies:

var milter = new Milter<RejectBigSenders>(socket, () => new RejectBigSenders(logger));

Without one the context comes from Activator.CreateInstance, which requires a public parameterless constructor.

Callbacks

Callback Event Returns
OnConnectionEstablished A client connected; carries the hostname and endpoint CommitAction
OnClientHello HELO / EHLO CommitAction
OnSender MAIL FROM CommitAction
OnRecipient RCPT TO, once per recipient CommitAction
OnHeader One message header CommitAction
OnEndOfHeaders The header block ended CommitAction
OnBodyChunk A slice of the body, as ReadOnlySpan<byte> CommitAction
OnEndOfBody The message is complete IAsyncEnumerable<Action>
OnDefineMacro The MTA supplied macro values nothing

The body arrives in chunks, not in one piece - Postfix caps each at 64 KiB - so a filter that needs the whole body has to accumulate it.

A context instance serves one message, not one connection. Postfix keeps a connection open across several messages, and the library builds a fresh context for each, so per-message state in a field is safe and state that must outlive a message is not.

Actions

Every callback except OnEndOfBody returns exactly one commit action, built from CommitActions:

Action Effect
Continue Carry on with the next event
Accept Accept the message, skip the remaining callbacks
Reject Reject with a permanent error (5xx)
Defer Reject with a temporary error (4xx), so the sender retries
Discard Accept on the wire, then silently drop the message
Reply(code, text) Reject with a reply you choose; the code must be 4xx or 5xx

OnEndOfBody returns a sequence instead: zero or more modification actions from ModificationActions, optionally interleaved with ProgressAction.Instance, and then exactly one commit action to end it. That shape is enforced - yielding nothing, forgetting the commit action, or yielding anything after it throws InvalidOperationException.

Modification Effect
AddHeader(name, value) Append a header
ChangeHeader(name, value, index) Replace the index-th occurrence of a header
DeleteHeader(name, index) Remove the index-th occurrence of a header
AddRecipient(address) Add an envelope recipient
DeleteRecipient(address) Remove an envelope recipient
ReplaceBody(stream, leaveOpen) Replace the message body
QuarantineMessage(reason) Hold the message in the MTA's queue instead of delivering it

ProgressAction.Instance decides nothing; it only tells the MTA that the filter is still working, so the reply timeout restarts. Yield it from a long-running OnEndOfBody to avoid being timed out.

Every action is built through those two factories, and there is no other way: the records have internal constructors and get-only properties, so neither new nor a with expression can get past the validation. Control characters are rejected in every outbound string, and header names must be RFC 5322 ftext - which is what stops a filter from splicing a second header, or a whole extra reply, into the message through an unsanitised value.

Declaring requirements

The two static properties on IMilterContext are part of the handshake with the MTA, not decoration:

  • ProtocolRequirements lists the events you want delivered. The MTA skips the rest, so narrowing this is a real saving - a filter that only looks at senders never has the body streamed to it. EndOfBody is deliberately 0: it cannot be excluded.
  • ActionRequirements lists the modifications you intend to make. The MTA rejects any modification it was not warned about during negotiation, so a filter that adds a header must say AddHeaders here.

The package writes both for you. That is why the example above declares neither, and why the class is partial: a source generator reads which callbacks you overrode and which actions your code builds, and emits the two properties to match. Overriding OnSender produces ProtocolRequirements.Sender; calling ModificationActions.AddHeader anywhere in the filter produces ActionRequirements.AddHeaders.

It follows the actions across method and class boundaries, and it takes the union over every branch - a modification behind an if still has to be negotiated, whether or not that if is ever true. What it cannot follow is an action arriving from outside the compilation: from an interface, an abstract member, or another assembly. Rather than guess - too few makes the MTA refuse the modification, too many hands the filter rights over the message it does not need - it reports MILT002 and leaves the property to you.

Declaring either property by hand always wins, and is the way out of MILT002 as well as the way to narrow what was derived:

internal sealed partial class RejectBigSenders : IMilterContext
{
    public static ActionRequirements ActionRequirements => ActionRequirements.AddHeaders;

    // ...
}

Bounding the connection

MilterOptions caps two things that are otherwise attacker-controlled:

var milter = new Milter<RejectBigSenders>(socket, new MilterOptions
{
    MaxFrameLength = 1024 * 1024,   // default; upper bound on a single frame's length prefix
    MaxConcurrentSessions = 1024,   // default; connections past this are closed, not queued
});

Going further

The wire layer is public and non-sealed on purpose - LinkReader/LinkWriter, RequestParser/ResponseFormatter, the packet hierarchies and their visitors - so a consumer who needs to reach below the callback surface can subclass or substitute rather than fork.

License

EUPL-1.2

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  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 was computed.  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. 
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
0.1.11 74 8/19/2026

- fix(build): packs only the library, so the release cannot push the wrong thing
- fix(build): moves off System.Linq.Async to keep net10 consumers unambiguous
- build: moves the project from GitHub to Codeberg
- build: adds a 1024x1024 icon for the Codeberg repository avatar
- build: gives the package an icon
- build: marks MILT001 and MILT002 as shipped in 0.1
- build: polyfills netstandard2.0 with PolySharp
- build: drops the generator's assembly reference from the library
- docs: documents the generated requirement properties
- build: ships the generator as an analyzer inside the package
- feat(generator): derives both requirement properties from the filter's code
- docs: documents the authoring surface
- chore: adds the licence headers and the hook that maintains them
- build: turns the library into a publishable package
- build: moves the projects into src, test and samples
- build: makes the style rules fail the build instead of tinting the editor