Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
878db12
Preserve UTF-8 SMTP domains
vhafdal Jul 15, 2026
28d6009
Fix package version metadata
vhafdal Jul 15, 2026
62beda3
Support RCPT ESMTP parameters
vhafdal Jul 15, 2026
5a8da46
Merge pull request #3 from vhafdal/codex/rcpt-esmtp-parameters
vhafdal Jul 15, 2026
d3e17d3
Merge pull request #2 from vhafdal/codex/package-version-11.1.0
vhafdal Jul 15, 2026
20194a9
Merge pull request #1 from vhafdal/codex/smtputf8-idn-support
vhafdal Jul 15, 2026
0b6e845
SS-3 Fix PROXY TCP version validation
vhafdal Jul 15, 2026
9f31367
SS-2 Tighten SMTP parser syntax handling
vhafdal Jul 15, 2026
28fc03a
SS-4 Add streaming DATA message store path
vhafdal Jul 15, 2026
5df5db2
SS-4 Add DATA streaming benchmark
vhafdal Jul 15, 2026
40f75cb
SS-5 Add verb-based SMTP parser dispatch
vhafdal Jul 15, 2026
be2a4a4
SS-6 Wire network buffer size option
vhafdal Jul 15, 2026
2b63582
SS-7 Reduce AUTH and EHLO allocations
vhafdal Jul 15, 2026
f3284b4
SS-8 Add DSN envelope parameters
vhafdal Jul 15, 2026
f46a0ed
Merge SS-2 parser strictness into nexer-prod
vhafdal Jul 15, 2026
324c2ad
Merge SS-3 PROXY validation into nexer-prod
vhafdal Jul 15, 2026
56470eb
Merge SS-4 streaming DATA into nexer-prod
vhafdal Jul 15, 2026
bd359d2
Merge SS-5 parser dispatch into nexer-prod
vhafdal Jul 15, 2026
a4f1be4
Merge SS-6 network buffer size into nexer-prod
vhafdal Jul 15, 2026
dae1987
Merge SS-7 AUTH and EHLO allocation work into nexer-prod
vhafdal Jul 15, 2026
0975627
Merge SS-8 DSN envelope parameters into nexer-prod
vhafdal Jul 15, 2026
6dde42d
SS-9 Add enhanced SMTP status codes
vhafdal Jul 15, 2026
b25349d
Merge SS-9 enhanced status codes into nexer-prod
vhafdal Jul 15, 2026
49b0202
SS-10 Add HELP VRFY EXPN policy hooks
vhafdal Jul 15, 2026
d8e675b
Merge SS-10 HELP VRFY EXPN hooks into nexer-prod
vhafdal Jul 15, 2026
09cd794
SS-11 Add CHUNKING BDAT support
vhafdal Jul 15, 2026
4486ee5
Merge SS-11 CHUNKING BDAT support into nexer-prod
vhafdal Jul 15, 2026
54f43bd
SS-12 Add SMTP hot-path benchmarks
vhafdal Jul 15, 2026
57ac17d
Merge SS-12 SMTP hot-path benchmarks into nexer-prod
vhafdal Jul 15, 2026
6194168
SS-13 Reduce buffered BDAT copy
vhafdal Jul 15, 2026
ede7986
Merge SS-13 buffered BDAT copy reduction into nexer-prod
vhafdal Jul 15, 2026
3b84756
SS-14 Support multi-segment SMTP parser input
vhafdal Jul 15, 2026
468bce0
Merge SS-14 parser multi-segment support into nexer-prod
vhafdal Jul 15, 2026
eeca747
SS-15 Separate command line length limit
vhafdal Jul 15, 2026
de42da6
Merge SS-15 command line limit split into nexer-prod
vhafdal Jul 15, 2026
fbe481c
SS-16 Document and test EHLO extension advertisement
vhafdal Jul 15, 2026
080a424
Merge SS-16 EHLO extension conformance into nexer-prod
vhafdal Jul 15, 2026
d51445d
SS-17 add configurable SMTP feature surface
vhafdal Jul 15, 2026
8833c19
Merge SS-17 configurable SMTP features into nexer-prod
vhafdal Jul 15, 2026
7d5da2a
SS-17 bump corrected package to 11.2.1
vhafdal Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
# Change Log

## v11.2.1

- Fixed: Repacked the SS-17 feature surface from the correct release build so the package includes configurable SMTP extensions, session policy callbacks, and safe command snapshots.

## v11.2.0

- Added: Configurable SMTP extension surface for SMTPUTF8, DSN, and CHUNKING. Extensions remain enabled by default for compatibility and can be disabled through `SmtpServerOptionsBuilder.Extensions(...)`.
- Added: Session policy callbacks for accepted connections and EHLO/HELO identity checks through `SmtpServerOptionsBuilder.SessionPolicy(...)`.
- Added: Safe command snapshots on command events so AUTH arguments can be logged without exposing credentials.

```cs
var options = new SmtpServerOptionsBuilder()
.ServerName("My mail server")
.Extensions(extensions => extensions
.SmtpUtf8(false)
.Dsn(false)
.Chunking(false))
.SessionPolicy(policy => policy
.OnConnectionAccepted((context, token) => Task.FromResult(SmtpResponse.Ok))
.OnHelo((context, name, token) => Task.FromResult(SmtpResponse.Ok)));
```

## v11.1.0

- Added: Configuration option to define the maximum allowed message size.
- Added: Support for custom SMTP greeting messages.
- Added: DSN envelope parameter support for MAIL and RCPT commands.
- Added: Enhanced status code support for common SMTP responses.
- Added: HELP, VRFY, and EXPN command handling with conservative default VRFY/EXPN responses and an opt-in `ISmtpCommandPolicy` extension point.
- Added: CHUNKING/BDAT support with multi-chunk streaming, LAST chunk completion, and strict size-limit enforcement.
- Added: `MaxCommandLineLength` option for SMTP command and AUTH continuation line limits, separate from message body size limits.
- Fixed: NetworkBufferSize now controls the stream read buffer used by the SMTP connection pipe.
- Improved: Reduced allocations in EHLO response generation and AUTH credential parsing.
- Improved: Optimized protection against excessively long text segments to enhance stability and performance.
- Improved: Documented ESMTP extension advertisement behavior and added tests for conditional EHLO SIZE, STARTTLS, and AUTH advertisement.

```cs
var options = new SmtpServerOptionsBuilder()
Expand Down
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,59 @@ SmtpServer currently supports the following extensions:
- PIPELINING
- 8BITMIME
- SMTPUTF8
- DSN
- CHUNKING
- AUTH PLAIN LOGIN

PIPELINING is advertised because the session reads queued command lines sequentially from the pipe. Commands are still validated against the SMTP state machine in order.

8BITMIME is advertised because message content is accepted and stored as bytes without 7-bit rewriting. Applications remain responsible for MIME validation or normalization in their message store if they need stricter policy.

SMTPUTF8 support covers UTF-8 mailbox/domain parsing and message acceptance. Applications remain responsible for downstream delivery compatibility and storage policy.

SIZE is advertised when `MaxMessageSize(...)` is configured. MAIL `SIZE` parameters are checked before accepting the transaction, and strict DATA/BDAT body limits are enforced while reading content.

DSN support parses and exposes `RET`, `ENVID`, `NOTIFY`, and `ORCPT` envelope parameters. Applications remain responsible for generating and delivering delivery status notifications from their message store or mailbox filter code.

CHUNKING support accepts `BDAT <size> [LAST]` message content without DATA dot-stuffing. Multi-chunk messages are stored only after the `LAST` chunk; strict maximum message size limits are enforced across the full BDAT transfer.

SMTPUTF8, DSN, and CHUNKING are enabled by default for compatibility. Applications can disable advertised and accepted support explicitly:

```cs
var options = new SmtpServerOptionsBuilder()
.ServerName("localhost")
.Extensions(extensions => extensions
.SmtpUtf8(false)
.Dsn(false)
.Chunking(false))
.Build();
```

STARTTLS is advertised only when the endpoint has a certificate and the current connection is not already secure.

AUTH PLAIN LOGIN is advertised only when an authenticator is registered and the current connection is secure or the endpoint explicitly allows insecure authentication.

SMTP replies include enhanced status codes for common success, syntax, authentication, mailbox, size, bad sequence, and transaction failure responses. AUTH continuation challenges are left unchanged for SASL compatibility.

HELP is implemented for basic command discovery. VRFY and EXPN are accepted, but the default policy avoids mailbox or mailing list enumeration and returns conservative `252` responses. Applications that intentionally disclose verification or expansion results can register `ISmtpCommandPolicy` or `ISmtpCommandPolicyFactory`.

## Configuration Limits

`MaxMessageSize(length, handling)` applies to DATA and BDAT message content. `MaxCommandLineLength(length)` applies separately to SMTP command lines and AUTH continuation lines; the default is 4096 bytes, excluding the terminating CRLF.

## Session Policy

Connection and HELO/EHLO policy can be configured without replacing command handlers:

```cs
var options = new SmtpServerOptionsBuilder()
.ServerName("localhost")
.SessionPolicy(policy => policy
.OnConnectionAccepted((context, token) => Task.FromResult(SmtpResponse.Ok))
.OnHelo((context, name, token) => Task.FromResult(SmtpResponse.Ok)))
.Build();
```

## Installation

The package is available on [NuGet](https://www.nuget.org/packages/SmtpServer)
Expand All @@ -43,7 +94,7 @@ await smtpServer.StartAsync(CancellationToken.None);

### What hooks are provided?

There are three hooks that can be implemented; IMessageStore, IMailboxFilter, and IUserAuthenticator.
There are four hooks that can be implemented: `IMessageStore`, `IMailboxFilter`, `IUserAuthenticator`, and `ISmtpCommandPolicy`.

```cs
var options = new SmtpServerOptionsBuilder()
Expand Down
89 changes: 89 additions & 0 deletions src/SmtpServer.Benchmarks/AuthEhloBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using SmtpServer.Authentication;
using SmtpServer.ComponentModel;
using SmtpServer.IO;
using SmtpServer.Protocol;

namespace SmtpServer.Benchmarks
{
[MemoryDiagnoser]
[ShortRunJob]
public class AuthEhloBenchmarks
{
readonly EhloCommand _ehloCommand = new EhloCommand("example.com");
readonly AuthCommand _authPlainCommand = new AuthCommand(AuthenticationMethod.Plain, "AHVzZXIAcGFzc3dvcmQ=");
readonly AuthCommand _invalidAuthPlainCommand = new AuthCommand(AuthenticationMethod.Plain, "not-base64");

MemoryStream _ehloStream;
SmtpSessionContext _ehloContext;
MemoryStream _authStream;
SmtpSessionContext _authContext;

[GlobalSetup]
public void Setup()
{
_ehloStream = new MemoryStream();
_ehloContext = CreateContext(_ehloStream, addAuthenticator: false);

_authStream = new MemoryStream();
_authContext = CreateContext(_authStream, addAuthenticator: true);
}

[Benchmark]
public Task Ehlo()
{
Reset(_ehloStream);

return _ehloCommand.ExecuteAsync(_ehloContext, CancellationToken.None);
}

[Benchmark]
public Task AuthPlain()
{
Reset(_authStream);

return _authPlainCommand.ExecuteAsync(_authContext, CancellationToken.None);
}

[Benchmark]
public Task InvalidAuthPlain()
{
Reset(_authStream);

return _invalidAuthPlainCommand.ExecuteAsync(_authContext, CancellationToken.None);
}

static SmtpSessionContext CreateContext(Stream stream, bool addAuthenticator)
{
var endpointDefinition = new EndpointDefinitionBuilder()
.AllowUnsecureAuthentication()
.Build();
var options = new SmtpServerOptionsBuilder()
.ServerName("localhost")
.Endpoint(endpointDefinition)
.Build();
var serviceProvider = new ServiceProvider();

if (addAuthenticator)
{
serviceProvider.Add(new DelegatingUserAuthenticator((user, password) => true));
}

var context = new SmtpSessionContext(serviceProvider, options, endpointDefinition)
{
Pipe = new SecurableDuplexPipe(stream, 128, () => { })
};

return context;
}

static void Reset(MemoryStream stream)
{
stream.Position = 0;
stream.SetLength(0);
}
}
}
169 changes: 169 additions & 0 deletions src/SmtpServer.Benchmarks/BdatCommandBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System;
using System.Buffers;
using System.IO.Pipelines;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using SmtpServer.ComponentModel;
using SmtpServer.IO;
using SmtpServer.Mail;
using SmtpServer.Protocol;
using SmtpServer.Storage;

namespace SmtpServer.Benchmarks
{
[MemoryDiagnoser]
[ShortRunJob]
public class BdatCommandBenchmarks
{
byte[] _message;

public enum StoreMode
{
BufferedMaterializing,
StreamingDrain
}

[Params(StoreMode.BufferedMaterializing, StoreMode.StreamingDrain)]
public StoreMode Mode { get; set; }

[Params(1024, 65536)]
public int BodySize { get; set; }

[GlobalSetup]
public void Setup()
{
_message = CreateMessage(BodySize);
}

[Benchmark]
public async Task BdatLastChunk()
{
var input = new Pipe();
var output = new Pipe();

input.Writer.Write(_message);
await input.Writer.CompleteAsync().ConfigureAwait(false);

var context = CreateContext(input.Reader, output.Writer);
var command = new BdatCommand(_message.Length, true);

await command.ExecuteAsync(context, CancellationToken.None).ConfigureAwait(false);
await output.Writer.CompleteAsync().ConfigureAwait(false);

while (true)
{
var result = await output.Reader.ReadAsync().ConfigureAwait(false);
output.Reader.AdvanceTo(result.Buffer.End);

if (result.IsCompleted)
{
break;
}
}

await output.Reader.CompleteAsync().ConfigureAwait(false);
}

SmtpSessionContext CreateContext(PipeReader input, PipeWriter output)
{
var endpointDefinition = new EndpointDefinitionBuilder().Build();
var options = new SmtpServerOptionsBuilder()
.ServerName("localhost")
.Endpoint(endpointDefinition)
.Build();

var serviceProvider = new ServiceProvider();
serviceProvider.Add(Mode == StoreMode.StreamingDrain
? (IMessageStore)new StreamingDrainMessageStore()
: new BufferedMaterializingMessageStore());

var context = new SmtpSessionContext(serviceProvider, options, endpointDefinition)
{
Pipe = new BenchmarkDuplexPipe(input, output)
};

context.Transaction.From = new Mailbox("sender@example.com");
context.Transaction.To.Add(new Mailbox("recipient@example.com"));

return context;
}

static byte[] CreateMessage(int bodySize)
{
var headers = Encoding.ASCII.GetBytes("From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: BDAT benchmark\r\n\r\n");
var body = Encoding.ASCII.GetBytes(new string('x', bodySize));
var message = new byte[headers.Length + body.Length];

headers.CopyTo(message, 0);
body.CopyTo(message, headers.Length);

return message;
}

sealed class BenchmarkDuplexPipe : ISecurableDuplexPipe
{
public BenchmarkDuplexPipe(PipeReader input, PipeWriter output)
{
Input = input;
Output = output;
}

public PipeReader Input { get; }

public PipeWriter Output { get; }

public bool IsSecure => false;

public SslProtocols SslProtocol => SslProtocols.None;

public Task UpgradeAsync(X509Certificate certificate, SslProtocols protocols, CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}

public void Dispose()
{
}
}

sealed class BufferedMaterializingMessageStore : MessageStore
{
public override Task<SmtpResponse> SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence<byte> buffer, CancellationToken cancellationToken)
{
_ = buffer.ToArray();

return Task.FromResult(SmtpResponse.Ok);
}
}

sealed class StreamingDrainMessageStore : MessageStore, IStreamingMessageStore
{
public override Task<SmtpResponse> SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence<byte> buffer, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}

public async Task<SmtpResponse> SaveAsync(ISessionContext context, IMessageTransaction transaction, PipeReader reader, CancellationToken cancellationToken)
{
while (true)
{
var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
var buffer = result.Buffer;

reader.AdvanceTo(buffer.End);

if (result.IsCompleted)
{
break;
}
}

return SmtpResponse.Ok;
}
}
}
}
Loading