diff --git a/.gitignore b/.gitignore index 30cd3a5c..64ed843b 100644 --- a/.gitignore +++ b/.gitignore @@ -139,4 +139,6 @@ FakesAssemblies/ .vs/ .vscode/ -project.lock.json \ No newline at end of file +project.lock.json +# JetBrains Rider/IntelliJ +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e1929e..75948387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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() diff --git a/README.md b/README.md index 9ff9b6a2..16cba0d9 100644 --- a/README.md +++ b/README.md @@ -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 [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) @@ -41,9 +92,35 @@ var smtpServer = new SmtpServer.SmtpServer(options, ServiceProvider.Default); await smtpServer.StartAsync(CancellationToken.None); ``` +### Logging with Generic Host + +When the server is created with an `IServiceProvider` that contains `ILoggerFactory`, SMTP lifecycle and protocol diagnostics are written through `Microsoft.Extensions.Logging`. + +```cs +Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddTransient(); + + services.AddSingleton(provider => + { + var options = new SmtpServerOptionsBuilder() + .ServerName("SMTP Server") + .Port(9025) + .Build(); + + return new SmtpServer.SmtpServer(options, provider); + }); + + services.AddHostedService(); + }); +``` + +Session lifecycle is logged at `Information`, safe command snapshots at `Debug`, expected SMTP response exceptions at `Warning`, and listener/session faults at `Error`. Session logs include a `BeginScope` with `SessionId`, endpoint details, TLS state, and authentication state. AUTH material and message bodies are not logged by default. + ### 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() diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..35a6254d --- /dev/null +++ b/nuget.config @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SmtpServer.Benchmarks/AuthEhloBenchmarks.cs b/src/SmtpServer.Benchmarks/AuthEhloBenchmarks.cs new file mode 100644 index 00000000..6eeb470b --- /dev/null +++ b/src/SmtpServer.Benchmarks/AuthEhloBenchmarks.cs @@ -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); + } + } +} diff --git a/src/SmtpServer.Benchmarks/BdatCommandBenchmarks.cs b/src/SmtpServer.Benchmarks/BdatCommandBenchmarks.cs new file mode 100644 index 00000000..abe8a05e --- /dev/null +++ b/src/SmtpServer.Benchmarks/BdatCommandBenchmarks.cs @@ -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 SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + _ = buffer.ToArray(); + + return Task.FromResult(SmtpResponse.Ok); + } + } + + sealed class StreamingDrainMessageStore : MessageStore, IStreamingMessageStore + { + public override Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public async Task 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; + } + } + } +} diff --git a/src/SmtpServer.Benchmarks/DataStoreBenchmarks.cs b/src/SmtpServer.Benchmarks/DataStoreBenchmarks.cs new file mode 100644 index 00000000..1756c3ea --- /dev/null +++ b/src/SmtpServer.Benchmarks/DataStoreBenchmarks.cs @@ -0,0 +1,113 @@ +using System; +using System.Buffers; +using System.IO; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using MimeKit; +using SmtpServer.ComponentModel; +using SmtpServer.Protocol; +using SmtpServer.Storage; +using SmtpClient = MailKit.Net.Smtp.SmtpClient; + +namespace SmtpServer.Benchmarks +{ + [MemoryDiagnoser] + [ShortRunJob] + public class DataStoreBenchmarks + { + const int Port = 9026; + + SmtpServer _smtpServer; + CancellationTokenSource _smtpServerCancellationTokenSource; + SmtpClient _smtpClient; + MimeMessage _message; + + public enum StoreMode + { + BufferedMaterializing, + StreamingDrain + } + + [Params(StoreMode.BufferedMaterializing, StoreMode.StreamingDrain)] + public StoreMode Mode { get; set; } + + [GlobalSetup] + public void SmtpServerSetup() + { + _message = MimeMessage.Load(typeof(DataStoreBenchmarks).Assembly.GetManifestResourceStream("SmtpServer.Benchmarks.Test3.eml")); + _smtpServerCancellationTokenSource = new CancellationTokenSource(); + + var serviceProvider = new ServiceProvider(); + serviceProvider.Add(Mode == StoreMode.StreamingDrain + ? (IMessageStore)new StreamingDrainMessageStore() + : new BufferedMaterializingMessageStore()); + + _smtpServer = new SmtpServer( + new SmtpServerOptionsBuilder() + .Port(Port, false) + .Build(), + serviceProvider); + + _ = _smtpServer.StartAsync(_smtpServerCancellationTokenSource.Token); + + _smtpClient = new SmtpClient(); + _smtpClient.Connect("localhost", Port); + } + + [GlobalCleanup] + public Task SmtpServerCleanupAsync() + { + _smtpClient.Disconnect(true); + _smtpClient.Dispose(); + + _smtpServerCancellationTokenSource.Cancel(); + _smtpServerCancellationTokenSource.Dispose(); + + return _smtpServer.ShutdownTask; + } + + [Benchmark] + public void SendMessage() + { + _smtpClient.Send(_message); + } + + sealed class BufferedMaterializingMessageStore : MessageStore + { + public override Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + _ = buffer.ToArray(); + + return Task.FromResult(SmtpResponse.Ok); + } + } + + sealed class StreamingDrainMessageStore : MessageStore, IStreamingMessageStore + { + public override Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public async Task 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; + } + } + } +} diff --git a/src/SmtpServer.Benchmarks/Program.cs b/src/SmtpServer.Benchmarks/Program.cs index e8c71cb9..10523351 100644 --- a/src/SmtpServer.Benchmarks/Program.cs +++ b/src/SmtpServer.Benchmarks/Program.cs @@ -8,17 +8,13 @@ public class Program { public static void Main(string[] args) { - //var summary = BenchmarkRunner.Run( - // ManualConfig - // .Create(DefaultConfig.Instance) - // .With(ConfigOptions.DisableOptimizationsValidator)); - - //var summary = BenchmarkRunner.Run(); - - var summary = BenchmarkRunner.Run( - ManualConfig - .Create(DefaultConfig.Instance) - .With(ConfigOptions.DisableOptimizationsValidator)); + BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run( + args, + ManualConfig + .Create(DefaultConfig.Instance) + .WithOptions(ConfigOptions.DisableOptimizationsValidator)); } } } diff --git a/src/SmtpServer.Benchmarks/README.md b/src/SmtpServer.Benchmarks/README.md new file mode 100644 index 00000000..febbf33f --- /dev/null +++ b/src/SmtpServer.Benchmarks/README.md @@ -0,0 +1,49 @@ +# SMTP Server Benchmarks + +Run the focused benchmark suite with: + +```bash +DOTNET_ROLL_FORWARD=Major dotnet run -c Release --project src/SmtpServer.Benchmarks/SmtpServer.Benchmarks.csproj -- --filter "*" +``` + +Focused runs: + +```bash +DOTNET_ROLL_FORWARD=Major dotnet run -c Release --project src/SmtpServer.Benchmarks/SmtpServer.Benchmarks.csproj -- --filter "*SmtpParserBenchmarks*" +DOTNET_ROLL_FORWARD=Major dotnet run -c Release --project src/SmtpServer.Benchmarks/SmtpServer.Benchmarks.csproj -- --filter "*AuthEhloBenchmarks*" +DOTNET_ROLL_FORWARD=Major dotnet run -c Release --project src/SmtpServer.Benchmarks/SmtpServer.Benchmarks.csproj -- --filter "*DataStoreBenchmarks*" +DOTNET_ROLL_FORWARD=Major dotnet run -c Release --project src/SmtpServer.Benchmarks/SmtpServer.Benchmarks.csproj -- --filter "*BdatCommandBenchmarks*" --job short +``` + +## Coverage + +- `SmtpParserBenchmarks` covers command parsing, including EHLO, MAIL, RCPT, AUTH, HELP, VRFY, EXPN, BDAT, PROXY, and unrecognized commands. +- `AuthEhloBenchmarks` covers EHLO response generation, successful AUTH PLAIN, and invalid AUTH PLAIN parsing. +- `ThroughputBenchmarks` covers end-to-end DATA delivery through MailKit for representative `.eml` files. +- `DataStoreBenchmarks` compares buffered DATA materialization with streaming DATA draining. +- `BdatCommandBenchmarks` compares buffered BDAT materialization with streaming BDAT draining for 1 KiB and 64 KiB bodies. + +## Initial Optimization Candidates + +1. Parser multi-segment handling still has known gaps in `TokenReader.TryMake`; benchmark any segment-aware parser changes against single-segment command lines. +2. Command-line size enforcement currently shares message-size plumbing; measure command parsing and body reads separately before splitting the limit path. +3. Review advertised SMTP extensions against parser/state-machine coverage so EHLO only advertises behavior the server actually implements. + +## SS-13 BDAT Copy Reduction + +Focused command: + +```bash +DOTNET_ROLL_FORWARD=Major dotnet run -c Release --project src/SmtpServer.Benchmarks/SmtpServer.Benchmarks.csproj -- --filter "*BdatCommandBenchmarks.BdatLastChunk*" --job short +``` + +Short-run results on .NET 9.0.17: + +| Mode | Body Size | Before | After | +| --- | ---: | ---: | ---: | +| BufferedMaterializing | 1 KiB | 2.610 us, 7.34 KB | 2.448 us, 6.23 KB | +| BufferedMaterializing | 64 KiB | 65.656 us, 386.48 KB | 55.955 us, 322.36 KB | +| StreamingDrain | 1 KiB | 4.656 us, 5.05 KB | 4.651 us, 5.05 KB | +| StreamingDrain | 64 KiB | 19.393 us, 9.55 KB | 20.303 us, 9.55 KB | + +The buffered BDAT fallback now builds the final `ReadOnlySequence` from the existing `MemoryStream` backing buffer instead of copying the complete message with `ToArray()`. Streaming BDAT allocation stayed unchanged. diff --git a/src/SmtpServer.Benchmarks/SmtpParserBenchmarks.cs b/src/SmtpServer.Benchmarks/SmtpParserBenchmarks.cs new file mode 100644 index 00000000..58fd513c --- /dev/null +++ b/src/SmtpServer.Benchmarks/SmtpParserBenchmarks.cs @@ -0,0 +1,186 @@ +using System.Buffers; +using System.Text; +using BenchmarkDotNet.Attributes; +using SmtpServer.Protocol; +using SmtpServer.Text; + +namespace SmtpServer.Benchmarks +{ + [MemoryDiagnoser] + [ShortRunJob] + public class SmtpParserBenchmarks + { + static readonly SmtpResponse UnrecognizedCommand = new SmtpResponse(SmtpReplyCode.CommandNotImplemented, "Unrecognized command"); + + readonly SmtpParser _parser = new SmtpParser(new SmtpCommandFactory()); + byte[] _buffer; + + [Params( + "EHLO example.com", + "MAIL FROM: SIZE=12345 SMTPUTF8", + "RCPT TO:", + "AUTH PLAIN Y2Fpbi5vc3VsbGl2YW5AZ21haWwuY29t", + "HELP", + "VRFY recipient@example.com", + "EXPN staff", + "BDAT 1024 LAST", + "PROXY TCP4 192.168.1.1 192.168.1.2 1234 16789", + "ABCDE FGHIJ KLMNO")] + public string Input { get; set; } + + [GlobalSetup] + public void Setup() + { + _buffer = Encoding.UTF8.GetBytes(Input); + } + + [Benchmark(Baseline = true)] + public bool LegacySequentialCandidateLoop() + { + var sequence = new ReadOnlySequence(_buffer); + + return LegacyTryMake(ref sequence, out _, out _); + } + + [Benchmark] + public bool SinglePassVerbDispatch() + { + var sequence = new ReadOnlySequence(_buffer); + + return _parser.TryMake(ref sequence, out _, out _); + } + + bool LegacyTryMake(ref ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + return TryMakeEhlo(buffer, out command, out errorResponse) + || TryMakeHelo(buffer, out command, out errorResponse) + || TryMakeMail(buffer, out command, out errorResponse) + || TryMakeRcpt(buffer, out command, out errorResponse) + || TryMakeData(buffer, out command, out errorResponse) + || TryMakeQuit(buffer, out command, out errorResponse) + || TryMakeRset(buffer, out command, out errorResponse) + || TryMakeNoop(buffer, out command, out errorResponse) + || TryMakeStartTls(buffer, out command, out errorResponse) + || TryMakeAuth(buffer, out command, out errorResponse) + || TryMakeHelp(buffer, out command, out errorResponse) + || TryMakeVrfy(buffer, out command, out errorResponse) + || TryMakeExpn(buffer, out command, out errorResponse) + || TryMakeBdat(buffer, out command, out errorResponse) + || TryMakeProxy(buffer, out command, out errorResponse) + || MakeUnrecognized(out command, out errorResponse); + } + + bool TryMakeEhlo(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeEhlo(ref reader, out command, out errorResponse); + } + + bool TryMakeHelo(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeHelo(ref reader, out command, out errorResponse); + } + + bool TryMakeMail(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeMail(ref reader, out command, out errorResponse); + } + + bool TryMakeRcpt(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeRcpt(ref reader, out command, out errorResponse); + } + + bool TryMakeData(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeData(ref reader, out command, out errorResponse); + } + + bool TryMakeQuit(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeQuit(ref reader, out command, out errorResponse); + } + + bool TryMakeRset(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeRset(ref reader, out command, out errorResponse); + } + + bool TryMakeNoop(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeNoop(ref reader, out command, out errorResponse); + } + + bool TryMakeStartTls(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeStartTls(ref reader, out command, out errorResponse); + } + + bool TryMakeAuth(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeAuth(ref reader, out command, out errorResponse); + } + + bool TryMakeHelp(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeHelp(ref reader, out command, out errorResponse); + } + + bool TryMakeVrfy(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeVrfy(ref reader, out command, out errorResponse); + } + + bool TryMakeExpn(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeExpn(ref reader, out command, out errorResponse); + } + + bool TryMakeBdat(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeBdat(ref reader, out command, out errorResponse); + } + + bool TryMakeProxy(ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) + { + var reader = new TokenReader(buffer); + + return _parser.TryMakeProxy(ref reader, out command, out errorResponse); + } + + static bool MakeUnrecognized(out SmtpCommand command, out SmtpResponse errorResponse) + { + command = null; + errorResponse = UnrecognizedCommand; + + return false; + } + } +} diff --git a/src/SmtpServer.Tests/Mocks/TestLoggerFactory.cs b/src/SmtpServer.Tests/Mocks/TestLoggerFactory.cs new file mode 100644 index 00000000..d4d238a3 --- /dev/null +++ b/src/SmtpServer.Tests/Mocks/TestLoggerFactory.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace SmtpServer.Tests.Mocks +{ + internal sealed class TestLoggerFactory : ILoggerFactory + { + readonly List _entries = new List(); + readonly AsyncLocal _currentScope = new AsyncLocal(); + + public IReadOnlyList Entries + { + get + { + lock (_entries) + { + return _entries.ToList(); + } + } + } + + public ILogger CreateLogger(string categoryName) + { + return new TestLogger(this, categoryName); + } + + public void AddProvider(ILoggerProvider provider) { } + + public void Dispose() { } + + internal IDisposable PushScope(object state) + { + var scope = new Scope(this, state, _currentScope.Value); + _currentScope.Value = scope; + return scope; + } + + internal IReadOnlyList GetScopes() + { + var scopes = new List(); + + for (var scope = _currentScope.Value; scope != null; scope = scope.Parent) + { + scopes.Add(scope.State); + } + + scopes.Reverse(); + return scopes; + } + + internal void Add(TestLogEntry entry) + { + lock (_entries) + { + _entries.Add(entry); + } + } + + sealed class Scope : IDisposable + { + readonly TestLoggerFactory _factory; + + public Scope(TestLoggerFactory factory, object state, Scope parent) + { + _factory = factory; + State = state; + Parent = parent; + } + + public object State { get; } + + public Scope Parent { get; } + + public void Dispose() + { + if (_factory._currentScope.Value == this) + { + _factory._currentScope.Value = Parent; + } + } + } + + sealed class TestLogger : ILogger + { + readonly TestLoggerFactory _factory; + readonly string _categoryName; + + public TestLogger(TestLoggerFactory factory, string categoryName) + { + _factory = factory; + _categoryName = categoryName; + } + + public IDisposable BeginScope(TState state) + { + return _factory.PushScope(state); + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + _factory.Add(new TestLogEntry(_categoryName, logLevel, eventId, state, formatter(state, exception), exception, _factory.GetScopes())); + } + } + } + + internal sealed class TestLogEntry + { + public TestLogEntry(string categoryName, LogLevel logLevel, EventId eventId, object state, string message, Exception exception, IReadOnlyList scopes) + { + CategoryName = categoryName; + LogLevel = logLevel; + EventId = eventId; + State = state; + Message = message; + Exception = exception; + Scopes = scopes; + } + + public string CategoryName { get; } + + public LogLevel LogLevel { get; } + + public EventId EventId { get; } + + public object State { get; } + + public string Message { get; } + + public Exception Exception { get; } + + public IReadOnlyList Scopes { get; } + + public bool TryGetStateValue(string name, out object value) + { + return TryGetValue(State, name, out value); + } + + public bool TryGetScopeValue(string name, out object value) + { + foreach (var scope in Scopes) + { + if (TryGetValue(scope, name, out value)) + { + return true; + } + } + + value = null; + return false; + } + + static bool TryGetValue(object state, string name, out object value) + { + if (state is IEnumerable> properties) + { + foreach (var property in properties) + { + if (property.Key == name) + { + value = property.Value; + return true; + } + } + } + + value = null; + return false; + } + } +} diff --git a/src/SmtpServer.Tests/OAuthSaslDecoderTests.cs b/src/SmtpServer.Tests/OAuthSaslDecoderTests.cs new file mode 100644 index 00000000..e49e8056 --- /dev/null +++ b/src/SmtpServer.Tests/OAuthSaslDecoderTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Text; +using SmtpServer.Protocol; +using Xunit; + +namespace SmtpServer.Tests +{ + public class OAuthSaslDecoderTests + { + // The SASL field separator (SOH, U+0001). + const string A = "\u0001"; + + static string Encode(string payload) + { + return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)); + } + + [Fact] + public void XOAuth2_DecodesUserAndBearerToken() + { + // arrange — the Google/Microsoft XOAUTH2 form: user={id}^Aauth=Bearer {token}^A^A + var blob = Encode("user=alice@example.is" + A + "auth=Bearer header.body.signature" + A + A); + + // act + var result = OAuthSaslDecoder.TryDecodeXOAuth2(blob, out var user, out var token); + + // assert + Assert.True(result); + Assert.Equal("alice@example.is", user); + Assert.Equal("header.body.signature", token); + } + + [Fact] + public void OAuthBearer_DecodesAuthzidAndBearerToken() + { + // arrange — the RFC 7628 form: n,a={id},^Aauth=Bearer {token}^A^A + var blob = Encode("n,a=alice@example.is," + A + "auth=Bearer header.body.signature" + A + A); + + // act + var result = OAuthSaslDecoder.TryDecodeOAuthBearer(blob, out var user, out var token); + + // assert + Assert.True(result); + Assert.Equal("alice@example.is", user); + Assert.Equal("header.body.signature", token); + } + + [Fact] + public void OAuthBearer_DecodesAuthzidWithHostAndPortFields() + { + // arrange — a fuller GS2 header with host/port fields interleaved before the auth field. + var blob = Encode("n,a=alice@example.is," + A + "host=mail.example.is" + A + "port=587" + A + "auth=Bearer the.jwt" + A + A); + + // act + var result = OAuthSaslDecoder.TryDecodeOAuthBearer(blob, out var user, out var token); + + // assert + Assert.True(result); + Assert.Equal("alice@example.is", user); + Assert.Equal("the.jwt", token); + } + + [Theory] + [InlineData("not base64 at all")] + [InlineData("")] + public void ReturnsFalse_ForInvalidBase64(string blob) + { + Assert.False(OAuthSaslDecoder.TryDecodeXOAuth2(blob, out _, out _)); + Assert.False(OAuthSaslDecoder.TryDecodeOAuthBearer(blob, out _, out _)); + } + + [Fact] + public void XOAuth2_ReturnsFalse_WhenTokenMissing() + { + var blob = Encode("user=alice@example.is" + A + A); + + Assert.False(OAuthSaslDecoder.TryDecodeXOAuth2(blob, out _, out _)); + } + + [Fact] + public void XOAuth2_ReturnsFalse_WhenUserMissing() + { + var blob = Encode("auth=Bearer the.jwt" + A + A); + + Assert.False(OAuthSaslDecoder.TryDecodeXOAuth2(blob, out _, out _)); + } + + [Fact] + public void OAuthBearer_ReturnsFalse_WhenAuthorizationIdentityMissing() + { + // "n,," has no a= authzid, so there is no identity to resolve the mailbox with. + var blob = Encode("n,," + A + "auth=Bearer the.jwt" + A + A); + + Assert.False(OAuthSaslDecoder.TryDecodeOAuthBearer(blob, out _, out _)); + } + } +} diff --git a/src/SmtpServer.Tests/PipeReaderTests.cs b/src/SmtpServer.Tests/PipeReaderTests.cs index b9a085a7..9ecb97e9 100644 --- a/src/SmtpServer.Tests/PipeReaderTests.cs +++ b/src/SmtpServer.Tests/PipeReaderTests.cs @@ -1,8 +1,10 @@ using System.IO; using System.IO.Pipelines; using System.Text; +using System.Threading; using System.Threading.Tasks; using SmtpServer.IO; +using SmtpServer.Protocol; using SmtpServer.Text; using Xunit; @@ -71,6 +73,62 @@ public async Task CanReadMultipleLines() Assert.Equal("klmno", line3); } + [Fact] + public async Task CanEnforceMaxCommandLineLength() + { + // arrange + var reader = CreatePipeReader("abcdef\r\n"); + + // act + var exception = await Assert.ThrowsAsync( + async () => await reader.ReadLineAsync(Encoding.ASCII, 5)); + + // assert + Assert.True(exception.IsQuitRequested); + Assert.Equal(SmtpReplyCode.SyntaxError, exception.Response.ReplyCode); + } + + [Fact] + public async Task CanReadLineAtMaxCommandLineLength() + { + // arrange + var reader = CreatePipeReader("abcde\r\n"); + + // act + var line = await reader.ReadLineAsync(Encoding.ASCII, 5); + + // assert + Assert.Equal("abcde", line); + } + + [Fact] + public async Task CanWriteEnhancedStatusCodeReply() + { + // arrange + var pipe = new Pipe(); + + // act + await pipe.Writer.WriteReplyAsync(SmtpResponse.AuthenticationFailed, CancellationToken.None); + pipe.Writer.Complete(); + + // assert + Assert.Equal("535 5.7.8 authentication failed\r\n", await ReadAllAsync(pipe.Reader)); + } + + [Fact] + public async Task CanWriteAuthContinuationWithoutEnhancedStatusCode() + { + // arrange + var pipe = new Pipe(); + + // act + await pipe.Writer.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.ContinueWithAuth, "VXNlcm5hbWU6"), CancellationToken.None); + pipe.Writer.Complete(); + + // assert + Assert.Equal("334 VXNlcm5hbWU6\r\n", await ReadAllAsync(pipe.Reader)); + } + [Fact] public async Task CanReadBlockWithDotStuffingRemoved() { @@ -93,5 +151,66 @@ await reader.ReadDotBlockAsync( // assert Assert.Equal("abcd\r\n.1234", text); } + + [Fact] + public async Task CanStreamBlockWithDotStuffingRemoved() + { + // arrange + var reader = CreatePipeReader("abcd\r\n..1234\r\n.\r\n"); + var writer = new Pipe(); + + var maxMessageSizeOptions = new MaxMessageSizeOptions(); + + // act + await reader.ReadDotBlockAsync(writer.Writer, maxMessageSizeOptions); + var text = await ReadAllAsync(writer.Reader); + + // assert + Assert.Equal("abcd\r\n.1234", text); + } + + [Fact] + public async Task CanEnforceMaxMessageSizeWhenStreamingBlock() + { + // arrange + var reader = CreatePipeReader("abcd\r\n1234\r\n.\r\n"); + var writer = new Pipe(); + + var maxMessageSizeOptions = new MaxMessageSizeOptions(MaxMessageSizeHandling.Strict, 5); + + // act + var exception = await Assert.ThrowsAsync( + async () => await reader.ReadDotBlockAsync(writer.Writer, maxMessageSizeOptions)); + + // assert + Assert.True(exception.IsQuitRequested); + } + + static async Task ReadAllAsync(PipeReader reader) + { + using var stream = new MemoryStream(); + + while (true) + { + var result = await reader.ReadAsync(); + var buffer = result.Buffer; + + foreach (var segment in buffer) + { + stream.Write(segment.Span); + } + + reader.AdvanceTo(buffer.End); + + if (result.IsCompleted) + { + break; + } + } + + reader.Complete(); + + return Encoding.ASCII.GetString(stream.ToArray()); + } } } diff --git a/src/SmtpServer.Tests/RawSmtpClient.cs b/src/SmtpServer.Tests/RawSmtpClient.cs index fca63812..fa7f109a 100644 --- a/src/SmtpServer.Tests/RawSmtpClient.cs +++ b/src/SmtpServer.Tests/RawSmtpClient.cs @@ -41,6 +41,14 @@ internal async Task ConnectAsync() return false; } + internal async Task ConnectAndReadGreetingAsync() + { + await _tcpClient.ConnectAsync(new IPEndPoint(IPAddress.Parse(_host), _port)); + _networkStream = _tcpClient.GetStream(); + + return await WaitForDataAsync(); + } + internal async Task SendCommandAsync(string command) { var commandData = Encoding.UTF8.GetBytes($"{command}\r\n"); @@ -49,6 +57,23 @@ internal async Task SendCommandAsync(string command) return await WaitForDataAsync(); } + internal async Task SendBdatAsync(string command, string data) + { + var commandData = Encoding.UTF8.GetBytes($"{command}\r\n{data}"); + + await _networkStream.WriteAsync(commandData, 0, commandData.Length); + return await WaitForDataAsync(); + } + + internal async Task SendBdatAsync(string command, byte[] data) + { + var commandData = Encoding.UTF8.GetBytes($"{command}\r\n"); + + await _networkStream.WriteAsync(commandData, 0, commandData.Length); + await _networkStream.WriteAsync(data, 0, data.Length); + return await WaitForDataAsync(); + } + internal async Task SendDataAsync(string data) { var mailData = Encoding.UTF8.GetBytes(data); diff --git a/src/SmtpServer.Tests/SecurableDuplexPipeTests.cs b/src/SmtpServer.Tests/SecurableDuplexPipeTests.cs new file mode 100644 index 00000000..e7c2ed0a --- /dev/null +++ b/src/SmtpServer.Tests/SecurableDuplexPipeTests.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SmtpServer.IO; +using Xunit; + +namespace SmtpServer.Tests +{ + public class SecurableDuplexPipeTests + { + [Fact] + public async Task CanUseConfiguredNetworkBufferSize() + { + // arrange + var stream = new RecordingReadStream(); + using var pipe = new SecurableDuplexPipe(stream, 4096, () => { }); + + // act + var result = await pipe.Input.ReadAsync(); + pipe.Input.AdvanceTo(result.Buffer.End); + + // assert + Assert.Equal(4096, stream.LastReadBufferSize); + } + + sealed class RecordingReadStream : Stream + { + public int LastReadBufferSize { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => 0; + + public override long Position + { + get => 0; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + LastReadBufferSize = count; + return 0; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + LastReadBufferSize = count; + return Task.FromResult(0); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + LastReadBufferSize = buffer.Length; + return new ValueTask(0); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return default; + } + } + } +} diff --git a/src/SmtpServer.Tests/SmtpParserTests.cs b/src/SmtpServer.Tests/SmtpParserTests.cs index 555093cd..6bb0981c 100644 --- a/src/SmtpServer.Tests/SmtpParserTests.cs +++ b/src/SmtpServer.Tests/SmtpParserTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Net; using System.Text; +using SmtpServer.IO; using SmtpServer.Mail; using SmtpServer.Protocol; using SmtpServer.Text; @@ -19,6 +20,23 @@ static TokenReader CreateReader(string text) return new TokenReader(new ReadOnlySequence(buffer, 0, buffer.Length)); } + static TokenReader CreateReader(params string[] values) + { + return new TokenReader(CreateSequence(values)); + } + + static ReadOnlySequence CreateSequence(params string[] values) + { + var segments = new ByteArraySegmentList(); + + foreach (var value in values) + { + segments.Append(Encoding.UTF8.GetBytes(value)); + } + + return segments.Build(); + } + static SmtpParser Parser => new SmtpParser(new SmtpCommandFactory()); [Fact] @@ -37,6 +55,131 @@ public void CanMakeUnrecognized() Assert.Equal(SmtpReplyCode.CommandNotImplemented, errorResponse.ReplyCode); } + [Theory] + [InlineData("HELO abc.example.com extra")] + [InlineData("MAIL FROM: SIZE=")] + public void CanReturnSyntaxErrorForMalformedKnownCommand(string input) + { + // arrange + var buffer = Encoding.UTF8.GetBytes(input); + var sequence = new ReadOnlySequence(buffer, 0, buffer.Length); + + // act + var result = Parser.TryMake(ref sequence, out var command, out var errorResponse); + + // assert + Assert.False(result); + Assert.Null(command); + Assert.Equal(SmtpReplyCode.SyntaxError, errorResponse.ReplyCode); + } + + [Theory] + [InlineData("ehlo example.com", typeof(EhloCommand))] + [InlineData("HELO example.com", typeof(HeloCommand))] + [InlineData("MAIL FROM:", typeof(MailCommand))] + [InlineData("RCPT TO:", typeof(RcptCommand))] + [InlineData("HELP", typeof(HelpCommand))] + [InlineData("VRFY cain.osullivan@gmail.com", typeof(VrfyCommand))] + [InlineData("EXPN staff", typeof(ExpnCommand))] + [InlineData("BDAT 5 LAST", typeof(BdatCommand))] + [InlineData("DATA", typeof(DataCommand))] + [InlineData("QUIT", typeof(QuitCommand))] + [InlineData("RSET", typeof(RsetCommand))] + [InlineData("NOOP", typeof(NoopCommand))] + [InlineData("STARTTLS", typeof(StartTlsCommand))] + [InlineData("AUTH PLAIN Y2Fpbi5vc3VsbGl2YW5AZ21haWwuY29t", typeof(AuthCommand))] + [InlineData("PROXY UNKNOWN", typeof(ProxyCommand))] + public void CanMakeKnownCommandUsingTopLevelDispatch(string input, Type commandType) + { + // arrange + var buffer = Encoding.UTF8.GetBytes(input); + var sequence = new ReadOnlySequence(buffer, 0, buffer.Length); + + // act + var result = Parser.TryMake(ref sequence, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.Equal(commandType, command.GetType()); + Assert.Null(errorResponse); + } + + [Fact] + public void CanMakeSplitMailWithEsmtpParameters() + { + // arrange + var sequence = CreateSequence("MA", "IL FROM: SI", "ZE=123 SMTP", "UTF8"); + + // act + var result = Parser.TryMake(ref sequence, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.Null(errorResponse); + + var mailCommand = Assert.IsType(command); + Assert.Equal("sender", mailCommand.Address.User); + Assert.Equal("example.com", mailCommand.Address.Host); + Assert.Equal("123", mailCommand.Parameters["SIZE"]); + Assert.True(mailCommand.Parameters.ContainsKey("SMTPUTF8")); + } + + [Fact] + public void CanMakeSplitRcptWithEsmtpParameters() + { + // arrange + var sequence = CreateSequence("RC", "PT TO: NOTIFY=SUCCESS,FAIL", "URE OR", "CPT=rfc822;original@example.com"); + + // act + var result = Parser.TryMake(ref sequence, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.Null(errorResponse); + + var rcptCommand = Assert.IsType(command); + Assert.Equal("recipient", rcptCommand.Address.User); + Assert.Equal("example.com", rcptCommand.Address.Host); + Assert.Equal("SUCCESS,FAILURE", rcptCommand.Parameters["NOTIFY"]); + Assert.Equal("rfc822;original@example.com", rcptCommand.Parameters["ORCPT"]); + } + + [Fact] + public void CanMakeSplitAuthPlain() + { + // arrange + var sequence = CreateSequence("AU", "TH PL", "AIN Y2Fpbi5vc3", "VsbGl2YW5AZ21haWwuY29t"); + + // act + var result = Parser.TryMake(ref sequence, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.Null(errorResponse); + + var authCommand = Assert.IsType(command); + Assert.Equal(AuthenticationMethod.Plain, authCommand.Method); + Assert.Equal("Y2Fpbi5vc3VsbGl2YW5AZ21haWwuY29t", authCommand.Parameter); + } + + [Fact] + public void CanMakeSplitBdatLast() + { + // arrange + var sequence = CreateSequence("BD", "AT 102", "4 LA", "ST"); + + // act + var result = Parser.TryMake(ref sequence, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.Null(errorResponse); + + var bdatCommand = Assert.IsType(command); + Assert.Equal(1024, bdatCommand.Size); + Assert.True(bdatCommand.IsLast); + } + [Fact] public void CanMakeQuit() { @@ -65,6 +208,125 @@ public void CanMakeNoop() Assert.True(command is NoopCommand); } + [Theory] + [InlineData("HELP", "")] + [InlineData("HELP MAIL", "MAIL")] + public void CanMakeHelp(string input, string argument) + { + // arrange + var reader = CreateReader(input); + + // act + var result = Parser.TryMakeHelp(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is HelpCommand); + Assert.Equal(argument, ((HelpCommand)command).Argument); + Assert.Null(errorResponse); + } + + [Fact] + public void CanMakeVrfy() + { + // arrange + var reader = CreateReader("VRFY user@example.com"); + + // act + var result = Parser.TryMakeVrfy(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is VrfyCommand); + Assert.Equal("user@example.com", ((VrfyCommand)command).Argument); + Assert.Null(errorResponse); + } + + [Fact] + public void CanNotMakeVrfyWithoutArgument() + { + // arrange + var reader = CreateReader("VRFY"); + + // act + var result = Parser.TryMakeVrfy(ref reader, out var command, out var errorResponse); + + // assert + Assert.False(result); + Assert.Null(command); + Assert.Equal(SmtpReplyCode.SyntaxError, errorResponse.ReplyCode); + } + + [Fact] + public void CanMakeExpn() + { + // arrange + var reader = CreateReader("EXPN staff"); + + // act + var result = Parser.TryMakeExpn(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is ExpnCommand); + Assert.Equal("staff", ((ExpnCommand)command).Argument); + Assert.Null(errorResponse); + } + + [Fact] + public void CanNotMakeExpnWithoutArgument() + { + // arrange + var reader = CreateReader("EXPN"); + + // act + var result = Parser.TryMakeExpn(ref reader, out var command, out var errorResponse); + + // assert + Assert.False(result); + Assert.Null(command); + Assert.Equal(SmtpReplyCode.SyntaxError, errorResponse.ReplyCode); + } + + [Theory] + [InlineData("BDAT 5", 5, false)] + [InlineData("BDAT 0 LAST", 0, true)] + [InlineData("BDAT 1024 last", 1024, true)] + public void CanMakeBdat(string input, long size, bool isLast) + { + // arrange + var reader = CreateReader(input); + + // act + var result = Parser.TryMakeBdat(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is BdatCommand); + Assert.Equal(size, ((BdatCommand)command).Size); + Assert.Equal(isLast, ((BdatCommand)command).IsLast); + Assert.Null(errorResponse); + } + + [Theory] + [InlineData("BDAT")] + [InlineData("BDAT LAST")] + [InlineData("BDAT 5 DONE")] + [InlineData("BDAT 5 LAST extra")] + public void CanNotMakeBdat(string input) + { + // arrange + var reader = CreateReader(input); + + // act + var result = Parser.TryMakeBdat(ref reader, out var command, out var errorResponse); + + // assert + Assert.False(result); + Assert.Null(command); + Assert.Equal(SmtpReplyCode.SyntaxError, errorResponse.ReplyCode); + } + [Fact] public void CanMakeHelo() { @@ -84,6 +346,8 @@ public void CanMakeHelo() [InlineData("HELO abc.")] [InlineData("HELO -abc.com")] [InlineData("HELO ////")] + [InlineData("HELO abc.example.com extra")] + [InlineData("HELO [192.168.1.200] extra")] public void CanNotMakeHelo(string input) { // arrange @@ -102,6 +366,7 @@ public void CanNotMakeHelo(string input) [InlineData("EHLO abc-1-def.mail.com", "abc-1-def.mail.com")] [InlineData("EHLO 192.168.1.200", "192.168.1.200")] [InlineData("EHLO [192.168.1.200]", "192.168.1.200")] + [InlineData("EHLO dæmi.is", "dæmi.is")] [InlineData("EHLO [IPv6:ABCD:EF01:2345:6789:ABCD:EF01:2345:6789]", "IPv6:ABCD:EF01:2345:6789:ABCD:EF01:2345:6789")] public void CanMakeEhlo(string input, string domainOrAddress) { @@ -117,6 +382,23 @@ public void CanMakeEhlo(string input, string domainOrAddress) Assert.Equal(domainOrAddress, ((EhloCommand)command).DomainOrAddress); } + [Theory] + [InlineData("EHLO abc.example.com extra")] + [InlineData("EHLO [192.168.1.200] extra")] + public void CanNotMakeEhlo(string input) + { + // arrange + var reader = CreateReader(input); + + // act + var result = Parser.TryMakeEhlo(ref reader, out var command, out var errorResponse); + + // assert + Assert.False(result); + Assert.Null(command); + Assert.NotNull(errorResponse); + } + [Fact] public void CanMakeAuthPlain() { @@ -149,10 +431,59 @@ public void CanMakeAuthLogin() Assert.Equal("Y2Fpbi5vc3VsbGl2YW5AZ21haWwuY29t", ((AuthCommand)command).Parameter); } + [Fact] + public void CanMakeAuthXOAuth2() + { + // arrange — the "2" tokenizes separately from "XOAUTH", so this proves both tokens are consumed + var reader = CreateReader("AUTH XOAUTH2 dXNlcj1hbGljZQ=="); + + // act + var result = Parser.TryMakeAuth(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is AuthCommand); + Assert.Equal(AuthenticationMethod.XOAuth2, ((AuthCommand)command).Method); + Assert.Equal("dXNlcj1hbGljZQ==", ((AuthCommand)command).Parameter); + } + + [Fact] + public void CanMakeAuthXOAuth2WithoutInitialResponse() + { + // arrange + var reader = CreateReader("AUTH XOAUTH2"); + + // act + var result = Parser.TryMakeAuth(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is AuthCommand); + Assert.Equal(AuthenticationMethod.XOAuth2, ((AuthCommand)command).Method); + Assert.Null(((AuthCommand)command).Parameter); + } + + [Fact] + public void CanMakeAuthOAuthBearer() + { + // arrange + var reader = CreateReader("AUTH OAUTHBEARER dXNlcj1hbGljZQ=="); + + // act + var result = Parser.TryMakeAuth(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.True(command is AuthCommand); + Assert.Equal(AuthenticationMethod.OAuthBearer, ((AuthCommand)command).Method); + Assert.Equal("dXNlcj1hbGljZQ==", ((AuthCommand)command).Parameter); + } + [Theory] [InlineData("MAIL FROM:", "cain.osullivan", "gmail.com")] [InlineData(@"MAIL FROM:<""Abc@def""@example.com>", "Abc@def", "example.com")] [InlineData("MAIL FROM: SMTPUTF8", "pelé", "example.com", "SMTPUTF8")] + [InlineData("MAIL FROM:<þorsteinn@dæmi.is> SMTPUTF8", "þorsteinn", "dæmi.is", "SMTPUTF8")] public void CanMakeMail(string input, string user, string host, string extension = null) { // arrange @@ -173,6 +504,23 @@ public void CanMakeMail(string input, string user, string host, string extension } } + [Fact] + public void CanMakeMailWithDsnParameters() + { + // arrange + var reader = CreateReader("MAIL FROM: ret=FULL ENVID=abc123"); + + // act + var result = Parser.TryMakeMail(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.Null(errorResponse); + var mailCommand = Assert.IsType(command); + Assert.Equal("FULL", mailCommand.Parameters["RET"]); + Assert.Equal("abc123", mailCommand.Parameters["envid"]); + } + [Fact] public void CanMakeMailWithNoAddress() { @@ -209,6 +557,9 @@ public void CanMakeMailWithBlankAddress() [Theory] [InlineData("MAIL FROM:cain")] + [InlineData("MAIL FROM: SIZE=")] + [InlineData("MAIL FROM: =BAD")] + [InlineData("MAIL FROM: SIZE=123 =BAD")] public void CanNotMakeMail(string input) { // arrange @@ -226,6 +577,7 @@ public void CanNotMakeMail(string input) [InlineData("RCPT TO:", "cain.osullivan", "gmail.com")] [InlineData(@"RCPT TO:<""Abc@def""@example.com>", "Abc@def", "example.com")] [InlineData("RCPT TO:", "pelé", "example.com")] + [InlineData("RCPT TO:<þorsteinn@dæmi.is>", "þorsteinn", "dæmi.is")] [InlineData("RCPT TO:<@example1.com:someone@example.com>", "someone", "example.com")] [InlineData("RCPT TO:<@example1.com,@example2.com:someone@example.com>", "someone", "example.com")] [InlineData("RCPT TO:", "example/example", "example.com")] @@ -244,6 +596,41 @@ public void CanMakeRcpt(string input, string user, string host) Assert.Equal(host, ((RcptCommand)command).Address.Host); } + [Fact] + public void CanMakeRcptWithDsnParameters() + { + // arrange + var reader = CreateReader("RCPT TO: notify=SUCCESS,FAILURE ORCPT=rfc822;original@example.com"); + + // act + var result = Parser.TryMakeRcpt(ref reader, out var command, out var errorResponse); + + // assert + Assert.True(result); + Assert.Null(errorResponse); + var rcptCommand = Assert.IsType(command); + Assert.Equal("recipient", rcptCommand.Address.User); + Assert.Equal("example.com", rcptCommand.Address.Host); + Assert.Equal(2, rcptCommand.Parameters.Count); + Assert.Equal("SUCCESS,FAILURE", rcptCommand.Parameters["NOTIFY"]); + Assert.Equal("rfc822;original@example.com", rcptCommand.Parameters["orcpt"]); + } + + [Fact] + public void CanNotMakeRcptWithInvalidParameters() + { + // arrange + var reader = CreateReader("RCPT TO: NOTIFY="); + + // act + var result = Parser.TryMakeRcpt(ref reader, out var command, out var errorResponse); + + // assert + Assert.False(result); + Assert.Null(command); + Assert.NotNull(errorResponse); + } + [Theory] [InlineData("RCPT TO:")] [InlineData("RCPT TO:")] @@ -312,6 +699,24 @@ public void CanMakeProxyTcp6() Assert.Equal(16789, ((ProxyCommand)command).DestinationEndpoint.Port); } + [Theory] + [InlineData("PROXY TCP5 192.168.1.1 192.168.1.2 1234 16789")] + [InlineData("PROXY TCP46 192.168.1.1 192.168.1.2 1234 16789")] + [InlineData("PROXY TCPA 192.168.1.1 192.168.1.2 1234 16789")] + public void CanNotMakeProxyWithInvalidTcpVersion(string input) + { + // arrange + var reader = CreateReader(input); + + // act + var result = Parser.TryMakeProxy(ref reader, out var command, out var errorResponse); + + // assert + Assert.False(result); + Assert.Null(command); + Assert.Null(errorResponse); + } + [Fact] public void CanMakeAtom() { diff --git a/src/SmtpServer.Tests/SmtpServer.Tests.csproj b/src/SmtpServer.Tests/SmtpServer.Tests.csproj index 9a479367..42eff99a 100644 --- a/src/SmtpServer.Tests/SmtpServer.Tests.csproj +++ b/src/SmtpServer.Tests/SmtpServer.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 diff --git a/src/SmtpServer.Tests/SmtpServerTests.cs b/src/SmtpServer.Tests/SmtpServerTests.cs index 292cdaa1..020278ad 100644 --- a/src/SmtpServer.Tests/SmtpServerTests.cs +++ b/src/SmtpServer.Tests/SmtpServerTests.cs @@ -1,5 +1,6 @@ using MailKit; using MailKit.Net.Smtp; +using Microsoft.Extensions.Logging; using SmtpServer.Authentication; using SmtpServer.ComponentModel; using SmtpServer.Mail; @@ -7,9 +8,13 @@ using SmtpServer.Protocol; using SmtpServer.Storage; using SmtpServer.Tests.Mocks; +using SmtpServer.Tracing; using System; +using System.Buffers; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.IO.Pipelines; using System.Linq; using System.Net; using System.Net.Security; @@ -52,6 +57,21 @@ public void CanReceiveMessage() } } + [Fact] + public void CanReceiveMessageUsingStreamingMessageStore() + { + var streamingMessageStore = new StreamingMockMessageStore(); + + using (CreateServer(services => services.Add(streamingMessageStore))) + { + MailClient.Send(MailClient.Message(from: "test1@test.com", to: "test2@test.com", text: "streamed body")); + } + + Assert.True(streamingMessageStore.StreamingSaveCalled); + Assert.False(streamingMessageStore.BufferedSaveCalled); + Assert.Contains("streamed body", streamingMessageStore.Message); + } + [Theory] [InlineData("Assunto teste acento çãõáéíóú", "utf-8")] [InlineData("שלום שלום שלום", "windows-1255")] @@ -126,6 +146,414 @@ public void CanFailAuthenticationEmptyUserOrPassword(string user, string passwor } } + [Theory] + [InlineData("AUTH PLAIN not-base64")] + [InlineData("AUTH LOGIN not-base64")] + public async Task CanFailInvalidBase64AuthenticationWithoutFaulting(string command) + { + var userAuthenticator = new DelegatingUserAuthenticator((user, password) => true); + + using (CreateServer(endpoint => endpoint.AllowUnsecureAuthentication(), services => services.Add(userAuthenticator))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var ehloResponse = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.StartsWith("250-", ehloResponse); + + var response = await rawSmtpClient.SendCommandAsync(command); + Assert.StartsWith("535 5.7.8 authentication failed", response); + + response = await rawSmtpClient.SendCommandAsync("NOOP"); + Assert.StartsWith("250 2.0.0 Ok", response); + } + } + + [Fact] + public async Task CanFailInvalidBase64AuthenticationContinuationWithoutFaulting() + { + var userAuthenticator = new DelegatingUserAuthenticator((user, password) => true); + + using (CreateServer(endpoint => endpoint.AllowUnsecureAuthentication(), services => services.Add(userAuthenticator))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var ehloResponse = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.StartsWith("250-", ehloResponse); + + var response = await rawSmtpClient.SendCommandAsync("AUTH LOGIN"); + Assert.StartsWith("334 VXNlcm5hbWU6", response); + + response = await rawSmtpClient.SendCommandAsync("not-base64"); + Assert.StartsWith("535 5.7.8 authentication failed", response); + + response = await rawSmtpClient.SendCommandAsync("NOOP"); + Assert.StartsWith("250 2.0.0 Ok", response); + } + } + + [Fact] + public async Task CanReturnStableEhloResponse() + { + using (CreateServer()) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + + Assert.Equal( + "250-localhost Hello example.com, haven't we met before?\r\n" + + "250-PIPELINING\r\n" + + "250-8BITMIME\r\n" + + "250-SMTPUTF8\r\n" + + "250-DSN\r\n" + + "250 CHUNKING\r\n", + response); + } + } + + [Fact] + public async Task CanDisableAdvertisedExtensions() + { + using (CreateServer(options => options.Extensions(extensions => extensions.SmtpUtf8(false).Dsn(false).Chunking(false)))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + + Assert.Equal( + "250-localhost Hello example.com, haven't we met before?\r\n" + + "250-PIPELINING\r\n" + + "250 8BITMIME\r\n", + response); + Assert.DoesNotContain("SMTPUTF8", response); + Assert.DoesNotContain("DSN", response); + Assert.DoesNotContain("CHUNKING", response); + } + } + + [Fact] + public async Task DisabledChunkingRejectsBdat() + { + using (CreateServer(options => options.Extensions(extensions => extensions.Chunking(false)))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.DoesNotContain("CHUNKING", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendBdatAsync("BDAT 4 LAST", "test"); + Assert.StartsWith("502 5.5.1 CHUNKING is not enabled", response); + } + + Assert.Empty(MessageStore.Messages); + } + + [Fact] + public async Task DisabledDsnRejectsEnvelopeParameters() + { + using (CreateServer(options => options.Extensions(extensions => extensions.Dsn(false)))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.DoesNotContain("DSN", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM: RET=FULL"); + Assert.StartsWith("504 5.5.4 DSN is not enabled", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO: NOTIFY=SUCCESS"); + Assert.StartsWith("504 5.5.4 DSN is not enabled", response); + } + } + + [Fact] + public async Task DisabledSmtpUtf8RejectsParameterAndUtf8Mailbox() + { + using (CreateServer(options => options.Extensions(extensions => extensions.SmtpUtf8(false)))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.DoesNotContain("SMTPUTF8", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM: SMTPUTF8"); + Assert.StartsWith("504 5.5.4 SMTPUTF8 is not enabled", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM:"); + Assert.StartsWith("553 5.1.3 mailbox name not allowed", response); + } + } + + [Fact] + public async Task SessionPolicyCanRejectConnectionBeforeGreeting() + { + using (CreateServer(options => options.SessionPolicy(policy => policy.OnConnectionAccepted((context, token) => Task.FromResult(new SmtpResponse(SmtpReplyCode.ServiceUnavailable, "blocked")))))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + var response = await rawSmtpClient.ConnectAndReadGreetingAsync(); + + Assert.Equal("421 4.3.0 blocked\r\n", response); + } + } + + [Fact] + public async Task SessionPolicyCanRejectHelo() + { + using (CreateServer(options => options.SessionPolicy(policy => policy.OnHelo((context, name, token) => Task.FromResult(new SmtpResponse(SmtpReplyCode.TransactionFailed, "bad helo")))))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + + Assert.Equal("554 5.0.0 bad helo\r\n", response); + } + } + + [Fact] + public async Task CommandEventSafeSnapshotRedactsAuthArgument() + { + SmtpCommandSnapshot safeCommand = null; + var userAuthenticator = new DelegatingUserAuthenticator((user, password) => false); + + using (var disposable = CreateServer(endpoint => endpoint.AllowUnsecureAuthentication(), services => services.Add(userAuthenticator))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + EventHandler sessionCreatedHandler = delegate (object sender, SessionEventArgs args) + { + args.Context.CommandExecuting += (_, commandArgs) => safeCommand = commandArgs.SafeCommand; + }; + + disposable.Server.SessionCreated += sessionCreatedHandler; + + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.StartsWith("250-", response); + + response = await rawSmtpClient.SendCommandAsync("AUTH PLAIN AHVzZXIAcGFzc3dvcmQ="); + Assert.StartsWith("535 5.7.8 authentication failed", response); + + disposable.Server.SessionCreated -= sessionCreatedHandler; + } + + Assert.NotNull(safeCommand); + Assert.Equal("AUTH", safeCommand.Name); + Assert.Equal("Plain ", safeCommand.Argument); + Assert.Equal("AUTH Plain ", safeCommand.ToString()); + } + + [Fact] + public async Task LoggerCapturesSessionLifecycleAndCommandsWithSessionScope() + { + var loggerFactory = new TestLoggerFactory(); + + using (CreateServer(services => services.Add(loggerFactory))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.StartsWith("250-", response); + + response = await rawSmtpClient.SendCommandAsync("QUIT"); + Assert.StartsWith("221", response); + + await WaitForLogAsync(loggerFactory, entry => entry.Message.StartsWith("SMTP session completed.")); + } + + Assert.Contains(loggerFactory.Entries, entry => entry.CategoryName == "SmtpServer.SmtpServer" && entry.Message.StartsWith("SMTP server starting")); + Assert.Contains(loggerFactory.Entries, entry => entry.CategoryName == "SmtpServer.SmtpSessionManager" && entry.Message.StartsWith("SMTP session created")); + + var commandEntry = loggerFactory.Entries.First(entry => + entry.CategoryName == "SmtpServer.SmtpSession" && + entry.LogLevel == LogLevel.Debug && + entry.Message.StartsWith("SMTP command executing") && + entry.TryGetStateValue("CommandName", out var commandName) && + (string)commandName == "EHLO"); + + Assert.True(commandEntry.TryGetScopeValue("SessionId", out var sessionId)); + Assert.IsType(sessionId); + Assert.True(commandEntry.TryGetScopeValue("RemoteEndPoint", out var remoteEndPoint)); + Assert.NotNull(remoteEndPoint); + Assert.True(commandEntry.TryGetScopeValue("EndpointPort", out var endpointPort)); + Assert.Equal(9025, endpointPort); + } + + [Fact] + public async Task LoggerRedactsAuthCommandArguments() + { + const string RawAuthParameter = "AHVzZXIAcGFzc3dvcmQ="; + var loggerFactory = new TestLoggerFactory(); + var userAuthenticator = new DelegatingUserAuthenticator((user, password) => true); + + using (CreateServer(endpoint => endpoint.AllowUnsecureAuthentication(), services => + { + services.Add(loggerFactory); + services.Add(userAuthenticator); + })) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.StartsWith("250-", response); + + response = await rawSmtpClient.SendCommandAsync($"AUTH PLAIN {RawAuthParameter}"); + Assert.StartsWith("235 2.7.0", response); + + await WaitForLogAsync(loggerFactory, entry => + entry.TryGetStateValue("CommandName", out var commandName) && + (string)commandName == "AUTH"); + } + + var authEntries = loggerFactory.Entries.Where(entry => + entry.TryGetStateValue("CommandName", out var commandName) && + (string)commandName == "AUTH").ToList(); + + Assert.NotEmpty(authEntries); + Assert.All(authEntries, entry => + { + Assert.True(entry.TryGetStateValue("CommandArgument", out var commandArgument)); + Assert.Equal("Plain ", commandArgument); + Assert.DoesNotContain(RawAuthParameter, entry.Message); + }); + + Assert.Contains(authEntries, entry => + entry.Message.StartsWith("SMTP command executed") && + entry.TryGetScopeValue("IsAuthenticated", out var isAuthenticated) && + (bool)isAuthenticated); + Assert.DoesNotContain(loggerFactory.Entries, entry => entry.Message.Contains(RawAuthParameter)); + } + + [Fact] + public async Task LoggerCapturesResponseExceptionsAndKeepsEvents() + { + var loggerFactory = new TestLoggerFactory(); + var responseExceptionEvents = 0; + var mailboxFilter = new DelegatingMailboxFilter(@from => throw new SmtpResponseException(SmtpResponse.AuthenticationRequired)); + + using (var disposable = CreateServer(services => + { + services.Add(loggerFactory); + services.Add(mailboxFilter); + })) + { + EventHandler sessionCreatedHandler = delegate (object sender, SessionEventArgs args) + { + args.Context.ResponseException += (_, __) => responseExceptionEvents++; + }; + + disposable.Server.SessionCreated += sessionCreatedHandler; + + using var client = MailClient.Client(); + + Assert.Throws(() => client.Send(MailClient.Message())); + client.NoOp(); + + await WaitForLogAsync(loggerFactory, entry => entry.Message.StartsWith("SMTP response exception")); + + disposable.Server.SessionCreated -= sessionCreatedHandler; + } + + Assert.True(responseExceptionEvents > 0); + + var warningEntry = loggerFactory.Entries.First(entry => entry.Message.StartsWith("SMTP response exception")); + Assert.Equal(LogLevel.Warning, warningEntry.LogLevel); + Assert.True(warningEntry.TryGetScopeValue("SessionId", out var sessionId)); + Assert.IsType(sessionId); + Assert.True(warningEntry.TryGetStateValue("ReplyCode", out var replyCode)); + Assert.Equal(SmtpReplyCode.AuthenticationRequired, replyCode); + } + + [Fact] + public void TracingSmtpCommandVisitorRedactsAuthParameter() + { + using var writer = new StringWriter(); + + new TracingSmtpCommandVisitor(writer).Visit(new AuthCommand(AuthenticationMethod.Plain, "AHVzZXIAcGFzc3dvcmQ=")); + + var output = writer.ToString(); + Assert.Contains("", output); + Assert.DoesNotContain("AHVzZXIAcGFzc3dvcmQ=", output); + } + + [Fact] + public async Task EhloAdvertisesSizeWhenMessageSizeLimitIsConfigured() + { + using (CreateServer(c => c.MaxMessageSize(1024))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + + Assert.Contains("250 SIZE 1024\r\n", response); + } + } + + [Fact] + public async Task EhloAdvertisesStartTlsOnlyWhenCertificateIsAvailable() + { + using (CreateServer(endpoint => endpoint.Certificate(CreateCertificate()))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + + Assert.Contains("STARTTLS", response); + } + } + + [Fact] + public async Task EhloAdvertisesAuthOnlyWhenAuthenticationIsAvailableOnCurrentConnection() + { + var userAuthenticator = new DelegatingUserAuthenticator((user, password) => true); + + using (CreateServer(endpoint => endpoint.AllowUnsecureAuthentication(), services => services.Add(userAuthenticator))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + + Assert.Contains("AUTH PLAIN LOGIN", response); + } + } + + [Fact] + public async Task EhloDoesNotAdvertiseAuthOnInsecureConnectionWhenAuthenticationRequiresTls() + { + var userAuthenticator = new DelegatingUserAuthenticator((user, password) => true); + + using (CreateServer(services => services.Add(userAuthenticator))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + + Assert.DoesNotContain("AUTH PLAIN LOGIN", response); + } + } + [Fact] public void CanReceiveBccInMessageTransaction() { @@ -144,6 +572,262 @@ public void CanReceiveBccInMessageTransaction() } } + [Fact] + public async Task CanReturnHelpResponse() + { + using (CreateServer()) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("HELP"); + + Assert.StartsWith("214 2.0.0 Commands:", response); + Assert.Contains("HELP", response); + Assert.Contains("VRFY", response); + Assert.Contains("EXPN", response); + } + } + + [Fact] + public async Task VrfyAndExpnDoNotEnumerateByDefault() + { + using (CreateServer()) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("VRFY user@example.com"); + Assert.Equal("252 2.5.2 cannot VRFY user, but will accept message and attempt delivery\r\n", response); + + response = await rawSmtpClient.SendCommandAsync("EXPN staff"); + Assert.Equal("252 2.5.2 cannot EXPN mailing list\r\n", response); + + response = await rawSmtpClient.SendCommandAsync("NOOP"); + Assert.StartsWith("250 2.0.0 Ok", response); + } + } + + [Fact] + public async Task CanUseCustomSmtpCommandPolicy() + { + var policy = new TestSmtpCommandPolicy(); + + using (CreateServer(services => services.Add(policy))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("HELP VRFY"); + Assert.Equal("214 2.0.0 Custom help for VRFY\r\n", response); + + response = await rawSmtpClient.SendCommandAsync("VRFY user@example.com"); + Assert.Equal("250 2.0.0 user@example.com\r\n", response); + + response = await rawSmtpClient.SendCommandAsync("EXPN staff"); + Assert.Equal("250 2.0.0 member@example.com\r\n", response); + } + } + + [Fact] + public async Task CanReceiveMessageUsingBdatLast() + { + using (CreateServer()) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.Contains("CHUNKING", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + const string message = "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: BDAT\r\n\r\nchunked body\r\n"; + response = await rawSmtpClient.SendBdatAsync($"BDAT {message.Length} LAST", message); + Assert.StartsWith("250 2.0.0 Ok", response); + } + + var stored = Assert.Single(MessageStore.Messages); + Assert.Equal("chunked body", stored.Text()); + } + + [Fact] + public async Task CanReceiveMessageUsingMultipleBdatChunks() + { + using (CreateServer()) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.Contains("CHUNKING", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + const string part1 = "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: BDAT\r\n\r\n"; + const string part2 = "chunked body\r\n"; + + response = await rawSmtpClient.SendBdatAsync($"BDAT {part1.Length}", part1); + Assert.StartsWith("250 2.0.0 Ok", response); + Assert.Empty(MessageStore.Messages); + + response = await rawSmtpClient.SendBdatAsync($"BDAT {part2.Length} LAST", part2); + Assert.StartsWith("250 2.0.0 Ok", response); + } + + var stored = Assert.Single(MessageStore.Messages); + Assert.Equal("chunked body", stored.Text()); + } + + [Fact] + public async Task BufferedBdatPassesBoundedSequenceToMessageStore() + { + var messageStore = new BufferedSequenceInspectingMessageStore(); + + using (CreateServer(services => services.Add(messageStore))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.Contains("CHUNKING", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + var part1 = Encoding.UTF8.GetBytes("From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: BDAT\r\n\r\n"); + var part2 = Encoding.UTF8.GetBytes("bounded buffered body with unicode þæö\r\n"); + + response = await rawSmtpClient.SendBdatAsync($"BDAT {part1.Length}", part1); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendBdatAsync($"BDAT {part2.Length} LAST", part2); + Assert.StartsWith("250 2.0.0 Ok", response); + + var expected = part1.Concat(part2).ToArray(); + + Assert.True(messageStore.SaveCalled); + Assert.Equal(expected.Length, messageStore.BufferLength); + Assert.Equal(expected.Length, messageStore.FirstSegmentLength); + Assert.Equal(expected, messageStore.Message); + } + } + + [Fact] + public async Task CanReceiveMessageUsingStreamingBdatChunks() + { + var streamingMessageStore = new StreamingMockMessageStore(); + + using (CreateServer(services => services.Add(streamingMessageStore))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.Contains("CHUNKING", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + const string part1 = "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: BDAT\r\n\r\n"; + const string part2 = "streamed body\r\n"; + + response = await rawSmtpClient.SendBdatAsync($"BDAT {part1.Length}", part1); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendBdatAsync($"BDAT {part2.Length} LAST", part2); + Assert.StartsWith("250 2.0.0 Ok", response); + } + + Assert.True(streamingMessageStore.StreamingSaveCalled); + Assert.False(streamingMessageStore.BufferedSaveCalled); + Assert.Contains("streamed body", streamingMessageStore.Message); + } + + [Fact] + public async Task BdatHonorsStrictMessageSizeLimit() + { + using (CreateServer(c => c.MaxMessageSize(50, MaxMessageSizeHandling.Strict))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.Contains("CHUNKING", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO:"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendBdatAsync("BDAT 51 LAST", new string('x', 51)); + Assert.Equal("552 5.3.4 message size exceeds fixed maximium message size\r\n", response); + } + + Assert.Empty(MessageStore.Messages); + } + + [Fact] + public async Task CanReceiveDsnEnvelopeParameters() + { + IReadOnlyDictionary filterParameters = null; + var mailboxFilter = new ParameterizedMailboxFilter((context, to, from, parameters, cancellationToken) => + { + filterParameters = parameters; + return Task.FromResult(true); + }); + + using (CreateServer(services => services.Add(mailboxFilter))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO example.com"); + Assert.Contains("DSN", response); + + response = await rawSmtpClient.SendCommandAsync("MAIL FROM: RET=FULL ENVID=abc123"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO: notify=SUCCESS,FAILURE orcpt=rfc822;original@example.com"); + Assert.StartsWith("250 2.0.0 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("DATA"); + Assert.StartsWith("354", response); + + response = await rawSmtpClient.SendCommandAsync("From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: DSN\r\n\r\nbody\r\n."); + Assert.StartsWith("250 2.0.0 Ok", response); + } + + Assert.Single(MessageStore.Messages); + var transaction = MessageStore.Messages[0].Transaction; + Assert.Equal("FULL", transaction.Parameters["ret"]); + Assert.Equal("abc123", transaction.Parameters["ENVID"]); + + var recipient = Assert.Single(transaction.GetRecipients()); + Assert.Equal("recipient@example.com", recipient.Address.AsAddress()); + Assert.Equal("SUCCESS,FAILURE", recipient.Parameters["NOTIFY"]); + Assert.Equal("rfc822;original@example.com", recipient.Parameters["ORCPT"]); + + Assert.NotNull(filterParameters); + Assert.Equal("SUCCESS,FAILURE", filterParameters["notify"]); + Assert.Equal("rfc822;original@example.com", filterParameters["orcpt"]); + } + [Fact(Skip = "Command timeout wont work properly until https://github.com/dotnet/corefx/issues/15033")] public void WillTimeoutWaitingForCommand() { @@ -178,6 +862,34 @@ public void WillTerminateDueToTooMuchData() } } + [Fact] + public async Task MaxMessageSizeDoesNotLimitCommandLine() + { + using (CreateServer(c => c.MaxMessageSize(10, MaxMessageSizeHandling.Strict))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("EHLO command-line.example.com"); + + Assert.StartsWith("250-", response); + } + } + + [Fact] + public async Task MaxCommandLineLengthLimitsCommandLine() + { + using (CreateServer(c => c.MaxCommandLineLength(10))) + using (var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025)) + { + Assert.True(await rawSmtpClient.ConnectAsync()); + + var response = await rawSmtpClient.SendCommandAsync("NOOP too-long"); + + Assert.StartsWith("501 5.5.2 command line length exceeds maximum command line length", response); + } + } + [Fact] public async Task WillSessionTimeoutDuringMailDataTransmission() { @@ -246,7 +958,7 @@ public async Task WillSessionTimeoutDuringMailDataTransmission() } Assert.True(isSessionCancelled, "Smtp session is not cancelled"); - Assert.Equal("554 \r\n221 The session has be cancelled.\r\n", smtpResponse); + Assert.Equal("554 5.0.0\r\n221 2.0.0 The session has be cancelled.\r\n", smtpResponse); Assert.True(stopwatch.Elapsed > sessionTimeout, "SessionTimeout not reached"); } @@ -506,6 +1218,14 @@ public void EndpointListenerWillRaiseEndPointEvents() Assert.True(stopped); } + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void CanNotConfigureInvalidNetworkBufferSize(int value) + { + Assert.Throws(() => new SmtpServerOptionsBuilder().NetworkBufferSize(value)); + } + public static X509Certificate2 CreateSelfSignedCertificate(string subjectName) { var validityPeriodInYears = 1; @@ -646,6 +1366,23 @@ SmtpServerDisposable CreateServer( }); } + static async Task WaitForLogAsync(TestLoggerFactory loggerFactory, Func predicate) + { + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed < TimeSpan.FromSeconds(5)) + { + if (loggerFactory.Entries.Any(predicate)) + { + return; + } + + await Task.Delay(20); + } + + Assert.Fail("The expected log entry was not captured."); + } + /// /// The message store that is being used to store the messages by default. /// @@ -655,5 +1392,110 @@ SmtpServerDisposable CreateServer( /// The cancellation token source for the test. /// public CancellationTokenSource CancellationTokenSource { get; } + + sealed class StreamingMockMessageStore : MessageStore, IStreamingMessageStore + { + public override Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + BufferedSaveCalled = true; + + return Task.FromResult(SmtpResponse.Ok); + } + + public async Task SaveAsync(ISessionContext context, IMessageTransaction transaction, PipeReader reader, CancellationToken cancellationToken) + { + StreamingSaveCalled = true; + + using var stream = new MemoryStream(); + + while (true) + { + var result = await reader.ReadAsync(cancellationToken); + var buffer = result.Buffer; + + foreach (var segment in buffer) + { + stream.Write(segment.Span); + } + + reader.AdvanceTo(buffer.End); + + if (result.IsCompleted) + { + break; + } + } + + Message = Encoding.UTF8.GetString(stream.ToArray()); + + return SmtpResponse.Ok; + } + + public bool BufferedSaveCalled { get; private set; } + + public bool StreamingSaveCalled { get; private set; } + + public string Message { get; private set; } + } + + sealed class BufferedSequenceInspectingMessageStore : MessageStore + { + public override Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + SaveCalled = true; + BufferLength = buffer.Length; + FirstSegmentLength = buffer.First.Length; + Message = buffer.ToArray(); + + return Task.FromResult(SmtpResponse.Ok); + } + + public bool SaveCalled { get; private set; } + + public long BufferLength { get; private set; } + + public int FirstSegmentLength { get; private set; } + + public byte[] Message { get; private set; } + } + + sealed class ParameterizedMailboxFilter : MailboxFilter + { + readonly Func, CancellationToken, Task> _canDeliverDelegate; + + public ParameterizedMailboxFilter( + Func, CancellationToken, Task> canDeliverDelegate) + { + _canDeliverDelegate = canDeliverDelegate; + } + + public override Task CanDeliverToAsync( + ISessionContext context, + IMailbox to, + IMailbox @from, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken) + { + return _canDeliverDelegate(context, to, @from, parameters, cancellationToken); + } + } + + sealed class TestSmtpCommandPolicy : SmtpCommandPolicy + { + public override Task GetHelpAsync(ISessionContext context, string argument, CancellationToken cancellationToken) + { + return Task.FromResult(new SmtpResponse(SmtpReplyCode.HelpResponse, $"Custom help for {argument}")); + } + + public override Task VerifyAsync(ISessionContext context, string argument, CancellationToken cancellationToken) + { + return Task.FromResult(new SmtpResponse(SmtpReplyCode.Ok, argument)); + } + + public override Task ExpandAsync(ISessionContext context, string argument, CancellationToken cancellationToken) + { + return Task.FromResult(new SmtpResponse(SmtpReplyCode.Ok, "member@example.com")); + } + } } } diff --git a/src/SmtpServer/ComponentModel/ServiceProvider.cs b/src/SmtpServer/ComponentModel/ServiceProvider.cs index 2230b531..3239aaa6 100644 --- a/src/SmtpServer/ComponentModel/ServiceProvider.cs +++ b/src/SmtpServer/ComponentModel/ServiceProvider.cs @@ -2,6 +2,7 @@ using SmtpServer.Protocol; using SmtpServer.Storage; using System; +using Microsoft.Extensions.Logging; using SmtpServer.Net; namespace SmtpServer.ComponentModel @@ -19,8 +20,10 @@ public sealed class ServiceProvider : IServiceProvider IEndpointListenerFactory _endpointListenerFactory; IUserAuthenticatorFactory _userAuthenticatorFactory; ISmtpCommandFactory _smtpCommandFactory; + ISmtpCommandPolicyFactory _smtpCommandPolicyFactory; IMailboxFilterFactory _mailboxFilterFactory; IMessageStoreFactory _messageStoreFactory; + ILoggerFactory _loggerFactory; /// /// Service Provider @@ -30,6 +33,7 @@ public ServiceProvider() Add(UserAuthenticator.Default); Add(MailboxFilter.Default); Add(MessageStore.Default); + Add(SmtpCommandPolicy.Default); } /// @@ -68,6 +72,24 @@ public void Add(ISmtpCommandFactory smtpCommandFactory) _smtpCommandFactory = smtpCommandFactory; } + /// + /// Add an instance of the SMTP command policy factory. + /// + /// The SMTP command policy factory. + public void Add(ISmtpCommandPolicyFactory smtpCommandPolicyFactory) + { + _smtpCommandPolicyFactory = smtpCommandPolicyFactory; + } + + /// + /// Add an instance of the SMTP command policy. + /// + /// The SMTP command policy. + public void Add(ISmtpCommandPolicy smtpCommandPolicy) + { + _smtpCommandPolicyFactory = new DelegatingSmtpCommandPolicyFactory(context => smtpCommandPolicy); + } + /// /// Add an instance of the Mailbox Filter Factory. /// @@ -104,6 +126,15 @@ public void Add(IMessageStore messageStore) _messageStoreFactory = new DelegatingMessageStoreFactory(context => messageStore); } + /// + /// Add an instance of the logger factory. + /// + /// The logger factory. + public void Add(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + } + /// /// Gets the service object of the specified type. /// @@ -126,6 +157,11 @@ public object GetService(Type serviceType) return _smtpCommandFactory; } + if (serviceType == typeof(ISmtpCommandPolicyFactory)) + { + return _smtpCommandPolicyFactory; + } + if (serviceType == typeof(IMailboxFilterFactory)) { return _mailboxFilterFactory; @@ -136,6 +172,11 @@ public object GetService(Type serviceType) return _messageStoreFactory; } + if (serviceType == typeof(ILoggerFactory)) + { + return _loggerFactory; + } + throw new NotSupportedException(serviceType.ToString()); } } diff --git a/src/SmtpServer/IMessageRecipient.cs b/src/SmtpServer/IMessageRecipient.cs new file mode 100644 index 00000000..a541153b --- /dev/null +++ b/src/SmtpServer/IMessageRecipient.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using SmtpServer.Mail; + +namespace SmtpServer +{ + /// + /// Message recipient with the parameters supplied on the RCPT command. + /// + public interface IMessageRecipient + { + /// + /// Gets the recipient mailbox address. + /// + IMailbox Address { get; } + + /// + /// Gets the parameters that were supplied for the recipient. + /// + IReadOnlyDictionary Parameters { get; } + } +} diff --git a/src/SmtpServer/IO/PipeReaderExtensions.cs b/src/SmtpServer/IO/PipeReaderExtensions.cs index c0c4f470..fefe6151 100644 --- a/src/SmtpServer/IO/PipeReaderExtensions.cs +++ b/src/SmtpServer/IO/PipeReaderExtensions.cs @@ -15,6 +15,7 @@ internal static class PipeReaderExtensions static readonly byte[] CRLF = { 13, 10 }; static readonly byte[] DotBlock = { 13, 10, 46, 13, 10 }; static readonly byte[] DotBlockStuffing = { 13, 10, 46, 46 }; + const int DotBlockTailLength = 4; /// /// Read from the reader until the sequence is found. @@ -62,6 +63,58 @@ static async ValueTask ReadUntilAsync(PipeReader reader, byte[] sequence, Func + /// Read from the reader until the sequence is found. + /// + /// The reader to read from. + /// The sequence to find to terminate the read operation. + /// The callback to execute to process the buffer. + /// The maximum length to read before the terminator. + /// The cancellation token. + /// The value that was read from the buffer. + static async ValueTask ReadUntilAsync(PipeReader reader, byte[] sequence, Func, Task> func, int maxLength, CancellationToken cancellationToken) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + var read = await reader.ReadAsync(cancellationToken); + var head = read.Buffer.Start; + + while (read.IsCanceled == false && read.IsCompleted == false && read.Buffer.IsEmpty == false) + { + if (read.Buffer.TryFind(sequence, ref head, out var tail)) + { + var line = read.Buffer.Slice(read.Buffer.Start, head); + if (line.Length > maxLength) + { + throw new SmtpResponseException(new SmtpResponse(SmtpReplyCode.SyntaxError, "command line length exceeds maximum command line length"), true); + } + + try + { + await func(line); + } + finally + { + reader.AdvanceTo(tail); + } + + return; + } + + if (read.Buffer.Length > maxLength) + { + throw new SmtpResponseException(new SmtpResponse(SmtpReplyCode.SyntaxError, "command line length exceeds maximum command line length"), true); + } + + reader.AdvanceTo(read.Buffer.Start, read.Buffer.End); + + read = await reader.ReadAsync(cancellationToken); + } + } + /// /// Reads a line from the reader. /// @@ -80,6 +133,24 @@ internal static ValueTask ReadLineAsync(this PipeReader reader, Func + /// Reads a command line from the reader. + /// + /// The reader to read from. + /// The action to process the buffer. + /// The maximum line length in bytes, excluding the terminating CRLF. + /// The cancellation token. + /// A task that can be used to wait on the operation on complete. + internal static ValueTask ReadLineAsync(this PipeReader reader, Func, Task> func, int maxLineLength, CancellationToken cancellationToken = default) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + return ReadUntilAsync(reader, CRLF, func, maxLineLength, cancellationToken); + } + /// /// Reads a line from the reader. /// @@ -97,6 +168,23 @@ internal static ValueTask ReadLineAsync(this PipeReader reader, IMaxMess return reader.ReadLineAsync(Encoding.ASCII, maxMessageSizeOptions, cancellationToken); } + /// + /// Reads a command line from the reader. + /// + /// The reader to read from. + /// The maximum line length in bytes, excluding the terminating CRLF. + /// The cancellation token. + /// A task that can be used to wait on the operation on complete. + internal static ValueTask ReadLineAsync(this PipeReader reader, int maxLineLength, CancellationToken cancellationToken = default) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + return reader.ReadLineAsync(Encoding.ASCII, maxLineLength, cancellationToken); + } + /// /// Reads a line from the reader. /// @@ -127,6 +215,36 @@ await reader.ReadLineAsync( return text; } + /// + /// Reads a command line from the reader. + /// + /// The reader to read from. + /// The encoding to use when converting the input. + /// The maximum line length in bytes, excluding the terminating CRLF. + /// The cancellation token. + /// A task that can be used to wait on the operation on complete. + internal static async ValueTask ReadLineAsync(this PipeReader reader, Encoding encoding, int maxLineLength, CancellationToken cancellationToken = default) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + var text = string.Empty; + + await reader.ReadLineAsync( + buffer => + { + text = StringUtil.Create(buffer, encoding); + + return Task.CompletedTask; + }, + maxLineLength, + cancellationToken); + + return text; + } + /// /// Reads a line from the reader. /// @@ -177,5 +295,139 @@ static ReadOnlySequence Unstuff(ReadOnlySequence buffer) return segments.Build(); } } + + /// + /// Reads a dot block from the reader and writes the unstuffed content to the writer. + /// + /// The reader to read from. + /// The writer to stream the message content to. + /// Handling of MaxMessageSize. + /// The cancellation token. + /// The value that was read from the buffer. + internal static async ValueTask ReadDotBlockAsync(this PipeReader reader, PipeWriter writer, IMaxMessageSizeOptions maxMessageSizeOptions, CancellationToken cancellationToken = default) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + Exception error = null; + + try + { + await ReadDotBlockAsync(reader, writer, maxMessageSizeOptions, cancellationToken, DotBlockTailLength).ConfigureAwait(false); + } + catch (Exception exception) + { + error = exception; + throw; + } + finally + { + writer.Complete(error); + } + } + + static async ValueTask ReadDotBlockAsync(PipeReader reader, PipeWriter writer, IMaxMessageSizeOptions maxMessageSizeOptions, CancellationToken cancellationToken, int tailLength) + { + long consumedLength = 0; + + while (true) + { + var read = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + var buffer = read.Buffer; + + if (buffer.IsEmpty && read.IsCompleted) + { + return; + } + + var head = buffer.GetPosition(0); + if (buffer.TryFind(DotBlock, ref head, out var tail)) + { + var body = buffer.Slice(buffer.Start, head); + + EnsureMessageSize(maxMessageSizeOptions, consumedLength + body.Length); + + await WriteUnstuffedAsync(writer, body, cancellationToken).ConfigureAwait(false); + + reader.AdvanceTo(tail); + return; + } + + if (read.IsCompleted) + { + reader.AdvanceTo(buffer.End); + return; + } + + if (buffer.Length > tailLength) + { + var safeLength = buffer.Length - tailLength; + var safeBuffer = buffer.Slice(0, safeLength); + + EnsureMessageSize(maxMessageSizeOptions, consumedLength + safeBuffer.Length); + + await WriteUnstuffedAsync(writer, safeBuffer, cancellationToken).ConfigureAwait(false); + + consumedLength += safeBuffer.Length; + + reader.AdvanceTo(buffer.GetPosition(safeLength), buffer.End); + continue; + } + + reader.AdvanceTo(buffer.Start, buffer.End); + } + } + + static void EnsureMessageSize(IMaxMessageSizeOptions maxMessageSizeOptions, long length) + { + if (maxMessageSizeOptions.Handling == MaxMessageSizeHandling.Strict && length > maxMessageSizeOptions.Length) + { + throw new SmtpResponseException(SmtpResponse.MaxMessageSizeExceeded, true); + } + } + + static async ValueTask WriteUnstuffedAsync(PipeWriter writer, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + var head = buffer.GetPosition(0); + var start = head; + + while (buffer.TryFind(DotBlockStuffing, ref head, out var tail)) + { + var slice = buffer.Slice(start, buffer.GetPosition(3, head)); + + Write(writer, slice); + + start = tail; + head = tail; + } + + Write(writer, buffer.Slice(start)); + + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + static void Write(PipeWriter writer, ReadOnlySequence buffer) + { + var position = buffer.GetPosition(0); + + while (buffer.TryGet(ref position, out var memory)) + { + if (memory.Length == 0) + { + continue; + } + + var span = writer.GetSpan(memory.Length); + memory.Span.CopyTo(span); + writer.Advance(memory.Length); + } + } } } diff --git a/src/SmtpServer/IO/PipeWriterExtensions.cs b/src/SmtpServer/IO/PipeWriterExtensions.cs index 767e12a6..ca83ace8 100644 --- a/src/SmtpServer/IO/PipeWriterExtensions.cs +++ b/src/SmtpServer/IO/PipeWriterExtensions.cs @@ -70,7 +70,18 @@ public static ValueTask WriteReplyAsync(this PipeWriter writer, Smt throw new ArgumentNullException(nameof(writer)); } - writer.WriteLine($"{(int)response.ReplyCode} {response.Message}"); + if (response.EnhancedStatusCode.HasValue) + { + var enhancedStatusCode = response.EnhancedStatusCode.Value; + + writer.WriteLine(string.IsNullOrEmpty(response.Message) + ? $"{(int)response.ReplyCode} {enhancedStatusCode}" + : $"{(int)response.ReplyCode} {enhancedStatusCode} {response.Message}"); + } + else + { + writer.WriteLine($"{(int)response.ReplyCode} {response.Message}"); + } return writer.FlushAsync(cancellationToken); } diff --git a/src/SmtpServer/IO/SecurableDuplexPipe.cs b/src/SmtpServer/IO/SecurableDuplexPipe.cs index cc6a278e..4f3a6a20 100644 --- a/src/SmtpServer/IO/SecurableDuplexPipe.cs +++ b/src/SmtpServer/IO/SecurableDuplexPipe.cs @@ -12,6 +12,7 @@ namespace SmtpServer.IO internal sealed class SecurableDuplexPipe : ISecurableDuplexPipe { readonly Action _disposeAction; + readonly int _networkBufferSize; Stream _stream; bool _disposed; @@ -19,13 +20,15 @@ internal sealed class SecurableDuplexPipe : ISecurableDuplexPipe /// Constructor. /// /// The stream that the pipe is reading and writing to. + /// The size of the buffer to use when reading from the stream. /// The action to execute when the stream has been disposed. - internal SecurableDuplexPipe(Stream stream, Action disposeAction) + internal SecurableDuplexPipe(Stream stream, int networkBufferSize, Action disposeAction) { _stream = stream; + _networkBufferSize = networkBufferSize; _disposeAction = disposeAction; - Input = PipeReader.Create(_stream); + Input = PipeReader.Create(_stream, new StreamPipeReaderOptions(bufferSize: networkBufferSize)); Output = PipeWriter.Create(_stream); } @@ -54,7 +57,7 @@ await sslStream.AuthenticateAsServerAsync( _stream = sslStream; - Input = PipeReader.Create(_stream); + Input = PipeReader.Create(_stream, new StreamPipeReaderOptions(bufferSize: _networkBufferSize)); Output = PipeWriter.Create(_stream); } diff --git a/src/SmtpServer/IParameterizedMessageTransaction.cs b/src/SmtpServer/IParameterizedMessageTransaction.cs new file mode 100644 index 00000000..1fc54dd9 --- /dev/null +++ b/src/SmtpServer/IParameterizedMessageTransaction.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace SmtpServer +{ + /// + /// Optional message transaction interface for recipient-specific parameters. + /// + public interface IParameterizedMessageTransaction + { + /// + /// Gets the accepted recipients and their RCPT command parameters. + /// + IReadOnlyList Recipients { get; } + } +} diff --git a/src/SmtpServer/ISmtpServerOptions.cs b/src/SmtpServer/ISmtpServerOptions.cs index 01a62663..d455ea8a 100644 --- a/src/SmtpServer/ISmtpServerOptions.cs +++ b/src/SmtpServer/ISmtpServerOptions.cs @@ -13,6 +13,11 @@ public interface ISmtpServerOptions /// IMaxMessageSizeOptions MaxMessageSizeOptions { get; } + /// + /// Gets the maximum SMTP command line length in bytes, excluding the terminating CRLF. + /// + int MaxCommandLineLength { get; } + /// /// The maximum number of retries before quitting the session. /// @@ -28,6 +33,16 @@ public interface ISmtpServerOptions /// string ServerName { get; } + /// + /// Gets the SMTP extension options. + /// + SmtpServerExtensionOptions Extensions { get; } + + /// + /// Gets the SMTP session policy options. + /// + SmtpServerSessionPolicyOptions SessionPolicy { get; } + /// /// Gets the collection of endpoints to listen on. /// diff --git a/src/SmtpServer/Jenkinsfile b/src/SmtpServer/Jenkinsfile new file mode 100644 index 00000000..2c0eabd8 --- /dev/null +++ b/src/SmtpServer/Jenkinsfile @@ -0,0 +1,99 @@ +pipeline { + agent { label 'dotnet' } + + parameters { + booleanParam( + name: 'PUSH_NUGET', + defaultValue: false, + description: 'Push generated NuGet packages to nuget.devop.is after pack' + ) + } + + environment { + PACKAGE_PROJECT = './src/SmtpServer/SmtpServer.csproj' + TEST_PROJECT = './src/SmtpServer.Tests/SmtpServer.Tests.csproj' + CONFIG = 'Release' + NUGET_OUTPUT = './nupkg' + NUGET_SOURCE = 'https://nuget.devop.is/v3/index.json' + DOTNET_CLI_TELEMETRY_OPTOUT = '1' + DOTNET_NOLOGO = 'true' + } + + stages { + stage('Build, Test & Pack') { + steps { + container('dotnet') { + // MinVer derives the package version from the git tag (prefix in the csproj). The library's + // own dependencies come from nuget.org, but the devop-marketplace source still needs its + // basic-auth credential injected here: the feed requires auth to read its service index, + // so the push stage (which reuses this nuget.config) cannot even reach it otherwise. + withCredentials([usernamePassword( + credentialsId: 'devop-nuget-credentials', + usernameVariable: 'DEVOP_NUGET_USER', + passwordVariable: 'DEVOP_NUGET_PASSWORD' + )]) { + sh ''' + git config --global --add safe.directory '*' 2>/dev/null || printf '[safe]\n\tdirectory = *\n' >> ~/.gitconfig + + TAG_PREFIX=$(grep -o '[^<]*' "$PACKAGE_PROJECT" | sed 's///') + VERSION_PROP='' + if [ -n "$TAG_PREFIX" ]; then + LATEST_TAG=$(git describe --tags --match "${TAG_PREFIX}*" --abbrev=0 2>/dev/null || echo '') + if [ -n "$LATEST_TAG" ]; then + PKG_VERSION="${LATEST_TAG#$TAG_PREFIX}" + echo "Resolved version $PKG_VERSION from tag $LATEST_TAG" + VERSION_PROP="-p:MinVerVersionOverride=$PKG_VERSION" + else + echo "No tag found for prefix '$TAG_PREFIX' -- MinVer will calculate from history" + fi + fi + + dotnet --info + dotnet nuget update source devop-marketplace \ + --source "$NUGET_SOURCE" \ + --username "$DEVOP_NUGET_USER" \ + --password "$DEVOP_NUGET_PASSWORD" \ + --store-password-in-clear-text \ + --valid-authentication-types basic \ + --configfile ./nuget.config + dotnet restore "$PACKAGE_PROJECT" --configfile ./nuget.config -v:minimal /nr:false + dotnet restore "$TEST_PROJECT" --configfile ./nuget.config -v:minimal /nr:false + dotnet build "$PACKAGE_PROJECT" -c "$CONFIG" --no-restore $VERSION_PROP -v:minimal /nr:false + dotnet test "$TEST_PROJECT" -c "$CONFIG" --no-restore $VERSION_PROP --logger trx --results-directory ./TestResults -v:minimal /nr:false + dotnet pack "$PACKAGE_PROJECT" -c "$CONFIG" --no-restore -o "$NUGET_OUTPUT" $VERSION_PROP -v:minimal /nr:false + ''' + } + } + } + } + + stage('Push NuGet Package') { + when { + expression { params.PUSH_NUGET } + } + steps { + container('dotnet') { + withCredentials([string(credentialsId: 'devop-nuget', variable: 'NUGET_API_KEY')]) { + sh ''' + for pkg in ./nupkg/*.nupkg; do + echo "Pushing $pkg to nuget.devop.is" + dotnet nuget push "$pkg" \ + --source "$NUGET_SOURCE" \ + --api-key "$NUGET_API_KEY" \ + --skip-duplicate + done + ''' + } + } + } + } + } + + post { + always { + archiveArtifacts artifacts: 'nupkg/**', fingerprint: true, allowEmptyArchive: true + archiveArtifacts artifacts: 'TestResults/**', fingerprint: true, allowEmptyArchive: true + jiraSendBuildInfo site: 'vlink-team.atlassian.net' + } + } +} diff --git a/src/SmtpServer/Logging/SmtpLoggerFactory.cs b/src/SmtpServer/Logging/SmtpLoggerFactory.cs new file mode 100644 index 00000000..bcc0f50b --- /dev/null +++ b/src/SmtpServer/Logging/SmtpLoggerFactory.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using SmtpServer.ComponentModel; +using SmtpServer.Net; + +namespace SmtpServer.Logging +{ + internal static class SmtpLoggerFactory + { + internal static ILoggerFactory Resolve(IServiceProvider serviceProvider) + { + if (serviceProvider == null) + { + return NullLoggerFactory.Instance; + } + + try + { + return serviceProvider.GetServiceOrDefault(NullLoggerFactory.Instance); + } + catch (NotSupportedException) + { + return NullLoggerFactory.Instance; + } + } + + internal static IReadOnlyList> CreateSessionScope(SmtpSessionContext context) + { + return new SmtpSessionLogScope(context); + } + + static object TryGetProperty(SmtpSessionContext context, string key) + { + return context.Properties.TryGetValue(key, out var value) ? value : null; + } + + sealed class SmtpSessionLogScope : IReadOnlyList> + { + static readonly string[] Names = + { + "SessionId", + "LocalEndPoint", + "RemoteEndPoint", + "EndpointPort", + "IsSecure", + "SslProtocol", + "IsAuthenticated" + }; + + readonly SmtpSessionContext _context; + + public SmtpSessionLogScope(SmtpSessionContext context) + { + _context = context; + } + + public KeyValuePair this[int index] => new KeyValuePair(Names[index], GetValue(Names[index])); + + public int Count => Names.Length; + + public IEnumerator> GetEnumerator() + { + for (var i = 0; i < Count; i++) + { + yield return this[i]; + } + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + object GetValue(string name) + { + switch (name) + { + case "SessionId": + return _context.SessionId; + + case "LocalEndPoint": + return TryGetProperty(_context, EndpointListener.LocalEndPointKey); + + case "RemoteEndPoint": + return TryGetProperty(_context, EndpointListener.RemoteEndPointKey); + + case "EndpointPort": + return _context.EndpointDefinition.Endpoint.Port; + + case "IsSecure": + return _context.Pipe?.IsSecure ?? _context.EndpointDefinition.IsSecure; + + case "SslProtocol": + return _context.Pipe?.SslProtocol.ToString(); + + case "IsAuthenticated": + return _context.Authentication.IsAuthenticated; + + default: + return null; + } + } + } + } +} diff --git a/src/SmtpServer/MessageTransactionExtensions.cs b/src/SmtpServer/MessageTransactionExtensions.cs new file mode 100644 index 00000000..442726af --- /dev/null +++ b/src/SmtpServer/MessageTransactionExtensions.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace SmtpServer +{ + /// + /// Extension methods for message transactions. + /// + public static class MessageTransactionExtensions + { + /// + /// Gets recipient-specific parameters when the transaction provides them. + /// + /// The message transaction. + /// The accepted recipients and their RCPT command parameters. + public static IReadOnlyList GetRecipients(this IMessageTransaction transaction) + { + if (transaction is IParameterizedMessageTransaction parameterized) + { + return parameterized.Recipients; + } + + return Array.Empty(); + } + } +} diff --git a/src/SmtpServer/Net/EndpointListener.cs b/src/SmtpServer/Net/EndpointListener.cs index c7d2946e..543563ec 100644 --- a/src/SmtpServer/Net/EndpointListener.cs +++ b/src/SmtpServer/Net/EndpointListener.cs @@ -51,7 +51,7 @@ public async Task GetPipeAsync(ISessionContext context, Ca var stream = tcpClient.GetStream(); - return new SecurableDuplexPipe(stream, () => + return new SecurableDuplexPipe(stream, context.ServerOptions.NetworkBufferSize, () => { try { diff --git a/src/SmtpServer/Protocol/AuthCommand.cs b/src/SmtpServer/Protocol/AuthCommand.cs index 0c84e310..2e66663a 100644 --- a/src/SmtpServer/Protocol/AuthCommand.cs +++ b/src/SmtpServer/Protocol/AuthCommand.cs @@ -1,7 +1,6 @@ using System; using System.IO.Pipelines; using System.Text; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using SmtpServer.Authentication; @@ -62,6 +61,22 @@ internal override async Task ExecuteAsync(SmtpSessionContext context, Canc return false; } break; + + case AuthenticationMethod.XOAuth2: + if (await TryOAuthAsync(context, xOAuth2: true, cancellationToken).ConfigureAwait(false) == false) + { + await context.Pipe.Output.WriteReplyAsync(SmtpResponse.AuthenticationFailed, cancellationToken).ConfigureAwait(false); + return false; + } + break; + + case AuthenticationMethod.OAuthBearer: + if (await TryOAuthAsync(context, xOAuth2: false, cancellationToken).ConfigureAwait(false) == false) + { + await context.Pipe.Output.WriteReplyAsync(SmtpResponse.AuthenticationFailed, cancellationToken).ConfigureAwait(false); + return false; + } + break; } var userAuthenticator = context.ServiceProvider.GetService(context, UserAuthenticator.Default); @@ -106,12 +121,11 @@ async Task TryPlainAsync(ISessionContext context, CancellationToken cancel { await context.Pipe.Output.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.ContinueWithAuth, " "), cancellationToken).ConfigureAwait(false); - authentication = await context.Pipe.Input.ReadLineAsync(Encoding.ASCII, context.ServerOptions.MaxMessageSizeOptions, cancellationToken).ConfigureAwait(false); + authentication = await context.Pipe.Input.ReadLineAsync(Encoding.ASCII, context.ServerOptions.MaxCommandLineLength, cancellationToken).ConfigureAwait(false); } if (TryExtractFromBase64(authentication) == false) { - await context.Pipe.Output.WriteReplyAsync(SmtpResponse.AuthenticationFailed, cancellationToken).ConfigureAwait(false); return false; } @@ -125,15 +139,36 @@ async Task TryPlainAsync(ISessionContext context, CancellationToken cancel /// true if the user name and password were extracted from the base64 encoded string, false if not. bool TryExtractFromBase64(string base64) { - var match = Regex.Match(Encoding.UTF8.GetString(Convert.FromBase64String(base64)), "\x0000(?.*)\x0000(?.*)"); - - if (match.Success == false) + if (TryDecodeBase64(base64, out var buffer, out var bytesWritten) == false) { return false; } - _user = match.Groups["user"].Value; - _password = match.Groups["password"].Value; + try + { + var decoded = new ReadOnlySpan(buffer, 0, bytesWritten); + + var userStart = decoded.IndexOf((byte)0); + if (userStart < 0) + { + return false; + } + + var passwordStart = decoded.Slice(userStart + 1).IndexOf((byte)0); + if (passwordStart < 0) + { + return false; + } + + passwordStart += userStart + 1; + + _user = Encoding.UTF8.GetString(buffer, userStart + 1, passwordStart - userStart - 1); + _password = Encoding.UTF8.GetString(buffer, passwordStart + 1, bytesWritten - passwordStart - 1); + } + finally + { + Array.Clear(buffer, 0, bytesWritten); + } return true; } @@ -148,35 +183,116 @@ async Task TryLoginAsync(ISessionContext context, CancellationToken cancel { if (string.IsNullOrWhiteSpace(Parameter) == false) { - _user = Encoding.UTF8.GetString(Convert.FromBase64String(Parameter)); + if (TryDecodeBase64String(Parameter, out _user) == false) + { + return false; + } } else { //Username = VXNlcm5hbWU6 (base64) await context.Pipe.Output.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.ContinueWithAuth, "VXNlcm5hbWU6"), cancellationToken).ConfigureAwait(false); - _user = await ReadBase64EncodedLineAsync(context.Pipe.Input, context.ServerOptions.MaxMessageSizeOptions, cancellationToken).ConfigureAwait(false); + _user = await ReadBase64EncodedLineAsync(context.Pipe.Input, context.ServerOptions.MaxCommandLineLength, cancellationToken).ConfigureAwait(false); + if (_user == null) + { + return false; + } } //Password = UGFzc3dvcmQ6 (base64) await context.Pipe.Output.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.ContinueWithAuth, "UGFzc3dvcmQ6"), cancellationToken).ConfigureAwait(false); - _password = await ReadBase64EncodedLineAsync(context.Pipe.Input, context.ServerOptions.MaxMessageSizeOptions, cancellationToken).ConfigureAwait(false); + _password = await ReadBase64EncodedLineAsync(context.Pipe.Input, context.ServerOptions.MaxCommandLineLength, cancellationToken).ConfigureAwait(false); + if (_password == null) + { + return false; + } return true; } + /// + /// Attempt an XOAUTH2 or OAUTHBEARER bearer-token exchange. The decoded bearer token is surfaced to + /// the authenticator as the password and the SASL identity as the user, so a host validates the token + /// exactly as it validates any other credential — no live identity-provider call happens here. + /// + /// The execution context to operate on. + /// true to decode the XOAUTH2 blob, false to decode the OAUTHBEARER blob. + /// The cancellation token. + /// true if a user and bearer token were decoded, false if not. + async Task TryOAuthAsync(ISessionContext context, bool xOAuth2, CancellationToken cancellationToken) + { + var response = Parameter; + + if (string.IsNullOrWhiteSpace(response)) + { + await context.Pipe.Output.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.ContinueWithAuth, " "), cancellationToken).ConfigureAwait(false); + + response = await context.Pipe.Input.ReadLineAsync(Encoding.ASCII, context.ServerOptions.MaxCommandLineLength, cancellationToken).ConfigureAwait(false); + } + + return xOAuth2 + ? OAuthSaslDecoder.TryDecodeXOAuth2(response, out _user, out _password) + : OAuthSaslDecoder.TryDecodeOAuthBearer(response, out _user, out _password); + } + /// /// Read a Base64 encoded line. /// /// The pipe to read from. + /// The maximum command line length. /// The cancellation token. - /// The decoded Base64 string. - static async Task ReadBase64EncodedLineAsync(PipeReader reader, IMaxMessageSizeOptions maxMessageSizeOptions, CancellationToken cancellationToken) + /// The decoded Base64 string, or null when the line is invalid. + static async Task ReadBase64EncodedLineAsync(PipeReader reader, int maxLineLength, CancellationToken cancellationToken) + { + var text = await reader.ReadLineAsync(maxLineLength, cancellationToken); + + return TryDecodeBase64String(text, out var value) ? value : null; + } + + static bool TryDecodeBase64String(string text, out string value) + { + value = null; + + if (TryDecodeBase64(text, out var buffer, out var bytesWritten) == false) + { + return false; + } + + try + { + value = Encoding.UTF8.GetString(buffer, 0, bytesWritten); + } + finally + { + Array.Clear(buffer, 0, bytesWritten); + } + + return true; + } + + static bool TryDecodeBase64(string text, out byte[] buffer, out int bytesWritten) { - var text = await reader.ReadLineAsync(maxMessageSizeOptions, cancellationToken); + buffer = null; + bytesWritten = 0; - return text == null ? string.Empty : Encoding.UTF8.GetString(Convert.FromBase64String(text)); + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + var maxByteCount = text.Length; + buffer = new byte[maxByteCount]; + + if (Convert.TryFromBase64String(text, buffer, out bytesWritten) == false) + { + Array.Clear(buffer, 0, buffer.Length); + buffer = null; + return false; + } + + return true; } /// diff --git a/src/SmtpServer/Protocol/AuthenticationMethod.cs b/src/SmtpServer/Protocol/AuthenticationMethod.cs index bb528a7c..7ac1bb8e 100644 --- a/src/SmtpServer/Protocol/AuthenticationMethod.cs +++ b/src/SmtpServer/Protocol/AuthenticationMethod.cs @@ -13,6 +13,16 @@ public enum AuthenticationMethod /// /// Plain /// - Plain + Plain, + + /// + /// XOAUTH2 — the bearer-token SASL mechanism used by common mail clients (Google/Microsoft style). + /// + XOAuth2, + + /// + /// OAUTHBEARER — the RFC 7628 bearer-token SASL mechanism. + /// + OAuthBearer } } diff --git a/src/SmtpServer/Protocol/BdatCommand.cs b/src/SmtpServer/Protocol/BdatCommand.cs new file mode 100644 index 00000000..b92c2698 --- /dev/null +++ b/src/SmtpServer/Protocol/BdatCommand.cs @@ -0,0 +1,302 @@ +using System; +using System.Buffers; +using System.IO; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using SmtpServer.ComponentModel; +using SmtpServer.IO; +using SmtpServer.Storage; + +namespace SmtpServer.Protocol +{ + /// + /// Bdat Command + /// + public sealed class BdatCommand : SmtpCommand + { + internal const string LastChunkKey = "SmtpServer:Bdat:LastChunk"; + const string TransactionKey = "SmtpServer:Bdat:Transaction"; + + /// + /// Smtp Bdat Command + /// + public const string Command = "BDAT"; + + /// + /// Constructor. + /// + /// The chunk size. + /// Whether this is the last chunk. + public BdatCommand(long size, bool isLast) : base(Command) + { + Size = size; + IsLast = isLast; + } + + /// + internal override async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellationToken) + { + if (context.ServerOptions.Extensions.ChunkingEnabled == false) + { + await context.Pipe.Output.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.CommandNotImplemented, "CHUNKING is not enabled"), cancellationToken).ConfigureAwait(false); + return false; + } + + context.Properties[LastChunkKey] = IsLast; + + if (context.Transaction.To.Count == 0) + { + await context.Pipe.Output.WriteReplyAsync(SmtpResponse.NoValidRecipientsGiven, cancellationToken).ConfigureAwait(false); + return false; + } + + var transaction = GetOrCreateTransaction(context); + + try + { + if (WouldExceedMessageSize(context, transaction.Length + Size)) + { + await ReadChunkAsync(context.Pipe.Input, Size, null, cancellationToken).ConfigureAwait(false); + await AbortAsync(context).ConfigureAwait(false); + throw new SmtpResponseException(SmtpResponse.MaxMessageSizeExceeded, true); + } + + await transaction.AppendAsync(context.Pipe.Input, Size, cancellationToken).ConfigureAwait(false); + + if (IsLast == false) + { + await context.Pipe.Output.WriteReplyAsync(SmtpResponse.Ok, cancellationToken).ConfigureAwait(false); + return true; + } + + var response = await CompleteTransactionAsync(context, transaction, cancellationToken).ConfigureAwait(false); + await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + return true; + } + catch (SmtpResponseException) + { + throw; + } + catch (Exception) + { + await AbortAsync(context).ConfigureAwait(false); + await context.Pipe.Output.WriteReplyAsync(new SmtpResponse(SmtpReplyCode.TransactionFailed), cancellationToken).ConfigureAwait(false); + return false; + } + } + + static bool WouldExceedMessageSize(SmtpSessionContext context, long length) + { + return context.ServerOptions.MaxMessageSizeOptions.Handling == MaxMessageSizeHandling.Strict + && length > context.ServerOptions.MaxMessageSizeOptions.Length; + } + + static BdatTransaction GetOrCreateTransaction(SmtpSessionContext context) + { + if (context.Properties.TryGetValue(TransactionKey, out var value) && value is BdatTransaction transaction) + { + return transaction; + } + + var messageStore = context.ServiceProvider.GetService(context, MessageStore.Default); + transaction = new BdatTransaction(context, messageStore); + context.Properties[TransactionKey] = transaction; + + return transaction; + } + + static async Task CompleteTransactionAsync(SmtpSessionContext context, BdatTransaction transaction, CancellationToken cancellationToken) + { + try + { + return await transaction.CompleteAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + context.Properties.Remove(TransactionKey); + } + } + + internal static async Task AbortAsync(SmtpSessionContext context) + { + context.Properties.Remove(LastChunkKey); + + if (context.Properties.TryGetValue(TransactionKey, out var value) && value is BdatTransaction transaction) + { + context.Properties.Remove(TransactionKey); + await transaction.AbortAsync().ConfigureAwait(false); + } + } + + static async Task ReadChunkAsync(PipeReader reader, long size, Func, ValueTask> writeAsync, CancellationToken cancellationToken) + { + var remaining = size; + + while (remaining > 0) + { + var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + var buffer = result.Buffer; + + if (buffer.IsEmpty && result.IsCompleted) + { + throw new SmtpResponseException(new SmtpResponse(SmtpReplyCode.TransactionFailed), true); + } + + var length = Math.Min(buffer.Length, remaining); + var chunk = buffer.Slice(0, length); + + if (writeAsync != null) + { + await writeAsync(chunk).ConfigureAwait(false); + } + + remaining -= length; + reader.AdvanceTo(buffer.GetPosition(length)); + } + } + + static void Write(PipeWriter writer, ReadOnlySequence buffer) + { + var position = buffer.GetPosition(0); + + while (buffer.TryGet(ref position, out var memory)) + { + if (memory.Length == 0) + { + continue; + } + + var span = writer.GetSpan(memory.Length); + memory.Span.CopyTo(span); + writer.Advance(memory.Length); + } + } + + /// + /// Gets the chunk size. + /// + public long Size { get; } + + /// + /// Gets a value indicating whether this is the last chunk. + /// + public bool IsLast { get; } + + sealed class BdatTransaction + { + readonly SmtpSessionContext _context; + readonly IMessageStore _messageStore; + readonly IStreamingMessageStore _streamingMessageStore; + readonly MemoryStream _buffer; + readonly Pipe _pipe; + readonly Task _saveTask; + + public BdatTransaction(SmtpSessionContext context, IMessageStore messageStore) + { + _context = context; + _messageStore = messageStore; + _streamingMessageStore = messageStore as IStreamingMessageStore; + + if (_streamingMessageStore == null) + { + _buffer = new MemoryStream(); + } + else + { + _pipe = new Pipe(); + _saveTask = _streamingMessageStore.SaveAsync(_context, _context.Transaction, _pipe.Reader, CancellationToken.None); + } + } + + public long Length { get; private set; } + + public async Task AppendAsync(PipeReader reader, long size, CancellationToken cancellationToken) + { + if (_streamingMessageStore == null) + { + await ReadChunkAsync( + reader, + size, + chunk => + { + foreach (var segment in chunk) + { + _buffer.Write(segment.Span); + } + + return default; + }, + cancellationToken).ConfigureAwait(false); + } + else + { + await ReadChunkAsync( + reader, + size, + async chunk => + { + Write(_pipe.Writer, chunk); + await _pipe.Writer.FlushAsync(cancellationToken).ConfigureAwait(false); + }, + cancellationToken).ConfigureAwait(false); + } + + Length += size; + } + + public async Task CompleteAsync(CancellationToken cancellationToken) + { + if (_streamingMessageStore == null) + { + var buffer = CreateBufferedSequence(_buffer); + return await _messageStore.SaveAsync(_context, _context.Transaction, buffer, cancellationToken).ConfigureAwait(false); + } + + await _pipe.Writer.CompleteAsync().ConfigureAwait(false); + try + { + return await _saveTask.ConfigureAwait(false); + } + finally + { + await _pipe.Reader.CompleteAsync().ConfigureAwait(false); + } + } + + public async Task AbortAsync() + { + if (_streamingMessageStore == null) + { + _buffer.Dispose(); + return; + } + + await _pipe.Writer.CompleteAsync().ConfigureAwait(false); + + try + { + await _saveTask.ConfigureAwait(false); + } + catch + { + // The caller is already handling the BDAT failure path. + } + finally + { + await _pipe.Reader.CompleteAsync().ConfigureAwait(false); + } + } + + static ReadOnlySequence CreateBufferedSequence(MemoryStream buffer) + { + if (buffer.TryGetBuffer(out var segment) == false) + { + return new ReadOnlySequence(buffer.ToArray()); + } + + return new ReadOnlySequence(segment.Array, segment.Offset, segment.Count); + } + } + } +} diff --git a/src/SmtpServer/Protocol/DataCommand.cs b/src/SmtpServer/Protocol/DataCommand.cs index b30ce23c..7df894a8 100644 --- a/src/SmtpServer/Protocol/DataCommand.cs +++ b/src/SmtpServer/Protocol/DataCommand.cs @@ -1,4 +1,5 @@ using System; +using System.IO.Pipelines; using System.Threading; using System.Threading.Tasks; using SmtpServer.ComponentModel; @@ -45,16 +46,9 @@ internal override async Task ExecuteAsync(SmtpSessionContext context, Canc { using var container = new DisposableContainer(messageStore); - SmtpResponse response = null; - - await context.Pipe.Input.ReadDotBlockAsync( - async buffer => - { - // ReSharper disable once AccessToDisposedClosure - response = await container.Instance.SaveAsync(context, context.Transaction, buffer, cancellationToken).ConfigureAwait(false); - }, - context.ServerOptions.MaxMessageSizeOptions, - cancellationToken).ConfigureAwait(false); + var response = container.Instance is IStreamingMessageStore streamingMessageStore + ? await SaveAsync(streamingMessageStore, context, cancellationToken).ConfigureAwait(false) + : await SaveAsync(container.Instance, context, cancellationToken).ConfigureAwait(false); await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); } @@ -70,5 +64,39 @@ await context.Pipe.Input.ReadDotBlockAsync( return true; } + + static async Task SaveAsync(IMessageStore messageStore, SmtpSessionContext context, CancellationToken cancellationToken) + { + SmtpResponse response = null; + + await context.Pipe.Input.ReadDotBlockAsync( + async buffer => + { + response = await messageStore.SaveAsync(context, context.Transaction, buffer, cancellationToken).ConfigureAwait(false); + }, + context.ServerOptions.MaxMessageSizeOptions, + cancellationToken).ConfigureAwait(false); + + return response; + } + + static async Task SaveAsync(IStreamingMessageStore messageStore, SmtpSessionContext context, CancellationToken cancellationToken) + { + var pipe = new Pipe(); + + try + { + var readTask = context.Pipe.Input.ReadDotBlockAsync(pipe.Writer, context.ServerOptions.MaxMessageSizeOptions, cancellationToken).AsTask(); + var saveTask = messageStore.SaveAsync(context, context.Transaction, pipe.Reader, cancellationToken); + + await readTask.ConfigureAwait(false); + + return await saveTask.ConfigureAwait(false); + } + finally + { + pipe.Reader.Complete(); + } + } } } diff --git a/src/SmtpServer/Protocol/DelegatingSmtpCommandPolicyFactory.cs b/src/SmtpServer/Protocol/DelegatingSmtpCommandPolicyFactory.cs new file mode 100644 index 00000000..509114da --- /dev/null +++ b/src/SmtpServer/Protocol/DelegatingSmtpCommandPolicyFactory.cs @@ -0,0 +1,31 @@ +using System; + +namespace SmtpServer.Protocol +{ + /// + /// Delegating SMTP command policy factory. + /// + public sealed class DelegatingSmtpCommandPolicyFactory : ISmtpCommandPolicyFactory + { + readonly Func _delegate; + + /// + /// Delegating SMTP command policy factory. + /// + /// The factory delegate. + public DelegatingSmtpCommandPolicyFactory(Func @delegate) + { + _delegate = @delegate; + } + + /// + /// Creates an instance of the service for the given session context. + /// + /// The session context. + /// The service instance for the session context. + public ISmtpCommandPolicy CreateInstance(ISessionContext context) + { + return _delegate(context); + } + } +} diff --git a/src/SmtpServer/Protocol/EhloCommand.cs b/src/SmtpServer/Protocol/EhloCommand.cs index e7975063..35160025 100644 --- a/src/SmtpServer/Protocol/EhloCommand.cs +++ b/src/SmtpServer/Protocol/EhloCommand.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using SmtpServer.Authentication; @@ -35,14 +34,33 @@ public EhloCommand(string domainOrAddress) : base(Command) /// if the current state is to be maintained. internal override async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellationToken) { - var output = new[] { GetGreeting(context) }.Union(GetExtensions(context)).ToArray(); - - for (var i = 0; i < output.Length - 1; i++) + if (await AcceptHeloAsync(context, cancellationToken).ConfigureAwait(false) == false) { - context.Pipe.Output.WriteLine($"250-{output[i]}"); + return false; } - context.Pipe.Output.WriteLine($"250 {output[output.Length - 1]}"); + var greeting = GetGreeting(context); + + using (var extensions = GetExtensions(context).GetEnumerator()) + { + if (extensions.MoveNext() == false) + { + context.Pipe.Output.WriteLine($"250 {greeting}"); + } + else + { + context.Pipe.Output.WriteLine($"250-{greeting}"); + + var extension = extensions.Current; + while (extensions.MoveNext()) + { + context.Pipe.Output.WriteLine($"250-{extension}"); + extension = extensions.Current; + } + + context.Pipe.Output.WriteLine($"250 {extension}"); + } + } await context.Pipe.Output.FlushAsync(cancellationToken).ConfigureAwait(false); @@ -68,7 +86,21 @@ protected virtual IEnumerable GetExtensions(ISessionContext context) { yield return "PIPELINING"; yield return "8BITMIME"; - yield return "SMTPUTF8"; + + if (context.ServerOptions.Extensions.SmtpUtf8Enabled) + { + yield return "SMTPUTF8"; + } + + if (context.ServerOptions.Extensions.DsnEnabled) + { + yield return "DSN"; + } + + if (context.ServerOptions.Extensions.ChunkingEnabled) + { + yield return "CHUNKING"; + } if (context.Pipe.IsSecure == false && context.EndpointDefinition.CertificateFactory != null) { @@ -82,7 +114,16 @@ protected virtual IEnumerable GetExtensions(ISessionContext context) if (IsPlainLoginAllowed(context)) { - yield return "AUTH PLAIN LOGIN"; + var mechanisms = "AUTH PLAIN LOGIN"; + + // Advertise the bearer-token mechanisms only when the host opts in (it has wired an + // authenticator that can validate a token), so a client never negotiates one we cannot honour. + if (context.ServerOptions.Extensions.OAuthEnabled) + { + mechanisms += " XOAUTH2 OAUTHBEARER"; + } + + yield return mechanisms; } static bool IsPlainLoginAllowed(ISessionContext context) @@ -95,7 +136,25 @@ static bool IsPlainLoginAllowed(ISessionContext context) return context.Pipe.IsSecure || context.EndpointDefinition.AllowUnsecureAuthentication; } } - + + async Task AcceptHeloAsync(SmtpSessionContext context, CancellationToken cancellationToken) + { + var policy = context.ServerOptions.SessionPolicy; + if (policy.Helo == null) + { + return true; + } + + var response = await policy.Helo(context, DomainOrAddress, cancellationToken).ConfigureAwait(false); + if (SmtpSession.IsSuccessResponse(response)) + { + return true; + } + + await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + return false; + } + /// /// Gets the domain name or address literal. /// diff --git a/src/SmtpServer/Protocol/ExpnCommand.cs b/src/SmtpServer/Protocol/ExpnCommand.cs new file mode 100644 index 00000000..f8ec1be3 --- /dev/null +++ b/src/SmtpServer/Protocol/ExpnCommand.cs @@ -0,0 +1,45 @@ +using System.Threading; +using System.Threading.Tasks; +using SmtpServer.ComponentModel; +using SmtpServer.IO; + +namespace SmtpServer.Protocol +{ + /// + /// Expn Command + /// + public sealed class ExpnCommand : SmtpCommand + { + /// + /// Smtp Expn Command + /// + public const string Command = "EXPN"; + + /// + /// Constructor. + /// + /// The expansion argument. + public ExpnCommand(string argument) : base(Command) + { + Argument = argument; + } + + /// + internal override async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellationToken) + { + var policy = context.ServiceProvider.GetService(context, SmtpCommandPolicy.Default); + + using var container = new DisposableContainer(policy); + + var response = await container.Instance.ExpandAsync(context, Argument, cancellationToken).ConfigureAwait(false); + await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + + return true; + } + + /// + /// Gets the expansion argument. + /// + public string Argument { get; } + } +} diff --git a/src/SmtpServer/Protocol/HeloCommand.cs b/src/SmtpServer/Protocol/HeloCommand.cs index b28103e1..315f04d4 100644 --- a/src/SmtpServer/Protocol/HeloCommand.cs +++ b/src/SmtpServer/Protocol/HeloCommand.cs @@ -32,6 +32,11 @@ public HeloCommand(string domainOrAddress) : base(Command) /// if the current state is to be maintained. internal override async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellationToken) { + if (await AcceptHeloAsync(context, cancellationToken).ConfigureAwait(false) == false) + { + return false; + } + var response = new SmtpResponse(SmtpReplyCode.Ok, GetGreeting(context)); await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); @@ -49,6 +54,24 @@ protected virtual string GetGreeting(ISessionContext context) return $"{context.ServerOptions.ServerName} Hello {DomainOrAddress}, haven't we met before?"; } + async Task AcceptHeloAsync(SmtpSessionContext context, CancellationToken cancellationToken) + { + var policy = context.ServerOptions.SessionPolicy; + if (policy.Helo == null) + { + return true; + } + + var response = await policy.Helo(context, DomainOrAddress, cancellationToken).ConfigureAwait(false); + if (SmtpSession.IsSuccessResponse(response)) + { + return true; + } + + await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + return false; + } + /// /// Gets the domain name. /// diff --git a/src/SmtpServer/Protocol/HelpCommand.cs b/src/SmtpServer/Protocol/HelpCommand.cs new file mode 100644 index 00000000..fad240ca --- /dev/null +++ b/src/SmtpServer/Protocol/HelpCommand.cs @@ -0,0 +1,45 @@ +using System.Threading; +using System.Threading.Tasks; +using SmtpServer.ComponentModel; +using SmtpServer.IO; + +namespace SmtpServer.Protocol +{ + /// + /// Help Command + /// + public sealed class HelpCommand : SmtpCommand + { + /// + /// Smtp Help Command + /// + public const string Command = "HELP"; + + /// + /// Constructor. + /// + /// The optional command argument. + public HelpCommand(string argument) : base(Command) + { + Argument = argument; + } + + /// + internal override async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellationToken) + { + var policy = context.ServiceProvider.GetService(context, SmtpCommandPolicy.Default); + + using var container = new DisposableContainer(policy); + + var response = await container.Instance.GetHelpAsync(context, Argument, cancellationToken).ConfigureAwait(false); + await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + + return true; + } + + /// + /// Gets the optional command argument. + /// + public string Argument { get; } + } +} diff --git a/src/SmtpServer/Protocol/ISmtpCommandFactory.cs b/src/SmtpServer/Protocol/ISmtpCommandFactory.cs index fa67d5f1..08184a67 100644 --- a/src/SmtpServer/Protocol/ISmtpCommandFactory.cs +++ b/src/SmtpServer/Protocol/ISmtpCommandFactory.cs @@ -38,6 +38,48 @@ public interface ISmtpCommandFactory /// The RCPT command. SmtpCommand CreateRcpt(IMailbox address); + /// + /// Create a RCPT command. + /// + /// The address that the mail is to. + /// The optional parameters for the recipient. + /// The RCPT command. + SmtpCommand CreateRcpt(IMailbox address, IReadOnlyDictionary parameters) + => CreateRcpt(address); + + /// + /// Create a HELP command. + /// + /// The optional command argument. + /// The HELP command. + SmtpCommand CreateHelp(string argument) + => new HelpCommand(argument); + + /// + /// Create a VRFY command. + /// + /// The verification argument. + /// The VRFY command. + SmtpCommand CreateVrfy(string argument) + => new VrfyCommand(argument); + + /// + /// Create an EXPN command. + /// + /// The expansion argument. + /// The EXPN command. + SmtpCommand CreateExpn(string argument) + => new ExpnCommand(argument); + + /// + /// Create a BDAT command. + /// + /// The chunk size. + /// Whether this is the last chunk. + /// The BDAT command. + SmtpCommand CreateBdat(long size, bool isLast) + => new BdatCommand(size, isLast); + /// /// Create a DATA command. /// diff --git a/src/SmtpServer/Protocol/ISmtpCommandPolicy.cs b/src/SmtpServer/Protocol/ISmtpCommandPolicy.cs new file mode 100644 index 00000000..f4350594 --- /dev/null +++ b/src/SmtpServer/Protocol/ISmtpCommandPolicy.cs @@ -0,0 +1,38 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace SmtpServer.Protocol +{ + /// + /// Provides policy responses for optional SMTP commands. + /// + public interface ISmtpCommandPolicy + { + /// + /// Gets the HELP response. + /// + /// The session context. + /// The optional command argument. + /// The cancellation token. + /// The SMTP response. + Task GetHelpAsync(ISessionContext context, string argument, CancellationToken cancellationToken); + + /// + /// Gets the VRFY response. + /// + /// The session context. + /// The verification argument. + /// The cancellation token. + /// The SMTP response. + Task VerifyAsync(ISessionContext context, string argument, CancellationToken cancellationToken); + + /// + /// Gets the EXPN response. + /// + /// The session context. + /// The expansion argument. + /// The cancellation token. + /// The SMTP response. + Task ExpandAsync(ISessionContext context, string argument, CancellationToken cancellationToken); + } +} diff --git a/src/SmtpServer/Protocol/ISmtpCommandPolicyFactory.cs b/src/SmtpServer/Protocol/ISmtpCommandPolicyFactory.cs new file mode 100644 index 00000000..44ce86b3 --- /dev/null +++ b/src/SmtpServer/Protocol/ISmtpCommandPolicyFactory.cs @@ -0,0 +1,11 @@ +using SmtpServer.ComponentModel; + +namespace SmtpServer.Protocol +{ + /// + /// Factory for creating SMTP command policies per session. + /// + public interface ISmtpCommandPolicyFactory : ISessionContextInstanceFactory + { + } +} diff --git a/src/SmtpServer/Protocol/MailCommand.cs b/src/SmtpServer/Protocol/MailCommand.cs index 45feadcf..be8ffb12 100644 --- a/src/SmtpServer/Protocol/MailCommand.cs +++ b/src/SmtpServer/Protocol/MailCommand.cs @@ -44,6 +44,13 @@ internal override async Task ExecuteAsync(SmtpSessionContext context, Canc return false; } + var unsupportedExtensionResponse = GetUnsupportedExtensionResponse(context); + if (unsupportedExtensionResponse != null) + { + await context.Pipe.Output.WriteReplyAsync(unsupportedExtensionResponse, cancellationToken).ConfigureAwait(false); + return false; + } + context.Transaction.Reset(); context.Transaction.Parameters = Parameters; @@ -90,6 +97,65 @@ int GetMessageSize() return int.TryParse(value, out var size) == false ? 0 : size; } + SmtpResponse GetUnsupportedExtensionResponse(ISessionContext context) + { + if (context.ServerOptions.Extensions.SmtpUtf8Enabled == false) + { + if (ContainsParameter("SMTPUTF8")) + { + return new SmtpResponse(SmtpReplyCode.CommandParameterNotImplemented, "SMTPUTF8 is not enabled"); + } + + if (ContainsNonAscii(Address)) + { + return SmtpResponse.MailboxNameNotAllowed; + } + } + + if (context.ServerOptions.Extensions.DsnEnabled == false && (ContainsParameter("RET") || ContainsParameter("ENVID"))) + { + return new SmtpResponse(SmtpReplyCode.CommandParameterNotImplemented, "DSN is not enabled"); + } + + return null; + } + + bool ContainsParameter(string name) + { + foreach (var parameter in Parameters) + { + if (string.Equals(parameter.Key, name, System.StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + static bool ContainsNonAscii(IMailbox mailbox) + { + return ContainsNonAscii(mailbox?.User) || ContainsNonAscii(mailbox?.Host); + } + + static bool ContainsNonAscii(string value) + { + if (value == null) + { + return false; + } + + foreach (var character in value) + { + if (character > 127) + { + return true; + } + } + + return false; + } + /// /// Gets the address that the mail is from. /// diff --git a/src/SmtpServer/Protocol/OAuthSaslDecoder.cs b/src/SmtpServer/Protocol/OAuthSaslDecoder.cs new file mode 100644 index 00000000..1061500c --- /dev/null +++ b/src/SmtpServer/Protocol/OAuthSaslDecoder.cs @@ -0,0 +1,173 @@ +using System; +using System.Text; + +namespace SmtpServer.Protocol +{ + /// + /// Decodes the SASL initial-response blobs for the bearer-token mechanisms XOAUTH2 and RFC 7628 + /// OAUTHBEARER into the authenticating identity and the bearer token. This mirrors the DevOp IMAP + /// server's parsing so the two protocol front ends agree on the wire. The server surfaces the token to + /// as the password, so a host validates it exactly as it + /// validates any other credential — the decoded token never leaves this boundary in another shape. + /// + internal static class OAuthSaslDecoder + { + // The SASL field separator (SOH, U+0001) both mechanisms delimit their key/value fields with. + const char Separator = '\u0001'; + + /// + /// Decodes a base64-encoded XOAUTH2 initial response of the form + /// user={identity}^Aauth=Bearer {token}^A^A. + /// + /// The base64-encoded initial response. + /// The decoded authenticating identity. + /// The decoded bearer token. + /// true if both an identity and a bearer token were present, false otherwise. + public static bool TryDecodeXOAuth2(string base64, out string user, out string token) + { + user = null; + token = null; + + if (TryDecodeUtf8(base64, out var payload) == false) + { + return false; + } + + var hasAuthorization = false; + foreach (var field in payload.Split(Separator)) + { + if (field.Length == 0) + { + continue; + } + + if (field.StartsWith("user=", StringComparison.OrdinalIgnoreCase)) + { + user = field.Substring("user=".Length); + } + else if (field.StartsWith("auth=", StringComparison.OrdinalIgnoreCase)) + { + hasAuthorization = true; + if (TryParseBearerAuthorization(field.Substring("auth=".Length), out token) == false) + { + return false; + } + } + } + + return IsPresent(user) && hasAuthorization && IsPresent(token); + } + + /// + /// Decodes a base64-encoded RFC 7628 OAUTHBEARER initial response of the form + /// {gs2-header}^A[key=value^A...]auth=Bearer {token}^A^A, where the GS2 header carries the + /// authorization identity as its a={identity} field. + /// + /// The base64-encoded initial response. + /// The decoded authorization identity. + /// The decoded bearer token. + /// true if both an identity and a bearer token were present, false otherwise. + public static bool TryDecodeOAuthBearer(string base64, out string user, out string token) + { + user = null; + token = null; + + if (TryDecodeUtf8(base64, out var payload) == false) + { + return false; + } + + var fields = payload.Split(Separator); + if (fields.Length < 2) + { + return false; + } + + user = ParseGs2Authzid(fields[0]); + var hasAuthorization = false; + for (var i = 1; i < fields.Length; i++) + { + var field = fields[i]; + if (field.Length == 0) + { + continue; + } + + var separator = field.IndexOf('='); + if (separator <= 0) + { + return false; + } + + var key = field.Substring(0, separator); + var value = field.Substring(separator + 1); + if (key.Equals("auth", StringComparison.OrdinalIgnoreCase)) + { + hasAuthorization = true; + if (TryParseBearerAuthorization(value, out token) == false) + { + return false; + } + } + else if (key.Equals("user", StringComparison.OrdinalIgnoreCase) && IsPresent(user) == false) + { + user = value; + } + } + + return IsPresent(user) && hasAuthorization && IsPresent(token); + } + + // The authorization field is "Bearer {token}" (the scheme is case-insensitive). + static bool TryParseBearerAuthorization(string value, out string token) + { + token = null; + + const string BearerPrefix = "Bearer "; + if (value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase) == false) + { + return false; + } + + token = value.Substring(BearerPrefix.Length); + return true; + } + + // The GS2 header is "{cb-flag},[a={identity}],": read the identity after "a=" up to the next comma. + static string ParseGs2Authzid(string value) + { + const string Prefix = "a="; + var start = value.IndexOf(Prefix, StringComparison.Ordinal); + if (start < 0) + { + return null; + } + + start += Prefix.Length; + var end = value.IndexOf(',', start); + return end < 0 ? value.Substring(start) : value.Substring(start, end - start); + } + + static bool IsPresent(string value) => string.IsNullOrEmpty(value) == false; + + static bool TryDecodeUtf8(string base64, out string value) + { + value = null; + + if (string.IsNullOrWhiteSpace(base64)) + { + return false; + } + + try + { + value = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); + return true; + } + catch (FormatException) + { + return false; + } + } + } +} diff --git a/src/SmtpServer/Protocol/RcptCommand.cs b/src/SmtpServer/Protocol/RcptCommand.cs index 1c1bc1ba..8e41a10c 100644 --- a/src/SmtpServer/Protocol/RcptCommand.cs +++ b/src/SmtpServer/Protocol/RcptCommand.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using SmtpServer.ComponentModel; @@ -22,9 +23,20 @@ public sealed class RcptCommand : SmtpCommand /// Constructor. /// /// The address. - public RcptCommand(IMailbox address) : base(Command) + public RcptCommand(IMailbox address) + : this(address, new Dictionary()) + { + } + + /// + /// Constructor. + /// + /// The address. + /// The optional parameters for the recipient. + public RcptCommand(IMailbox address, IReadOnlyDictionary parameters) : base(Command) { Address = address; + Parameters = parameters ?? new Dictionary(); } /// @@ -36,14 +48,26 @@ public RcptCommand(IMailbox address) : base(Command) /// if the current state is to be maintained. internal override async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellationToken) { + var unsupportedExtensionResponse = GetUnsupportedExtensionResponse(context); + if (unsupportedExtensionResponse != null) + { + await context.Pipe.Output.WriteReplyAsync(unsupportedExtensionResponse, cancellationToken).ConfigureAwait(false); + return false; + } + var mailboxFilter = context.ServiceProvider.GetService(context, MailboxFilter.Default); using var container = new DisposableContainer(mailboxFilter); - switch (await container.Instance.CanDeliverToAsync(context, Address, context.Transaction.From, cancellationToken).ConfigureAwait(false)) + var canDeliverTo = container.Instance is IParameterizedMailboxFilter parameterizedMailboxFilter + ? parameterizedMailboxFilter.CanDeliverToAsync(context, Address, context.Transaction.From, Parameters, cancellationToken) + : container.Instance.CanDeliverToAsync(context, Address, context.Transaction.From, cancellationToken); + + switch (await canDeliverTo.ConfigureAwait(false)) { case true: context.Transaction.To.Add(Address); + context.Transaction.Recipients.Add(new SmtpMessageRecipient(Address, Parameters)); await context.Pipe.Output.WriteReplyAsync(SmtpResponse.Ok, cancellationToken).ConfigureAwait(false); return true; @@ -55,9 +79,65 @@ internal override async Task ExecuteAsync(SmtpSessionContext context, Canc throw new NotSupportedException("The Acceptance state is not supported."); } + SmtpResponse GetUnsupportedExtensionResponse(ISessionContext context) + { + if (context.ServerOptions.Extensions.SmtpUtf8Enabled == false && ContainsNonAscii(Address)) + { + return SmtpResponse.MailboxNameNotAllowed; + } + + if (context.ServerOptions.Extensions.DsnEnabled == false && (ContainsParameter("NOTIFY") || ContainsParameter("ORCPT"))) + { + return new SmtpResponse(SmtpReplyCode.CommandParameterNotImplemented, "DSN is not enabled"); + } + + return null; + } + + bool ContainsParameter(string name) + { + foreach (var parameter in Parameters) + { + if (string.Equals(parameter.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + static bool ContainsNonAscii(IMailbox mailbox) + { + return ContainsNonAscii(mailbox?.User) || ContainsNonAscii(mailbox?.Host); + } + + static bool ContainsNonAscii(string value) + { + if (value == null) + { + return false; + } + + foreach (var character in value) + { + if (character > 127) + { + return true; + } + } + + return false; + } + /// /// Gets the address that the mail is to. /// public IMailbox Address { get; } + + /// + /// The list of extended recipient parameters. + /// + public IReadOnlyDictionary Parameters { get; } } } diff --git a/src/SmtpServer/Protocol/RsetCommand.cs b/src/SmtpServer/Protocol/RsetCommand.cs index 7c50b1bf..d5e39e8d 100644 --- a/src/SmtpServer/Protocol/RsetCommand.cs +++ b/src/SmtpServer/Protocol/RsetCommand.cs @@ -28,6 +28,7 @@ public RsetCommand() : base(Command) { } /// if the current state is to be maintained. internal override async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellationToken) { + await BdatCommand.AbortAsync(context).ConfigureAwait(false); context.Transaction.Reset(); await context.Pipe.Output.WriteReplyAsync(SmtpResponse.Ok, cancellationToken).ConfigureAwait(false); diff --git a/src/SmtpServer/Protocol/SmtpCommandFactory.cs b/src/SmtpServer/Protocol/SmtpCommandFactory.cs index e0c0d75e..351294ae 100644 --- a/src/SmtpServer/Protocol/SmtpCommandFactory.cs +++ b/src/SmtpServer/Protocol/SmtpCommandFactory.cs @@ -30,7 +30,37 @@ public virtual SmtpCommand CreateMail(IMailbox address, IReadOnlyDictionary public virtual SmtpCommand CreateRcpt(IMailbox address) { - return new RcptCommand(address); + return CreateRcpt(address, new Dictionary()); + } + + /// + public virtual SmtpCommand CreateRcpt(IMailbox address, IReadOnlyDictionary parameters) + { + return new RcptCommand(address, parameters); + } + + /// + public virtual SmtpCommand CreateHelp(string argument) + { + return new HelpCommand(argument); + } + + /// + public virtual SmtpCommand CreateVrfy(string argument) + { + return new VrfyCommand(argument); + } + + /// + public virtual SmtpCommand CreateExpn(string argument) + { + return new ExpnCommand(argument); + } + + /// + public virtual SmtpCommand CreateBdat(long size, bool isLast) + { + return new BdatCommand(size, isLast); } /// diff --git a/src/SmtpServer/Protocol/SmtpCommandPolicy.cs b/src/SmtpServer/Protocol/SmtpCommandPolicy.cs new file mode 100644 index 00000000..64029ac4 --- /dev/null +++ b/src/SmtpServer/Protocol/SmtpCommandPolicy.cs @@ -0,0 +1,44 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace SmtpServer.Protocol +{ + /// + /// Default SMTP command policy. + /// + public abstract class SmtpCommandPolicy : ISmtpCommandPolicy + { + /// + /// Default SMTP command policy. + /// + public static readonly ISmtpCommandPolicy Default = new DefaultSmtpCommandPolicy(); + + /// + public virtual Task GetHelpAsync(ISessionContext context, string argument, CancellationToken cancellationToken) + { + var message = string.IsNullOrWhiteSpace(argument) + ? "Commands: EHLO HELO MAIL RCPT DATA RSET NOOP QUIT STARTTLS AUTH HELP VRFY EXPN" + : $"No additional help is available for {argument.Trim().ToUpperInvariant()}"; + + return Task.FromResult(new SmtpResponse(SmtpReplyCode.HelpResponse, message)); + } + + /// + public virtual Task VerifyAsync(ISessionContext context, string argument, CancellationToken cancellationToken) + { + return Task.FromResult(new SmtpResponse( + SmtpReplyCode.CantVerifyUser, + "cannot VRFY user, but will accept message and attempt delivery")); + } + + /// + public virtual Task ExpandAsync(ISessionContext context, string argument, CancellationToken cancellationToken) + { + return Task.FromResult(new SmtpResponse(SmtpReplyCode.CantVerifyUser, "cannot EXPN mailing list")); + } + + sealed class DefaultSmtpCommandPolicy : SmtpCommandPolicy + { + } + } +} diff --git a/src/SmtpServer/Protocol/SmtpCommandVisitor.cs b/src/SmtpServer/Protocol/SmtpCommandVisitor.cs index f6fd723b..8281ba57 100644 --- a/src/SmtpServer/Protocol/SmtpCommandVisitor.cs +++ b/src/SmtpServer/Protocol/SmtpCommandVisitor.cs @@ -43,6 +43,30 @@ public void Visit(SmtpCommand command) return; } + if (command is HelpCommand helpCommand) + { + Visit(helpCommand); + return; + } + + if (command is VrfyCommand vrfyCommand) + { + Visit(vrfyCommand); + return; + } + + if (command is ExpnCommand expnCommand) + { + Visit(expnCommand); + return; + } + + if (command is BdatCommand bdatCommand) + { + Visit(bdatCommand); + return; + } + if (command is NoopCommand noopCommand) { Visit(noopCommand); @@ -112,6 +136,30 @@ protected virtual void Visit(EhloCommand command) { } /// The command that is being visited. protected virtual void Visit(MailCommand command) { } + /// + /// Visit a HELP command. + /// + /// The command that is being visited. + protected virtual void Visit(HelpCommand command) { } + + /// + /// Visit a VRFY command. + /// + /// The command that is being visited. + protected virtual void Visit(VrfyCommand command) { } + + /// + /// Visit an EXPN command. + /// + /// The command that is being visited. + protected virtual void Visit(ExpnCommand command) { } + + /// + /// Visit a BDAT command. + /// + /// The command that is being visited. + protected virtual void Visit(BdatCommand command) { } + /// /// Visit an NOOP command. /// diff --git a/src/SmtpServer/Protocol/SmtpEnhancedStatusCode.cs b/src/SmtpServer/Protocol/SmtpEnhancedStatusCode.cs new file mode 100644 index 00000000..68daea90 --- /dev/null +++ b/src/SmtpServer/Protocol/SmtpEnhancedStatusCode.cs @@ -0,0 +1,77 @@ +using System; + +namespace SmtpServer.Protocol +{ + /// + /// Enhanced SMTP status code as defined by RFC 3463. + /// + public readonly struct SmtpEnhancedStatusCode : IEquatable + { + /// + /// Constructor. + /// + /// The status class. + /// The status subject. + /// The status detail. + public SmtpEnhancedStatusCode(int @class, int subject, int detail) + { + if (@class < 2 || @class > 5) + { + throw new ArgumentOutOfRangeException(nameof(@class)); + } + + if (subject < 0) + { + throw new ArgumentOutOfRangeException(nameof(subject)); + } + + if (detail < 0) + { + throw new ArgumentOutOfRangeException(nameof(detail)); + } + + Class = @class; + Subject = subject; + Detail = detail; + } + + /// + /// Gets the status class. + /// + public int Class { get; } + + /// + /// Gets the status subject. + /// + public int Subject { get; } + + /// + /// Gets the status detail. + /// + public int Detail { get; } + + /// + public bool Equals(SmtpEnhancedStatusCode other) + { + return Class == other.Class && Subject == other.Subject && Detail == other.Detail; + } + + /// + public override bool Equals(object obj) + { + return obj is SmtpEnhancedStatusCode other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(Class, Subject, Detail); + } + + /// + public override string ToString() + { + return $"{Class}.{Subject}.{Detail}"; + } + } +} diff --git a/src/SmtpServer/Protocol/SmtpParser.cs b/src/SmtpServer/Protocol/SmtpParser.cs index 3b57b32c..31e76e10 100644 --- a/src/SmtpServer/Protocol/SmtpParser.cs +++ b/src/SmtpServer/Protocol/SmtpParser.cs @@ -39,25 +39,121 @@ public SmtpParser(ISmtpCommandFactory smtpCommandFactory) /// Returns true if a command could be made, false if not. public bool TryMake(ref ReadOnlySequence buffer, out SmtpCommand command, out SmtpResponse errorResponse) { - return Make(buffer, TryMakeEhlo, out command, out errorResponse) - || Make(buffer, TryMakeHelo, out command, out errorResponse) - || Make(buffer, TryMakeMail, out command, out errorResponse) - || Make(buffer, TryMakeRcpt, out command, out errorResponse) - || Make(buffer, TryMakeData, out command, out errorResponse) - || Make(buffer, TryMakeQuit, out command, out errorResponse) - || Make(buffer, TryMakeRset, out command, out errorResponse) - || Make(buffer, TryMakeNoop, out command, out errorResponse) - || Make(buffer, TryMakeStartTls, out command, out errorResponse) - || Make(buffer, TryMakeAuth, out command, out errorResponse) - || Make(buffer, TryMakeProxy, out command, out errorResponse) - || Make(buffer, MakeUnrecognized, out command, out errorResponse); + var verbReader = new TokenReader(buffer); - static bool Make(ReadOnlySequence buffer, TryMakeDelegate tryMakeDelegate, out SmtpCommand command, out SmtpResponse errorResponse) + if (verbReader.TryMake(TryMakeText, out var verb) == false) { - var reader = new TokenReader(buffer); + var unrecognizedReader = new TokenReader(buffer); + return MakeUnrecognized(ref unrecognizedReader, out command, out errorResponse); + } + + var reader = new TokenReader(buffer); + + if (IsVerb(verb, "EHLO")) + { + return Make(ref reader, TryMakeEhlo, out command, out errorResponse); + } + + if (IsVerb(verb, "HELO")) + { + return Make(ref reader, TryMakeHelo, out command, out errorResponse); + } + + if (IsVerb(verb, "MAIL")) + { + return Make(ref reader, TryMakeMail, out command, out errorResponse); + } + + if (IsVerb(verb, "RCPT")) + { + return Make(ref reader, TryMakeRcpt, out command, out errorResponse); + } + + if (IsVerb(verb, "HELP")) + { + return Make(ref reader, TryMakeHelp, out command, out errorResponse); + } + + if (IsVerb(verb, "VRFY")) + { + return Make(ref reader, TryMakeVrfy, out command, out errorResponse); + } + + if (IsVerb(verb, "EXPN")) + { + return Make(ref reader, TryMakeExpn, out command, out errorResponse); + } - return tryMakeDelegate(ref reader, out command, out errorResponse); + if (IsVerb(verb, "BDAT")) + { + return Make(ref reader, TryMakeBdat, out command, out errorResponse); + } + + if (IsVerb(verb, "DATA")) + { + return Make(ref reader, TryMakeData, out command, out errorResponse); + } + + if (IsVerb(verb, "QUIT")) + { + return Make(ref reader, TryMakeQuit, out command, out errorResponse); + } + + if (IsVerb(verb, "RSET")) + { + return Make(ref reader, TryMakeRset, out command, out errorResponse); + } + + if (IsVerb(verb, "NOOP")) + { + return Make(ref reader, TryMakeNoop, out command, out errorResponse); + } + + if (IsVerb(verb, "STARTTLS")) + { + return Make(ref reader, TryMakeStartTls, out command, out errorResponse); + } + + if (IsVerb(verb, "AUTH")) + { + return Make(ref reader, TryMakeAuth, out command, out errorResponse); + } + + if (IsVerb(verb, "PROXY")) + { + return Make(ref reader, TryMakeProxy, out command, out errorResponse); } + + return MakeUnrecognized(ref reader, out command, out errorResponse); + + static bool Make(ref TokenReader reader, TryMakeDelegate tryMakeDelegate, out SmtpCommand command, out SmtpResponse errorResponse) + { + if (tryMakeDelegate(ref reader, out command, out errorResponse)) + { + return true; + } + + command = null; + errorResponse ??= UnrecognizedCommand; + return false; + } + } + + static bool IsVerb(ReadOnlySequence verb, string expected) + { + if (verb.Length != expected.Length) + { + return false; + } + + Span text = stackalloc char[expected.Length]; + + for (var i = 0; i < expected.Length; i++) + { + text[i] = expected[i]; + } + + return verb.CaseInsensitiveStringEquals(ref text); } static bool MakeUnrecognized(ref TokenReader reader, out SmtpCommand command, out SmtpResponse errorResponse) @@ -68,6 +164,39 @@ static bool MakeUnrecognized(ref TokenReader reader, out SmtpCommand command, ou return false; } + static bool TryMakeCommandArgument(ref TokenReader reader, bool required, out string argument) + { + reader.Skip(TokenKind.Space); + + if (reader.Peek().Kind == TokenKind.None) + { + argument = string.Empty; + return required == false; + } + + if (reader.TryMake(TryMakeRemainingLine, out var buffer) == false) + { + argument = string.Empty; + return false; + } + + argument = StringUtil.Create(buffer, Encoding.UTF8)?.Trim() ?? string.Empty; + return required == false || argument.Length > 0; + } + + static bool TryMakeRemainingLine(ref TokenReader reader) + { + var hasTokens = false; + + while (reader.Peek().Kind != TokenKind.None) + { + reader.Take(); + hasTokens = true; + } + + return hasTokens; + } + /// /// Make a HELO command from the given enumerator. /// @@ -89,7 +218,13 @@ public bool TryMakeHelo(ref TokenReader reader, out SmtpCommand command, out Smt if (reader.TryMake(TryMakeDomain, out var domain)) { - command = _smtpCommandFactory.CreateHelo(StringUtil.Create(domain)); + if (TryMakeEnd(ref reader) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + command = _smtpCommandFactory.CreateHelo(StringUtil.Create(domain, Encoding.UTF8)); return true; } @@ -98,6 +233,12 @@ public bool TryMakeHelo(ref TokenReader reader, out SmtpCommand command, out Smt // address literal and there is no harm in accepting it if (reader.TryMake(TryMakeAddressLiteral, out var address)) { + if (TryMakeEnd(ref reader) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + command = _smtpCommandFactory.CreateHelo(StringUtil.Create(address)); return true; } @@ -148,12 +289,24 @@ public bool TryMakeEhlo(ref TokenReader reader, out SmtpCommand command, out Smt if (reader.TryMake(TryMakeDomain, out var domain)) { - command = _smtpCommandFactory.CreateEhlo(StringUtil.Create(domain)); + if (TryMakeEnd(ref reader) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + command = _smtpCommandFactory.CreateEhlo(StringUtil.Create(domain, Encoding.UTF8)); return true; } if (reader.TryMake(TryMakeAddressLiteral, out var address)) { + if (TryMakeEnd(ref reader) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + // remove the brackets address = address.Slice(1, address.Length - 2); @@ -223,10 +376,16 @@ public bool TryMakeMail(ref TokenReader reader, out SmtpCommand command, out Smt reader.Skip(TokenKind.Space); // match the optional (ESMTP) parameters - if (reader.TryMake(TryMakeMailParameters, out IReadOnlyDictionary parameters) == false) + IReadOnlyDictionary parameters; + if (reader.Peek().Kind == TokenKind.None) { parameters = new Dictionary(); } + else if (reader.TryMake(TryMakeMailParameters, out parameters) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } command = _smtpCommandFactory.CreateMail(mailbox, parameters); return true; @@ -313,9 +472,20 @@ public bool TryMakeRcpt(ref TokenReader reader, out SmtpCommand command, out Smt return false; } - // TODO: support optional service extension parameters here + reader.Skip(TokenKind.Space); - command = _smtpCommandFactory.CreateRcpt(mailbox); + IReadOnlyDictionary parameters; + if (reader.Peek().Kind == TokenKind.None) + { + parameters = new Dictionary(); + } + else if (reader.TryMake(TryMakeMailParameters, out parameters) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + command = _smtpCommandFactory.CreateRcpt(mailbox, parameters); return true; } @@ -359,6 +529,247 @@ public bool TryMakeToLiteral(ref TokenReader reader) return false; } + /// + /// Make a HELP command. + /// + /// The reader to perform the operation on. + /// The command that is defined within the token reader. + /// The error that indicates why the command could not be made. + /// Returns true if a command could be made, false if not. + public bool TryMakeHelp(ref TokenReader reader, out SmtpCommand command, out SmtpResponse errorResponse) + { + command = null; + errorResponse = null; + + if (reader.TryMake(TryMakeHelpLiteral) == false) + { + return false; + } + + if (TryMakeCommandArgument(ref reader, false, out var argument) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + command = _smtpCommandFactory.CreateHelp(argument); + return true; + } + + /// + /// Try to make the HELP text sequence. + /// + /// The reader to perform the operation on. + /// true if the HELP text sequence could be made, false if not. + public bool TryMakeHelpLiteral(ref TokenReader reader) + { + if (reader.TryMake(TryMakeText, out var text)) + { + Span command = stackalloc char[4]; + command[0] = 'H'; + command[1] = 'E'; + command[2] = 'L'; + command[3] = 'P'; + + return text.CaseInsensitiveStringEquals(ref command); + } + + return false; + } + + /// + /// Make a VRFY command. + /// + /// The reader to perform the operation on. + /// The command that is defined within the token reader. + /// The error that indicates why the command could not be made. + /// Returns true if a command could be made, false if not. + public bool TryMakeVrfy(ref TokenReader reader, out SmtpCommand command, out SmtpResponse errorResponse) + { + command = null; + errorResponse = null; + + if (reader.TryMake(TryMakeVrfyLiteral) == false) + { + return false; + } + + if (TryMakeCommandArgument(ref reader, true, out var argument) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + command = _smtpCommandFactory.CreateVrfy(argument); + return true; + } + + /// + /// Try to make the VRFY text sequence. + /// + /// The reader to perform the operation on. + /// true if the VRFY text sequence could be made, false if not. + public bool TryMakeVrfyLiteral(ref TokenReader reader) + { + if (reader.TryMake(TryMakeText, out var text)) + { + Span command = stackalloc char[4]; + command[0] = 'V'; + command[1] = 'R'; + command[2] = 'F'; + command[3] = 'Y'; + + return text.CaseInsensitiveStringEquals(ref command); + } + + return false; + } + + /// + /// Make an EXPN command. + /// + /// The reader to perform the operation on. + /// The command that is defined within the token reader. + /// The error that indicates why the command could not be made. + /// Returns true if a command could be made, false if not. + public bool TryMakeExpn(ref TokenReader reader, out SmtpCommand command, out SmtpResponse errorResponse) + { + command = null; + errorResponse = null; + + if (reader.TryMake(TryMakeExpnLiteral) == false) + { + return false; + } + + if (TryMakeCommandArgument(ref reader, true, out var argument) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + command = _smtpCommandFactory.CreateExpn(argument); + return true; + } + + /// + /// Try to make the EXPN text sequence. + /// + /// The reader to perform the operation on. + /// true if the EXPN text sequence could be made, false if not. + public bool TryMakeExpnLiteral(ref TokenReader reader) + { + if (reader.TryMake(TryMakeText, out var text)) + { + Span command = stackalloc char[4]; + command[0] = 'E'; + command[1] = 'X'; + command[2] = 'P'; + command[3] = 'N'; + + return text.CaseInsensitiveStringEquals(ref command); + } + + return false; + } + + /// + /// Make a BDAT command. + /// + /// The reader to perform the operation on. + /// The command that is defined within the token reader. + /// The error that indicates why the command could not be made. + /// Returns true if a command could be made, false if not. + public bool TryMakeBdat(ref TokenReader reader, out SmtpCommand command, out SmtpResponse errorResponse) + { + command = null; + errorResponse = null; + + if (reader.TryMake(TryMakeBdatLiteral) == false) + { + return false; + } + + reader.Skip(TokenKind.Space); + + if (reader.TryMake(TryMakeNumber, out var number) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + if (long.TryParse(StringUtil.Create(number), out var size) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + reader.Skip(TokenKind.Space); + + var isLast = false; + if (reader.Peek().Kind != TokenKind.None) + { + if (reader.TryMake(TryMakeLastLiteral) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + isLast = true; + } + + if (TryMakeEnd(ref reader) == false) + { + errorResponse = SmtpResponse.SyntaxError; + return false; + } + + command = _smtpCommandFactory.CreateBdat(size, isLast); + return true; + } + + /// + /// Try to make the BDAT text sequence. + /// + /// The reader to perform the operation on. + /// true if the BDAT text sequence could be made, false if not. + public bool TryMakeBdatLiteral(ref TokenReader reader) + { + if (reader.TryMake(TryMakeText, out var text)) + { + Span command = stackalloc char[4]; + command[0] = 'B'; + command[1] = 'D'; + command[2] = 'A'; + command[3] = 'T'; + + return text.CaseInsensitiveStringEquals(ref command); + } + + return false; + } + + /// + /// Try to make the LAST text sequence. + /// + /// The reader to perform the operation on. + /// true if the LAST text sequence could be made, false if not. + public bool TryMakeLastLiteral(ref TokenReader reader) + { + if (reader.TryMake(TryMakeText, out var text)) + { + Span command = stackalloc char[4]; + command[0] = 'L'; + command[1] = 'A'; + command[2] = 'S'; + command[3] = 'T'; + + return text.CaseInsensitiveStringEquals(ref command); + } + + return false; + } + /// /// Make a DATA command from the given enumerator. /// @@ -671,6 +1082,18 @@ public bool TryMakeAuthenticationMethod(ref TokenReader reader, out Authenticati return true; } + if (reader.TryMake(TryMakeXOAuth2Literal)) + { + authenticationMethod = AuthenticationMethod.XOAuth2; + return true; + } + + if (reader.TryMake(TryMakeOAuthBearerLiteral)) + { + authenticationMethod = AuthenticationMethod.OAuthBearer; + return true; + } + authenticationMethod = default; return false; } @@ -740,6 +1163,70 @@ public bool TryMakePlainLiteral(ref TokenReader reader) return false; } + /// + /// Try to make the XOAUTH2 sequence. The tokenizer splits letters from digits, so this matches the + /// "XOAUTH" text token followed by the "2" number token. + /// + /// The reader to perform the operation on. + /// true if the XOAUTH2 sequence could be made, false if not. + public bool TryMakeXOAuth2Literal(ref TokenReader reader) + { + if (reader.TryMake(TryMakeText, out var text) == false) + { + return false; + } + + Span command = stackalloc char[6]; + command[0] = 'X'; + command[1] = 'O'; + command[2] = 'A'; + command[3] = 'U'; + command[4] = 'T'; + command[5] = 'H'; + + if (text.CaseInsensitiveStringEquals(ref command) == false) + { + return false; + } + + var number = reader.Peek(); + if (number.Kind != TokenKind.Number || number.Text.Length != 1 || number.Text[0] != (byte)'2') + { + return false; + } + + reader.Skip(TokenKind.Number); + return true; + } + + /// + /// Try to make the OAUTHBEARER text sequence. + /// + /// The reader to perform the operation on. + /// true if the OAUTHBEARER text sequence could be made, false if not. + public bool TryMakeOAuthBearerLiteral(ref TokenReader reader) + { + if (reader.TryMake(TryMakeText, out var text)) + { + Span command = stackalloc char[11]; + command[0] = 'O'; + command[1] = 'A'; + command[2] = 'U'; + command[3] = 'T'; + command[4] = 'H'; + command[5] = 'B'; + command[6] = 'E'; + command[7] = 'A'; + command[8] = 'R'; + command[9] = 'E'; + command[10] = 'R'; + + return text.CaseInsensitiveStringEquals(ref command); + } + + return false; + } + /// /// Support proxy protocol version 1 header for use with HAProxy. /// Documented at http://www.haproxy.org/download/1.8/doc/proxy-protocol.txt @@ -845,7 +1332,7 @@ public bool TryMakeTcp4Proxy(ref TokenReader reader, out SmtpCommand command) } var token = reader.Take(); - if (token.Kind != TokenKind.Number && token.Text[0] != '4') + if (token.Kind != TokenKind.Number || token.Text.Length != 1 || token.Text[0] != '4') { return false; } @@ -869,7 +1356,7 @@ public bool TryMakeTcp6Proxy(ref TokenReader reader, out SmtpCommand command) } var token = reader.Take(); - if (token.Kind != TokenKind.Number && token.Text[0] != '6') + if (token.Kind != TokenKind.Number || token.Text.Length != 1 || token.Text[0] != '6') { return false; } @@ -1119,7 +1606,7 @@ static Mailbox CreateMailbox(ReadOnlySequence localpart, ReadOnlySequence< return null; } - var tempDomain = StringUtil.Create(domainOrAddress); + var tempDomain = StringUtil.Create(domainOrAddress, Encoding.UTF8); if (tempDomain == null) { return null; diff --git a/src/SmtpServer/Protocol/SmtpResponse.cs b/src/SmtpServer/Protocol/SmtpResponse.cs index 17487c9c..158f0427 100644 --- a/src/SmtpServer/Protocol/SmtpResponse.cs +++ b/src/SmtpServer/Protocol/SmtpResponse.cs @@ -80,10 +80,12 @@ public class SmtpResponse /// /// The reply code. /// The reply message. - public SmtpResponse(SmtpReplyCode replyCode, string message = null) + /// The enhanced status code. + public SmtpResponse(SmtpReplyCode replyCode, string message = null, SmtpEnhancedStatusCode? enhancedStatusCode = null) { ReplyCode = replyCode; Message = message; + EnhancedStatusCode = enhancedStatusCode ?? GetDefaultEnhancedStatusCode(replyCode); } /// @@ -95,5 +97,63 @@ public SmtpResponse(SmtpReplyCode replyCode, string message = null) /// Gets the response message. /// public string Message { get; } + + /// + /// Gets the enhanced status code. + /// + public SmtpEnhancedStatusCode? EnhancedStatusCode { get; } + + static SmtpEnhancedStatusCode? GetDefaultEnhancedStatusCode(SmtpReplyCode replyCode) + { + switch (replyCode) + { + case SmtpReplyCode.ServiceReady: + case SmtpReplyCode.ServiceClosingTransmissionChannel: + case SmtpReplyCode.Ok: + case SmtpReplyCode.HelpResponse: + return new SmtpEnhancedStatusCode(2, 0, 0); + + case SmtpReplyCode.AuthenticationSuccessful: + return new SmtpEnhancedStatusCode(2, 7, 0); + + case SmtpReplyCode.CantVerifyUser: + return new SmtpEnhancedStatusCode(2, 5, 2); + + case SmtpReplyCode.ServiceUnavailable: + return new SmtpEnhancedStatusCode(4, 3, 0); + + case SmtpReplyCode.CommandUnrecognized: + case SmtpReplyCode.CommandNotImplemented: + case SmtpReplyCode.BadSequence: + return new SmtpEnhancedStatusCode(5, 5, 1); + + case SmtpReplyCode.SyntaxError: + return new SmtpEnhancedStatusCode(5, 5, 2); + + case SmtpReplyCode.CommandParameterNotImplemented: + return new SmtpEnhancedStatusCode(5, 5, 4); + + case SmtpReplyCode.AuthenticationRequired: + return new SmtpEnhancedStatusCode(5, 7, 0); + + case SmtpReplyCode.AuthenticationFailed: + return new SmtpEnhancedStatusCode(5, 7, 8); + + case SmtpReplyCode.MailboxUnavailable: + return new SmtpEnhancedStatusCode(5, 1, 1); + + case SmtpReplyCode.MailboxNameNotAllowed: + return new SmtpEnhancedStatusCode(5, 1, 3); + + case SmtpReplyCode.SizeLimitExceeded: + return new SmtpEnhancedStatusCode(5, 3, 4); + + case SmtpReplyCode.TransactionFailed: + return new SmtpEnhancedStatusCode(5, 0, 0); + + default: + return null; + } + } } } diff --git a/src/SmtpServer/Protocol/VrfyCommand.cs b/src/SmtpServer/Protocol/VrfyCommand.cs new file mode 100644 index 00000000..d2cfe61b --- /dev/null +++ b/src/SmtpServer/Protocol/VrfyCommand.cs @@ -0,0 +1,45 @@ +using System.Threading; +using System.Threading.Tasks; +using SmtpServer.ComponentModel; +using SmtpServer.IO; + +namespace SmtpServer.Protocol +{ + /// + /// Vrfy Command + /// + public sealed class VrfyCommand : SmtpCommand + { + /// + /// Smtp Vrfy Command + /// + public const string Command = "VRFY"; + + /// + /// Constructor. + /// + /// The verification argument. + public VrfyCommand(string argument) : base(Command) + { + Argument = argument; + } + + /// + internal override async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellationToken) + { + var policy = context.ServiceProvider.GetService(context, SmtpCommandPolicy.Default); + + using var container = new DisposableContainer(policy); + + var response = await container.Instance.VerifyAsync(context, Argument, cancellationToken).ConfigureAwait(false); + await context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + + return true; + } + + /// + /// Gets the verification argument. + /// + public string Argument { get; } + } +} diff --git a/src/SmtpServer/SmtpCommandEventArgs.cs b/src/SmtpServer/SmtpCommandEventArgs.cs index cd15e679..af33bef9 100644 --- a/src/SmtpServer/SmtpCommandEventArgs.cs +++ b/src/SmtpServer/SmtpCommandEventArgs.cs @@ -15,11 +15,17 @@ public sealed class SmtpCommandEventArgs : SessionEventArgs public SmtpCommandEventArgs(ISessionContext context, SmtpCommand command) : base(context) { Command = command; + SafeCommand = SmtpCommandSnapshot.From(command); } /// /// The command for the event. /// public SmtpCommand Command { get; } + + /// + /// The safe-to-log command snapshot for the event. + /// + public SmtpCommandSnapshot SafeCommand { get; } } } diff --git a/src/SmtpServer/SmtpCommandSnapshot.cs b/src/SmtpServer/SmtpCommandSnapshot.cs new file mode 100644 index 00000000..af3204ca --- /dev/null +++ b/src/SmtpServer/SmtpCommandSnapshot.cs @@ -0,0 +1,93 @@ +using System; +using SmtpServer.Mail; +using SmtpServer.Protocol; + +namespace SmtpServer +{ + /// + /// Provides a safe-to-log snapshot of an SMTP command. + /// + public sealed class SmtpCommandSnapshot + { + /// + /// The replacement text used for sensitive command arguments. + /// + public const string Redacted = ""; + + /// + /// Initializes a new instance of the class. + /// + /// The command name. + /// The safe command argument. + public SmtpCommandSnapshot(string name, string argument) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Argument = argument; + } + + /// + /// Creates a safe-to-log snapshot for the specified command. + /// + /// The command to snapshot. + /// The safe command snapshot. + public static SmtpCommandSnapshot From(SmtpCommand command) + { + if (command == null) + { + throw new ArgumentNullException(nameof(command)); + } + + switch (command) + { + case AuthCommand authCommand: + return new SmtpCommandSnapshot(authCommand.Name, $"{authCommand.Method} {Redacted}"); + + case BdatCommand bdatCommand: + return new SmtpCommandSnapshot(bdatCommand.Name, bdatCommand.IsLast ? $"{bdatCommand.Size} LAST" : bdatCommand.Size.ToString()); + + case EhloCommand ehloCommand: + return new SmtpCommandSnapshot(ehloCommand.Name, ehloCommand.DomainOrAddress); + + case HeloCommand heloCommand: + return new SmtpCommandSnapshot(heloCommand.Name, heloCommand.DomainOrAddress); + + case HelpCommand helpCommand: + return new SmtpCommandSnapshot(helpCommand.Name, helpCommand.Argument); + + case MailCommand mailCommand: + return new SmtpCommandSnapshot(mailCommand.Name, $"FROM:<{mailCommand.Address.AsAddress()}>"); + + case RcptCommand rcptCommand: + return new SmtpCommandSnapshot(rcptCommand.Name, $"TO:<{rcptCommand.Address.AsAddress()}>"); + + case VrfyCommand vrfyCommand: + return new SmtpCommandSnapshot(vrfyCommand.Name, vrfyCommand.Argument); + + case ExpnCommand expnCommand: + return new SmtpCommandSnapshot(expnCommand.Name, expnCommand.Argument); + + default: + return new SmtpCommandSnapshot(command.Name, null); + } + } + + /// + /// Gets the command name. + /// + public string Name { get; } + + /// + /// Gets the safe command argument. + /// + public string Argument { get; } + + /// + /// Returns the safe command text. + /// + /// The safe command text. + public override string ToString() + { + return string.IsNullOrEmpty(Argument) ? Name : $"{Name} {Argument}"; + } + } +} diff --git a/src/SmtpServer/SmtpMessageRecipient.cs b/src/SmtpServer/SmtpMessageRecipient.cs new file mode 100644 index 00000000..b87fcb69 --- /dev/null +++ b/src/SmtpServer/SmtpMessageRecipient.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using SmtpServer.Mail; + +namespace SmtpServer +{ + sealed class SmtpMessageRecipient : IMessageRecipient + { + public SmtpMessageRecipient(IMailbox address, IReadOnlyDictionary parameters) + { + Address = address; + Parameters = parameters ?? new Dictionary(); + } + + public IMailbox Address { get; } + + public IReadOnlyDictionary Parameters { get; } + } +} diff --git a/src/SmtpServer/SmtpMessageTransaction.cs b/src/SmtpServer/SmtpMessageTransaction.cs index 010a4b13..b95b1810 100644 --- a/src/SmtpServer/SmtpMessageTransaction.cs +++ b/src/SmtpServer/SmtpMessageTransaction.cs @@ -7,7 +7,7 @@ namespace SmtpServer /// /// Smtp Message Transaction /// - internal sealed class SmtpMessageTransaction : IMessageTransaction + internal sealed class SmtpMessageTransaction : IMessageTransaction, IParameterizedMessageTransaction { /// /// Reset the current transaction. @@ -16,6 +16,7 @@ public void Reset() { From = null; To = new Collection(); + Recipients = new Collection(); Parameters = new ReadOnlyDictionary(new Dictionary()); } @@ -25,6 +26,12 @@ public void Reset() /// public IList To { get; set; } = new Collection(); + /// + public Collection Recipients { get; private set; } = new Collection(); + + /// + IReadOnlyList IParameterizedMessageTransaction.Recipients => Recipients; + /// public IReadOnlyDictionary Parameters { get; set; } = new ReadOnlyDictionary(new Dictionary()); } diff --git a/src/SmtpServer/SmtpServer.cs b/src/SmtpServer/SmtpServer.cs index d4c4a75c..533102d2 100644 --- a/src/SmtpServer/SmtpServer.cs +++ b/src/SmtpServer/SmtpServer.cs @@ -2,7 +2,9 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using SmtpServer.ComponentModel; +using SmtpServer.Logging; using SmtpServer.Net; namespace SmtpServer @@ -36,6 +38,7 @@ public class SmtpServer readonly IServiceProvider _serviceProvider; readonly IEndpointListenerFactory _endpointListenerFactory; readonly SmtpSessionManager _sessions; + readonly ILogger _logger; readonly CancellationTokenSource _shutdownTokenSource = new CancellationTokenSource(); readonly TaskCompletionSource _shutdownTask = new TaskCompletionSource(); @@ -48,7 +51,9 @@ public SmtpServer(ISmtpServerOptions options, IServiceProvider serviceProvider) { _options = options; _serviceProvider = serviceProvider; - _sessions = new SmtpSessionManager(this); + var loggerFactory = SmtpLoggerFactory.Resolve(serviceProvider); + _logger = loggerFactory.CreateLogger(); + _sessions = new SmtpSessionManager(this, loggerFactory); _endpointListenerFactory = serviceProvider.GetServiceOrDefault(EndpointListenerFactory.Default); } @@ -95,13 +100,19 @@ protected internal virtual void OnSessionCancelled(SessionEventArgs args) /// A task which performs the operation. public async Task StartAsync(CancellationToken cancellationToken) { + _logger.LogInformation("SMTP server starting with {EndpointCount} endpoint(s).", _options.Endpoints.Count()); + var tasks = _options.Endpoints.Select(e => ListenAsync(e, cancellationToken)); await Task.WhenAll(tasks).ConfigureAwait(false); + _logger.LogInformation("SMTP server stopped accepting new sessions."); + _shutdownTask.TrySetResult(true); await _sessions.WaitAsync().ConfigureAwait(false); + + _logger.LogInformation("SMTP server stopped."); } /// @@ -125,6 +136,7 @@ async Task ListenAsync(IEndpointDefinition endpointDefinition, CancellationToken var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_shutdownTokenSource.Token, cancellationToken); using var endpointListener = _endpointListenerFactory.CreateListener(endpointDefinition); + _logger.LogInformation("SMTP endpoint listening on {EndPoint}.", endpointDefinition.Endpoint); while (cancellationTokenSource.Token.IsCancellationRequested == false) { @@ -138,6 +150,7 @@ async Task ListenAsync(IEndpointDefinition endpointDefinition, CancellationToken catch (OperationCanceledException) { } catch (Exception ex) { + _logger.LogError(ex, "SMTP listener failed while accepting a session on {EndPoint}.", endpointDefinition.Endpoint); OnSessionFaulted(new SessionFaultedEventArgs(sessionContext, ex)); continue; } diff --git a/src/SmtpServer/SmtpServer.csproj b/src/SmtpServer/SmtpServer.csproj index 8374ef9b..60575368 100644 --- a/src/SmtpServer/SmtpServer.csproj +++ b/src/SmtpServer/SmtpServer.csproj @@ -5,7 +5,8 @@ 8.0 SmtpServer SmtpServer - 10.0.1 + DevOp.SMTPServer + smtp-server-v High-performance, flexible SMTP server implementation for .NET with support for ESMTP, TLS, authentication, and custom message handling. Cain O'Sullivan 2015-2023 @@ -35,7 +36,9 @@ + + diff --git a/src/SmtpServer/SmtpServerExtensionOptions.cs b/src/SmtpServer/SmtpServerExtensionOptions.cs new file mode 100644 index 00000000..d778ea1a --- /dev/null +++ b/src/SmtpServer/SmtpServerExtensionOptions.cs @@ -0,0 +1,86 @@ +namespace SmtpServer +{ + /// + /// Defines the SMTP protocol extensions that the server advertises and accepts. + /// + public sealed class SmtpServerExtensionOptions + { + /// + /// Initializes a new instance of the class. + /// + public SmtpServerExtensionOptions() + { + SmtpUtf8Enabled = true; + DsnEnabled = true; + ChunkingEnabled = true; + OAuthEnabled = false; + } + + /// + /// Enables or disables SMTPUTF8 advertisement and SMTPUTF8-specific envelope handling. + /// + /// A value indicating whether SMTPUTF8 is enabled. + /// The current options instance. + public SmtpServerExtensionOptions SmtpUtf8(bool enabled) + { + SmtpUtf8Enabled = enabled; + return this; + } + + /// + /// Enables or disables DSN advertisement and DSN envelope parameters. + /// + /// A value indicating whether DSN is enabled. + /// The current options instance. + public SmtpServerExtensionOptions Dsn(bool enabled) + { + DsnEnabled = enabled; + return this; + } + + /// + /// Enables or disables CHUNKING advertisement and BDAT command processing. + /// + /// A value indicating whether CHUNKING is enabled. + /// The current options instance. + public SmtpServerExtensionOptions Chunking(bool enabled) + { + ChunkingEnabled = enabled; + return this; + } + + /// + /// Enables or disables advertisement of the XOAUTH2 and OAUTHBEARER bearer-token SASL mechanisms. + /// Off by default: a host should only advertise them once it has wired a bearer-token authenticator, + /// otherwise clients would negotiate a mechanism the host cannot honour. The mechanisms are always + /// parsed and processed when a client sends them; this flag only controls EHLO discovery. + /// + /// A value indicating whether the OAuth bearer-token mechanisms are advertised. + /// The current options instance. + public SmtpServerExtensionOptions OAuth(bool enabled) + { + OAuthEnabled = enabled; + return this; + } + + /// + /// Gets a value indicating whether SMTPUTF8 is enabled. + /// + public bool SmtpUtf8Enabled { get; private set; } + + /// + /// Gets a value indicating whether the XOAUTH2 and OAUTHBEARER bearer-token mechanisms are advertised. + /// + public bool OAuthEnabled { get; private set; } + + /// + /// Gets a value indicating whether DSN is enabled. + /// + public bool DsnEnabled { get; private set; } + + /// + /// Gets a value indicating whether CHUNKING is enabled. + /// + public bool ChunkingEnabled { get; private set; } + } +} diff --git a/src/SmtpServer/SmtpServerOptionsBuilder.cs b/src/SmtpServer/SmtpServerOptionsBuilder.cs index 1c9b46eb..ab6680c4 100644 --- a/src/SmtpServer/SmtpServerOptionsBuilder.cs +++ b/src/SmtpServer/SmtpServerOptionsBuilder.cs @@ -8,6 +8,11 @@ namespace SmtpServer /// public sealed class SmtpServerOptionsBuilder { + /// + /// The default maximum SMTP command line length in bytes, excluding the terminating CRLF. + /// + public const int DefaultMaxCommandLineLength = 4096; + readonly List> _setters = new List>(); /// @@ -19,11 +24,14 @@ public ISmtpServerOptions Build() var serverOptions = new SmtpServerOptions { MaxMessageSizeOptions = new MaxMessageSizeOptions(), + MaxCommandLineLength = DefaultMaxCommandLineLength, Endpoints = new List(), MaxRetryCount = 5, MaxAuthenticationAttempts = 3, NetworkBufferSize = 128, CommandWaitTimeout = TimeSpan.FromMinutes(5), + Extensions = new SmtpServerExtensionOptions(), + SessionPolicy = new SmtpServerSessionPolicyOptions(), CustomSmtpGreeting = null, }; @@ -110,6 +118,23 @@ public SmtpServerOptionsBuilder MaxMessageSize(int length, MaxMessageSizeHandlin return this; } + /// + /// Sets the maximum SMTP command line length in bytes, excluding the terminating CRLF. + /// + /// The maximum command line length to allow in bytes. + /// An OptionsBuilder to continue building on. + public SmtpServerOptionsBuilder MaxCommandLineLength(int length) + { + if (length <= 0) + { + throw new ArgumentOutOfRangeException(nameof(length), "The maximum command line length must be greater than zero."); + } + + _setters.Add(options => options.MaxCommandLineLength = length); + + return this; + } + /// /// Sets the maximum number of retries for a failed command. /// @@ -134,6 +159,40 @@ public SmtpServerOptionsBuilder MaxAuthenticationAttempts(int value) return this; } + /// + /// Configures the SMTP extensions that are advertised and accepted. + /// + /// The callback used to configure the SMTP extension options. + /// A OptionsBuilder to continue building on. + public SmtpServerOptionsBuilder Extensions(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + _setters.Add(options => configure(options.Extensions)); + + return this; + } + + /// + /// Configures optional SMTP session policy callbacks. + /// + /// The callback used to configure the SMTP session policy options. + /// A OptionsBuilder to continue building on. + public SmtpServerOptionsBuilder SessionPolicy(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + _setters.Add(options => configure(options.SessionPolicy)); + + return this; + } + /// /// Sets the size of the buffer for each read operation. /// @@ -141,6 +200,11 @@ public SmtpServerOptionsBuilder MaxAuthenticationAttempts(int value) /// An OptionsBuilder to continue building on. public SmtpServerOptionsBuilder NetworkBufferSize(int value) { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "The network buffer size must be greater than zero."); + } + _setters.Add(options => options.NetworkBufferSize = value); return this; @@ -184,6 +248,11 @@ class SmtpServerOptions : ISmtpServerOptions /// public IMaxMessageSizeOptions MaxMessageSizeOptions { get; set; } + /// + /// Gets or sets the maximum SMTP command line length in bytes, excluding the terminating CRLF. + /// + public int MaxCommandLineLength { get; set; } + /// /// The maximum number of retries before quitting the session. /// @@ -199,6 +268,16 @@ class SmtpServerOptions : ISmtpServerOptions /// public string ServerName { get; set; } + /// + /// Gets or sets the SMTP extension options. + /// + public SmtpServerExtensionOptions Extensions { get; set; } + + /// + /// Gets or sets the SMTP session policy options. + /// + public SmtpServerSessionPolicyOptions SessionPolicy { get; set; } + /// /// Gets or sets the endpoint to listen on. /// diff --git a/src/SmtpServer/SmtpServerSessionPolicyOptions.cs b/src/SmtpServer/SmtpServerSessionPolicyOptions.cs new file mode 100644 index 00000000..00e5aa6e --- /dev/null +++ b/src/SmtpServer/SmtpServerSessionPolicyOptions.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using SmtpServer.Protocol; + +namespace SmtpServer +{ + /// + /// Defines optional SMTP session policy callbacks. + /// + public sealed class SmtpServerSessionPolicyOptions + { + /// + /// Configures a callback that runs after a connection is accepted and before the SMTP greeting is sent. + /// + /// The callback used to evaluate the accepted connection. + /// The current options instance. + public SmtpServerSessionPolicyOptions OnConnectionAccepted(Func> callback) + { + ConnectionAccepted = callback ?? throw new ArgumentNullException(nameof(callback)); + return this; + } + + /// + /// Configures a callback that runs before accepting an EHLO or HELO identity. + /// + /// The callback used to evaluate the supplied EHLO or HELO identity. + /// The current options instance. + public SmtpServerSessionPolicyOptions OnHelo(Func> callback) + { + Helo = callback ?? throw new ArgumentNullException(nameof(callback)); + return this; + } + + /// + /// Gets the callback that evaluates accepted connections. + /// + public Func> ConnectionAccepted { get; private set; } + + /// + /// Gets the callback that evaluates EHLO and HELO identities. + /// + public Func> Helo { get; private set; } + } +} diff --git a/src/SmtpServer/SmtpSession.cs b/src/SmtpServer/SmtpSession.cs index dfc84618..a4ab016d 100644 --- a/src/SmtpServer/SmtpSession.cs +++ b/src/SmtpServer/SmtpSession.cs @@ -9,6 +9,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace SmtpServer { @@ -20,14 +21,17 @@ internal sealed class SmtpSession readonly SmtpStateMachine _stateMachine; readonly SmtpSessionContext _context; readonly ISmtpCommandFactory _commandFactory; + readonly ILogger _logger; /// /// Constructor. /// /// The session context. - internal SmtpSession(SmtpSessionContext context) + /// The logger to write session diagnostics to. + internal SmtpSession(SmtpSessionContext context, ILogger logger) { _context = context; + _logger = logger; _stateMachine = new SmtpStateMachine(_context); _commandFactory = context.ServiceProvider.GetServiceOrDefault(new SmtpCommandFactory()); } @@ -44,6 +48,11 @@ internal async Task RunAsync(CancellationToken cancellationToken) return; } + if (await IsConnectionAcceptedAsync(cancellationToken).ConfigureAwait(false) == false) + { + return; + } + await OutputGreetingAsync(cancellationToken).ConfigureAwait(false); await ExecuteAsync(_context, cancellationToken).ConfigureAwait(false); @@ -84,6 +93,7 @@ async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellati } catch (SmtpResponseException responseException) when (responseException.IsQuitRequested) { + LogResponseException(responseException, retries); context.RaiseResponseException(responseException); await context.Pipe.Output.WriteReplyAsync(responseException.Response, cancellationToken).ConfigureAwait(false); @@ -92,6 +102,7 @@ async Task ExecuteAsync(SmtpSessionContext context, CancellationToken cancellati } catch (SmtpResponseException responseException) { + LogResponseException(responseException, retries); context.RaiseResponseException(responseException); var response = CreateErrorResponse(responseException.Response, retries); @@ -132,7 +143,7 @@ await context.Pipe.Input.ReadLineAsync( return Task.CompletedTask; }, - context.ServerOptions.MaxMessageSizeOptions, + context.ServerOptions.MaxCommandLineLength, cancellationTokenSource.Token).ConfigureAwait(false); return command; @@ -171,17 +182,32 @@ static SmtpResponse CreateErrorResponse(SmtpResponse response, int retries) /// The execution context to operate on. /// The cancellation token. /// A task which asynchronously performs the execution. - static async Task ExecuteAsync(SmtpCommand command, SmtpSessionContext context, CancellationToken cancellationToken) + async Task ExecuteAsync(SmtpCommand command, SmtpSessionContext context, CancellationToken cancellationToken) { + var safeCommand = SmtpCommandSnapshot.From(command); + + _logger.LogDebug("SMTP command executing: {CommandName} {CommandArgument}.", safeCommand.Name, safeCommand.Argument); context.RaiseCommandExecuting(command); var result = await command.ExecuteAsync(context, cancellationToken); context.RaiseCommandExecuted(command); + _logger.LogDebug("SMTP command executed: {CommandName} {CommandArgument}.", safeCommand.Name, safeCommand.Argument); return result; } + void LogResponseException(SmtpResponseException responseException, int retries) + { + _logger.LogWarning( + responseException, + "SMTP response exception {ReplyCode}: {ReplyMessage}. QuitRequested={QuitRequested}, RetriesRemaining={RetriesRemaining}.", + responseException.Response.ReplyCode, + responseException.Response.Message, + responseException.IsQuitRequested, + retries); + } + /// /// Output the greeting. /// @@ -201,5 +227,36 @@ ValueTask OutputGreetingAsync(CancellationToken cancellationToken) return _context.Pipe.Output.FlushAsync(cancellationToken); } + + async Task IsConnectionAcceptedAsync(CancellationToken cancellationToken) + { + var policy = _context.ServerOptions.SessionPolicy; + if (policy.ConnectionAccepted == null) + { + return true; + } + + var response = await policy.ConnectionAccepted(_context, cancellationToken).ConfigureAwait(false); + if (IsSuccessResponse(response)) + { + return true; + } + + await _context.Pipe.Output.WriteReplyAsync(response, cancellationToken).ConfigureAwait(false); + _context.IsQuitRequested = true; + _logger.LogWarning("SMTP session rejected by connection policy with {ReplyCode}: {ReplyMessage}.", response.ReplyCode, response.Message); + return false; + } + + internal static bool IsSuccessResponse(SmtpResponse response) + { + if (response == null) + { + return true; + } + + var replyCode = (int)response.ReplyCode; + return replyCode >= 200 && replyCode < 400; + } } } diff --git a/src/SmtpServer/SmtpSessionManager.cs b/src/SmtpServer/SmtpSessionManager.cs index d4900287..4739b999 100644 --- a/src/SmtpServer/SmtpSessionManager.cs +++ b/src/SmtpServer/SmtpSessionManager.cs @@ -3,22 +3,28 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SmtpServer.Logging; namespace SmtpServer { internal sealed class SmtpSessionManager { readonly SmtpServer _smtpServer; + readonly ILoggerFactory _loggerFactory; + readonly ILogger _logger; readonly ConcurrentDictionary _sessions = new ConcurrentDictionary(); - internal SmtpSessionManager(SmtpServer smtpServer) + internal SmtpSessionManager(SmtpServer smtpServer, ILoggerFactory loggerFactory) { _smtpServer = smtpServer; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); } internal void Run(SmtpSessionContext sessionContext, CancellationToken cancellationToken) { - var handle = new SmtpSessionHandle(new SmtpSession(sessionContext), sessionContext); + var handle = new SmtpSessionHandle(new SmtpSession(sessionContext, _loggerFactory.CreateLogger()), sessionContext); Add(handle); handle.CompletionTask = RunAsync(handle, cancellationToken).ContinueWith(task => @@ -29,12 +35,14 @@ internal void Run(SmtpSessionContext sessionContext, CancellationToken cancellat async Task RunAsync(SmtpSessionHandle handle, CancellationToken cancellationToken) { + using var scope = _logger.BeginScope(SmtpLoggerFactory.CreateSessionScope(handle.SessionContext)); using var sessionTimeoutCancellationTokenSource = new CancellationTokenSource(handle.SessionContext.EndpointDefinition.SessionTimeout); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, sessionTimeoutCancellationTokenSource.Token); try { + _logger.LogInformation("SMTP session created from {RemoteEndPoint}.", TryGetProperty(handle.SessionContext, Net.EndpointListener.RemoteEndPointKey)); _smtpServer.OnSessionCreated(new SessionEventArgs(handle.SessionContext)); await UpgradeAsync(handle, linkedTokenSource.Token); @@ -43,14 +51,17 @@ async Task RunAsync(SmtpSessionHandle handle, CancellationToken cancellationToke await handle.Session.RunAsync(linkedTokenSource.Token); + _logger.LogInformation("SMTP session completed."); _smtpServer.OnSessionCompleted(new SessionEventArgs(handle.SessionContext)); } catch (OperationCanceledException) { + _logger.LogInformation("SMTP session cancelled."); _smtpServer.OnSessionCancelled(new SessionEventArgs(handle.SessionContext)); } catch (Exception ex) { + _logger.LogError(ex, "SMTP session faulted."); _smtpServer.OnSessionFaulted(new SessionFaultedEventArgs(handle.SessionContext, ex)); } finally @@ -69,7 +80,9 @@ async Task UpgradeAsync(SmtpSessionHandle handle, CancellationToken cancellation { var serverCertificate = endpoint.CertificateFactory.GetServerCertificate(handle.SessionContext); + _logger.LogInformation("SMTP session upgrading to TLS using {SslProtocols}.", endpoint.SupportedSslProtocols); await handle.SessionContext.Pipe.UpgradeAsync(serverCertificate, endpoint.SupportedSslProtocols, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("SMTP session upgraded to TLS using {SslProtocol}.", handle.SessionContext.Pipe.SslProtocol); } } @@ -89,6 +102,11 @@ void Remove(SmtpSessionHandle handle) _sessions.TryRemove(handle.SessionContext.SessionId, out _); } + static object TryGetProperty(SmtpSessionContext context, string key) + { + return context.Properties.TryGetValue(key, out var value) ? value : null; + } + class SmtpSessionHandle { public SmtpSessionHandle(SmtpSession session, SmtpSessionContext sessionContext) diff --git a/src/SmtpServer/StateMachine/SmtpStateId.cs b/src/SmtpServer/StateMachine/SmtpStateId.cs index 942eb6ae..fa1b8eed 100644 --- a/src/SmtpServer/StateMachine/SmtpStateId.cs +++ b/src/SmtpServer/StateMachine/SmtpStateId.cs @@ -8,5 +8,6 @@ internal enum SmtpStateId WaitingForMailSecure = 3, WithinTransaction = 4, CanAcceptData = 5, + BdatInProgress = 6, } } diff --git a/src/SmtpServer/StateMachine/SmtpStateTable.cs b/src/SmtpServer/StateMachine/SmtpStateTable.cs index d795a9e7..c837791d 100644 --- a/src/SmtpServer/StateMachine/SmtpStateTable.cs +++ b/src/SmtpServer/StateMachine/SmtpStateTable.cs @@ -12,6 +12,9 @@ internal sealed class SmtpStateTable : IEnumerable new SmtpState(SmtpStateId.Initialized) { { NoopCommand.Command }, + { HelpCommand.Command }, + { VrfyCommand.Command }, + { ExpnCommand.Command }, { RsetCommand.Command }, { QuitCommand.Command }, { ProxyCommand.Command }, @@ -21,6 +24,9 @@ internal sealed class SmtpStateTable : IEnumerable new SmtpState(SmtpStateId.WaitingForMail) { { NoopCommand.Command }, + { HelpCommand.Command }, + { VrfyCommand.Command }, + { ExpnCommand.Command }, { RsetCommand.Command }, { QuitCommand.Command }, { StartTlsCommand.Command, CanAcceptStartTls, SmtpStateId.WaitingForMailSecure }, @@ -32,6 +38,9 @@ internal sealed class SmtpStateTable : IEnumerable new SmtpState(SmtpStateId.WaitingForMailSecure) { { NoopCommand.Command }, + { HelpCommand.Command }, + { VrfyCommand.Command }, + { ExpnCommand.Command }, { RsetCommand.Command }, { QuitCommand.Command }, { AuthCommand.Command, context => context.Authentication.IsAuthenticated == false }, @@ -42,6 +51,9 @@ internal sealed class SmtpStateTable : IEnumerable new SmtpState(SmtpStateId.WithinTransaction) { { NoopCommand.Command }, + { HelpCommand.Command }, + { VrfyCommand.Command }, + { ExpnCommand.Command }, { RsetCommand.Command, WaitingForMailSecureWhenSecure }, { QuitCommand.Command }, { RcptCommand.Command, SmtpStateId.CanAcceptData }, @@ -49,10 +61,20 @@ internal sealed class SmtpStateTable : IEnumerable new SmtpState(SmtpStateId.CanAcceptData) { { NoopCommand.Command }, + { HelpCommand.Command }, + { VrfyCommand.Command }, + { ExpnCommand.Command }, { RsetCommand.Command, WaitingForMailSecureWhenSecure }, { QuitCommand.Command }, { RcptCommand.Command }, { DataCommand.Command, SmtpStateId.WaitingForMail }, + { BdatCommand.Command, BdatNextState }, + }, + new SmtpState(SmtpStateId.BdatInProgress) + { + { RsetCommand.Command, WaitingForMailSecureWhenSecure }, + { QuitCommand.Command }, + { BdatCommand.Command, BdatNextState }, } }; @@ -66,6 +88,16 @@ static bool CanAcceptStartTls(SmtpSessionContext context) return context.EndpointDefinition.CertificateFactory != null && context.Pipe.IsSecure == false; } + static SmtpStateId BdatNextState(SmtpSessionContext context) + { + if (context.Properties.TryGetValue(BdatCommand.LastChunkKey, out var value) && value is bool isLast && isLast) + { + return WaitingForMailSecureWhenSecure(context); + } + + return SmtpStateId.BdatInProgress; + } + readonly IDictionary _states = new Dictionary(); internal SmtpState this[SmtpStateId stateId] => _states[stateId]; diff --git a/src/SmtpServer/Storage/CompositeMailboxFilter.cs b/src/SmtpServer/Storage/CompositeMailboxFilter.cs index eabd5f47..0eb28aa2 100644 --- a/src/SmtpServer/Storage/CompositeMailboxFilter.cs +++ b/src/SmtpServer/Storage/CompositeMailboxFilter.cs @@ -1,11 +1,12 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using SmtpServer.Mail; namespace SmtpServer.Storage { - internal sealed class CompositeMailboxFilter : IMailboxFilter + internal sealed class CompositeMailboxFilter : IParameterizedMailboxFilter { readonly IMailboxFilter[] _filters; @@ -55,13 +56,27 @@ public async Task CanDeliverToAsync( IMailbox to, IMailbox @from, CancellationToken cancellationToken = default) + { + return await CanDeliverToAsync(context, to, @from, new Dictionary(), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task CanDeliverToAsync( + ISessionContext context, + IMailbox to, + IMailbox @from, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken = default) { if (_filters == null || _filters.Any() == false) { return true; } - var results = await Task.WhenAll(_filters.Select(f => f.CanDeliverToAsync(context, to, @from, cancellationToken))).ConfigureAwait(false); + var results = await Task.WhenAll(_filters.Select(f => + f is IParameterizedMailboxFilter parameterizedMailboxFilter + ? parameterizedMailboxFilter.CanDeliverToAsync(context, to, @from, parameters, cancellationToken) + : f.CanDeliverToAsync(context, to, @from, cancellationToken))).ConfigureAwait(false); return results.All(r => r == true); } diff --git a/src/SmtpServer/Storage/IParameterizedMailboxFilter.cs b/src/SmtpServer/Storage/IParameterizedMailboxFilter.cs new file mode 100644 index 00000000..1e7fe369 --- /dev/null +++ b/src/SmtpServer/Storage/IParameterizedMailboxFilter.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SmtpServer.Mail; + +namespace SmtpServer.Storage +{ + /// + /// Optional mailbox filter interface for recipient parameters supplied on the RCPT command. + /// + public interface IParameterizedMailboxFilter : IMailboxFilter + { + /// + /// Returns a value indicating whether the given mailbox can be accepted as a recipient to the given sender. + /// + /// The session context. + /// The mailbox to test. + /// The sender's mailbox. + /// The recipient parameters supplied on the RCPT command. + /// The cancellation token. + /// Returns true if the mailbox can be delivered to, false if not. + Task CanDeliverToAsync( + ISessionContext context, + IMailbox to, + IMailbox from, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken); + } +} diff --git a/src/SmtpServer/Storage/IStreamingMessageStore.cs b/src/SmtpServer/Storage/IStreamingMessageStore.cs new file mode 100644 index 00000000..996949ee --- /dev/null +++ b/src/SmtpServer/Storage/IStreamingMessageStore.cs @@ -0,0 +1,23 @@ +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using SmtpServer.Protocol; + +namespace SmtpServer.Storage +{ + /// + /// Streaming Message Store Interface + /// + public interface IStreamingMessageStore : IMessageStore + { + /// + /// Save the given message to the underlying storage system. + /// + /// The session level context. + /// The SMTP message transaction to store. + /// The reader that streams the message content. + /// The cancellation token. + /// The response code to return that indicates the result of the message being saved. + Task SaveAsync(ISessionContext context, IMessageTransaction transaction, PipeReader reader, CancellationToken cancellationToken); + } +} diff --git a/src/SmtpServer/Storage/MailboxFilter.cs b/src/SmtpServer/Storage/MailboxFilter.cs index 940fe52e..7ad99490 100644 --- a/src/SmtpServer/Storage/MailboxFilter.cs +++ b/src/SmtpServer/Storage/MailboxFilter.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using SmtpServer.Mail; @@ -7,7 +8,7 @@ namespace SmtpServer.Storage /// /// Mailbox Filter /// - public abstract class MailboxFilter : IMailboxFilter + public abstract class MailboxFilter : IParameterizedMailboxFilter { /// /// Default Mailbox Filter @@ -34,6 +35,17 @@ public virtual Task CanDeliverToAsync( return Task.FromResult(true); } + /// + public virtual Task CanDeliverToAsync( + ISessionContext context, + IMailbox to, + IMailbox @from, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken) + { + return CanDeliverToAsync(context, to, @from, cancellationToken); + } + sealed class DefaultMailboxFilter : MailboxFilter { } } } diff --git a/src/SmtpServer/Text/TokenReader.cs b/src/SmtpServer/Text/TokenReader.cs index 1a45c5d9..e852f860 100644 --- a/src/SmtpServer/Text/TokenReader.cs +++ b/src/SmtpServer/Text/TokenReader.cs @@ -167,20 +167,15 @@ public bool TryMake(TryMakeDelegate @delegate, out TOut found) /// true if the match could be made, false if not. public bool TryMake(TryMakeDelegate @delegate, out TOut1 value1, out TOut2 value2) { - if (_buffer.IsSingleSegment) - { - var checkpoint = Checkpoint(); - - if (@delegate(ref this, out value1, out value2) == false) - { - Rollback(ref checkpoint); - return false; - } + var checkpoint = Checkpoint(); - return true; + if (@delegate(ref this, out value1, out value2) == false) + { + Rollback(ref checkpoint); + return false; } - throw new NotImplementedException(); + return true; } /// diff --git a/src/SmtpServer/Tracing/TracingSmtpCommandVisitor.cs b/src/SmtpServer/Tracing/TracingSmtpCommandVisitor.cs index 1e2317cf..0d34936f 100644 --- a/src/SmtpServer/Tracing/TracingSmtpCommandVisitor.cs +++ b/src/SmtpServer/Tracing/TracingSmtpCommandVisitor.cs @@ -33,7 +33,7 @@ public TracingSmtpCommandVisitor(TextWriter output) /// The command that is being visited. protected override void Visit(AuthCommand command) { - _output.WriteLine("AUTH: Method={0}, Parameter={1}", command.Method, command.Parameter); + _output.WriteLine("AUTH: Method={0}, Parameter={1}", command.Method, SmtpCommandSnapshot.Redacted); } /// @@ -74,6 +74,42 @@ protected override void Visit(MailCommand command) string.Join(",", command.Parameters.Select(kvp => $"{kvp.Key}={kvp.Value}"))); } + /// + /// Visit a HELP command. + /// + /// The command that is being visited. + protected override void Visit(HelpCommand command) + { + _output.WriteLine("HELP: Argument={0}", command.Argument); + } + + /// + /// Visit a VRFY command. + /// + /// The command that is being visited. + protected override void Visit(VrfyCommand command) + { + _output.WriteLine("VRFY: Argument={0}", command.Argument); + } + + /// + /// Visit an EXPN command. + /// + /// The command that is being visited. + protected override void Visit(ExpnCommand command) + { + _output.WriteLine("EXPN: Argument={0}", command.Argument); + } + + /// + /// Visit a BDAT command. + /// + /// The command that is being visited. + protected override void Visit(BdatCommand command) + { + _output.WriteLine("BDAT: Size={0}, Last={1}", command.Size, command.IsLast); + } + /// /// Visit an NOOP command. /// @@ -107,7 +143,9 @@ protected override void Visit(QuitCommand command) /// The command that is being visited. protected override void Visit(RcptCommand command) { - _output.WriteLine("RCPT: Address={0}", command.Address.AsAddress()); + _output.WriteLine("RCPT: Address={0} Parameters={1}", + command.Address.AsAddress(), + string.Join(",", command.Parameters.Select(kvp => $"{kvp.Key}={kvp.Value}"))); } ///