From f3284b4db6ddf93811cec92f332c6cb26cd097f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Eorvaldur=20Hafdal?= Date: Wed, 15 Jul 2026 12:40:29 +0000 Subject: [PATCH] SS-8 Add DSN envelope parameters --- CHANGELOG.md | 1 + README.md | 3 + src/SmtpServer.Tests/SmtpParserTests.cs | 36 ++++++++++ src/SmtpServer.Tests/SmtpServerTests.cs | 68 +++++++++++++++++++ src/SmtpServer/IMessageRecipient.cs | 21 ++++++ .../IParameterizedMessageTransaction.cs | 15 ++++ .../MessageTransactionExtensions.cs | 26 +++++++ src/SmtpServer/Protocol/EhloCommand.cs | 1 + .../IParameterizedSmtpCommandFactory.cs | 19 ++++++ src/SmtpServer/Protocol/RcptCommand.cs | 26 ++++++- src/SmtpServer/Protocol/SmtpCommandFactory.cs | 10 ++- src/SmtpServer/Protocol/SmtpParser.cs | 11 ++- src/SmtpServer/SmtpMessageRecipient.cs | 18 +++++ src/SmtpServer/SmtpMessageTransaction.cs | 9 ++- .../Storage/CompositeMailboxFilter.cs | 21 +++++- .../Storage/IParameterizedMailboxFilter.cs | 29 ++++++++ src/SmtpServer/Storage/MailboxFilter.cs | 16 ++++- .../Tracing/TracingSmtpCommandVisitor.cs | 4 +- 18 files changed, 321 insertions(+), 13 deletions(-) create mode 100644 src/SmtpServer/IMessageRecipient.cs create mode 100644 src/SmtpServer/IParameterizedMessageTransaction.cs create mode 100644 src/SmtpServer/MessageTransactionExtensions.cs create mode 100644 src/SmtpServer/Protocol/IParameterizedSmtpCommandFactory.cs create mode 100644 src/SmtpServer/SmtpMessageRecipient.cs create mode 100644 src/SmtpServer/Storage/IParameterizedMailboxFilter.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e1929e..d3eb8553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 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. - Improved: Optimized protection against excessively long text segments to enhance stability and performance. ```cs diff --git a/README.md b/README.md index 9ff9b6a2..68e07889 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,11 @@ SmtpServer currently supports the following extensions: - PIPELINING - 8BITMIME - SMTPUTF8 +- DSN - AUTH PLAIN LOGIN +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. + ## Installation The package is available on [NuGet](https://www.nuget.org/packages/SmtpServer) diff --git a/src/SmtpServer.Tests/SmtpParserTests.cs b/src/SmtpServer.Tests/SmtpParserTests.cs index 555093cd..0c5585b5 100644 --- a/src/SmtpServer.Tests/SmtpParserTests.cs +++ b/src/SmtpServer.Tests/SmtpParserTests.cs @@ -173,6 +173,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() { @@ -244,6 +261,25 @@ 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("SUCCESS,FAILURE", rcptCommand.Parameters["NOTIFY"]); + Assert.Equal("rfc822;original@example.com", rcptCommand.Parameters["orcpt"]); + } + [Theory] [InlineData("RCPT TO:")] [InlineData("RCPT TO:")] diff --git a/src/SmtpServer.Tests/SmtpServerTests.cs b/src/SmtpServer.Tests/SmtpServerTests.cs index 292cdaa1..19099259 100644 --- a/src/SmtpServer.Tests/SmtpServerTests.cs +++ b/src/SmtpServer.Tests/SmtpServerTests.cs @@ -8,6 +8,7 @@ using SmtpServer.Storage; using SmtpServer.Tests.Mocks; using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -144,6 +145,52 @@ public void CanReceiveBccInMessageTransaction() } } + [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 Ok", response); + + response = await rawSmtpClient.SendCommandAsync("RCPT TO: notify=SUCCESS,FAILURE orcpt=rfc822;original@example.com"); + Assert.StartsWith("250 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 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() { @@ -655,5 +702,26 @@ SmtpServerDisposable CreateServer( /// The cancellation token source for the test. /// public CancellationTokenSource CancellationTokenSource { get; } + + 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); + } + } } } 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/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/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/Protocol/EhloCommand.cs b/src/SmtpServer/Protocol/EhloCommand.cs index e7975063..e0aa493e 100644 --- a/src/SmtpServer/Protocol/EhloCommand.cs +++ b/src/SmtpServer/Protocol/EhloCommand.cs @@ -69,6 +69,7 @@ protected virtual IEnumerable GetExtensions(ISessionContext context) yield return "PIPELINING"; yield return "8BITMIME"; yield return "SMTPUTF8"; + yield return "DSN"; if (context.Pipe.IsSecure == false && context.EndpointDefinition.CertificateFactory != null) { diff --git a/src/SmtpServer/Protocol/IParameterizedSmtpCommandFactory.cs b/src/SmtpServer/Protocol/IParameterizedSmtpCommandFactory.cs new file mode 100644 index 00000000..9b929697 --- /dev/null +++ b/src/SmtpServer/Protocol/IParameterizedSmtpCommandFactory.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using SmtpServer.Mail; + +namespace SmtpServer.Protocol +{ + /// + /// Optional SMTP command factory interface for commands with ESMTP parameters. + /// + public interface IParameterizedSmtpCommandFactory : ISmtpCommandFactory + { + /// + /// Create a RCPT command. + /// + /// The address that the mail is to. + /// The optional recipient parameters. + /// The RCPT command. + SmtpCommand CreateRcpt(IMailbox address, IReadOnlyDictionary parameters); + } +} diff --git a/src/SmtpServer/Protocol/RcptCommand.cs b/src/SmtpServer/Protocol/RcptCommand.cs index 1c1bc1ba..01599de3 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 list of recipient parameters. + public RcptCommand(IMailbox address, IReadOnlyDictionary parameters) : base(Command) { Address = address; + Parameters = parameters ?? new Dictionary(); } /// @@ -40,10 +52,15 @@ internal override async Task ExecuteAsync(SmtpSessionContext context, Canc 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; @@ -59,5 +76,10 @@ internal override async Task ExecuteAsync(SmtpSessionContext context, Canc /// Gets the address that the mail is to. /// public IMailbox Address { get; } + + /// + /// The list of recipient parameters. + /// + public IReadOnlyDictionary Parameters { get; } } } diff --git a/src/SmtpServer/Protocol/SmtpCommandFactory.cs b/src/SmtpServer/Protocol/SmtpCommandFactory.cs index e0c0d75e..fc1a872a 100644 --- a/src/SmtpServer/Protocol/SmtpCommandFactory.cs +++ b/src/SmtpServer/Protocol/SmtpCommandFactory.cs @@ -7,7 +7,7 @@ namespace SmtpServer.Protocol /// /// Smtp Command Factory /// - public class SmtpCommandFactory : ISmtpCommandFactory + public class SmtpCommandFactory : IParameterizedSmtpCommandFactory { /// public virtual SmtpCommand CreateHelo(string domainOrAddress) @@ -30,7 +30,13 @@ 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); } /// diff --git a/src/SmtpServer/Protocol/SmtpParser.cs b/src/SmtpServer/Protocol/SmtpParser.cs index 3b57b32c..494ec195 100644 --- a/src/SmtpServer/Protocol/SmtpParser.cs +++ b/src/SmtpServer/Protocol/SmtpParser.cs @@ -313,9 +313,16 @@ public bool TryMakeRcpt(ref TokenReader reader, out SmtpCommand command, out Smt return false; } - // TODO: support optional service extension parameters here + reader.Skip(TokenKind.Space); + + if (reader.TryMake(TryMakeMailParameters, out IReadOnlyDictionary parameters) == false) + { + parameters = new Dictionary(); + } - command = _smtpCommandFactory.CreateRcpt(mailbox); + command = _smtpCommandFactory is IParameterizedSmtpCommandFactory parameterizedSmtpCommandFactory + ? parameterizedSmtpCommandFactory.CreateRcpt(mailbox, parameters) + : _smtpCommandFactory.CreateRcpt(mailbox); return true; } 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/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/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/Tracing/TracingSmtpCommandVisitor.cs b/src/SmtpServer/Tracing/TracingSmtpCommandVisitor.cs index 1e2317cf..a6815e20 100644 --- a/src/SmtpServer/Tracing/TracingSmtpCommandVisitor.cs +++ b/src/SmtpServer/Tracing/TracingSmtpCommandVisitor.cs @@ -107,7 +107,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}"))); } ///