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
<PackageReference Include="Josupeit.Mail.Milter" Version="0.1.11" />
<PackageVersion Include="Josupeit.Mail.Milter" Version="0.1.11" />
<PackageReference Include="Josupeit.Mail.Milter" />
paket add Josupeit.Mail.Milter --version 0.1.11
#r "nuget: Josupeit.Mail.Milter, 0.1.11"
#:package Josupeit.Mail.Milter@0.1.11
#addin nuget:?package=Josupeit.Mail.Milter&version=0.1.11
#tool nuget:?package=Josupeit.Mail.Milter&version=0.1.11
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:
ProtocolRequirementslists 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.EndOfBodyis deliberately0: it cannot be excluded.ActionRequirementslists 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 sayAddHeadershere.
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
| Product | Versions 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. |
-
net8.0
- Nito.AsyncEx.Coordination (>= 5.0.0 && < 6.0.0)
- System.Interactive.Async (>= 7.0.1 && < 8.0.0)
- System.IO.Pipelines (>= 8.0.0 && < 9.0.0)
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