Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions src/SmtpServer.Tests/SmtpParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<sender@example.com> 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<MailCommand>(command);
Assert.Equal("FULL", mailCommand.Parameters["RET"]);
Assert.Equal("abc123", mailCommand.Parameters["envid"]);
}

[Fact]
public void CanMakeMailWithNoAddress()
{
Expand Down Expand Up @@ -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:<recipient@example.com> 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<RcptCommand>(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:<someone@@example.com>")]
[InlineData("RCPT TO:<someone@example..com>")]
Expand Down
68 changes: 68 additions & 0 deletions src/SmtpServer.Tests/SmtpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -144,6 +145,52 @@ public void CanReceiveBccInMessageTransaction()
}
}

[Fact]
public async Task CanReceiveDsnEnvelopeParameters()
{
IReadOnlyDictionary<string, string> 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:<sender@example.com> RET=FULL ENVID=abc123");
Assert.StartsWith("250 Ok", response);

response = await rawSmtpClient.SendCommandAsync("RCPT TO:<recipient@example.com> 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()
{
Expand Down Expand Up @@ -655,5 +702,26 @@ SmtpServerDisposable CreateServer(
/// The cancellation token source for the test.
/// </summary>
public CancellationTokenSource CancellationTokenSource { get; }

sealed class ParameterizedMailboxFilter : MailboxFilter
{
readonly Func<ISessionContext, IMailbox, IMailbox, IReadOnlyDictionary<string, string>, CancellationToken, Task<bool>> _canDeliverDelegate;

public ParameterizedMailboxFilter(
Func<ISessionContext, IMailbox, IMailbox, IReadOnlyDictionary<string, string>, CancellationToken, Task<bool>> canDeliverDelegate)
{
_canDeliverDelegate = canDeliverDelegate;
}

public override Task<bool> CanDeliverToAsync(
ISessionContext context,
IMailbox to,
IMailbox @from,
IReadOnlyDictionary<string, string> parameters,
CancellationToken cancellationToken)
{
return _canDeliverDelegate(context, to, @from, parameters, cancellationToken);
}
}
}
}
21 changes: 21 additions & 0 deletions src/SmtpServer/IMessageRecipient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Collections.Generic;
using SmtpServer.Mail;

namespace SmtpServer
{
/// <summary>
/// Message recipient with the parameters supplied on the RCPT command.
/// </summary>
public interface IMessageRecipient
{
/// <summary>
/// Gets the recipient mailbox address.
/// </summary>
IMailbox Address { get; }

/// <summary>
/// Gets the parameters that were supplied for the recipient.
/// </summary>
IReadOnlyDictionary<string, string> Parameters { get; }
}
}
15 changes: 15 additions & 0 deletions src/SmtpServer/IParameterizedMessageTransaction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Collections.Generic;

namespace SmtpServer
{
/// <summary>
/// Optional message transaction interface for recipient-specific parameters.
/// </summary>
public interface IParameterizedMessageTransaction
{
/// <summary>
/// Gets the accepted recipients and their RCPT command parameters.
/// </summary>
IReadOnlyList<IMessageRecipient> Recipients { get; }
}
}
26 changes: 26 additions & 0 deletions src/SmtpServer/MessageTransactionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;

namespace SmtpServer
{
/// <summary>
/// Extension methods for message transactions.
/// </summary>
public static class MessageTransactionExtensions
{
/// <summary>
/// Gets recipient-specific parameters when the transaction provides them.
/// </summary>
/// <param name="transaction">The message transaction.</param>
/// <returns>The accepted recipients and their RCPT command parameters.</returns>
public static IReadOnlyList<IMessageRecipient> GetRecipients(this IMessageTransaction transaction)
{
if (transaction is IParameterizedMessageTransaction parameterized)
{
return parameterized.Recipients;
}

return Array.Empty<IMessageRecipient>();
}
}
}
1 change: 1 addition & 0 deletions src/SmtpServer/Protocol/EhloCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ protected virtual IEnumerable<string> GetExtensions(ISessionContext context)
yield return "PIPELINING";
yield return "8BITMIME";
yield return "SMTPUTF8";
yield return "DSN";

if (context.Pipe.IsSecure == false && context.EndpointDefinition.CertificateFactory != null)
{
Expand Down
19 changes: 19 additions & 0 deletions src/SmtpServer/Protocol/IParameterizedSmtpCommandFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using SmtpServer.Mail;

namespace SmtpServer.Protocol
{
/// <summary>
/// Optional SMTP command factory interface for commands with ESMTP parameters.
/// </summary>
public interface IParameterizedSmtpCommandFactory : ISmtpCommandFactory
{
/// <summary>
/// Create a RCPT command.
/// </summary>
/// <param name="address">The address that the mail is to.</param>
/// <param name="parameters">The optional recipient parameters.</param>
/// <returns>The RCPT command.</returns>
SmtpCommand CreateRcpt(IMailbox address, IReadOnlyDictionary<string, string> parameters);
}
}
26 changes: 24 additions & 2 deletions src/SmtpServer/Protocol/RcptCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using SmtpServer.ComponentModel;
Expand All @@ -22,9 +23,20 @@ public sealed class RcptCommand : SmtpCommand
/// Constructor.
/// </summary>
/// <param name="address">The address.</param>
public RcptCommand(IMailbox address) : base(Command)
public RcptCommand(IMailbox address)
: this(address, new Dictionary<string, string>())
{
}

/// <summary>
/// Constructor.
/// </summary>
/// <param name="address">The address.</param>
/// <param name="parameters">The list of recipient parameters.</param>
public RcptCommand(IMailbox address, IReadOnlyDictionary<string, string> parameters) : base(Command)
{
Address = address;
Parameters = parameters ?? new Dictionary<string, string>();
}

/// <summary>
Expand All @@ -40,10 +52,15 @@ internal override async Task<bool> ExecuteAsync(SmtpSessionContext context, Canc

using var container = new DisposableContainer<IMailboxFilter>(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;

Expand All @@ -59,5 +76,10 @@ internal override async Task<bool> ExecuteAsync(SmtpSessionContext context, Canc
/// Gets the address that the mail is to.
/// </summary>
public IMailbox Address { get; }

/// <summary>
/// The list of recipient parameters.
/// </summary>
public IReadOnlyDictionary<string, string> Parameters { get; }
}
}
10 changes: 8 additions & 2 deletions src/SmtpServer/Protocol/SmtpCommandFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace SmtpServer.Protocol
/// <summary>
/// Smtp Command Factory
/// </summary>
public class SmtpCommandFactory : ISmtpCommandFactory
public class SmtpCommandFactory : IParameterizedSmtpCommandFactory
{
/// <inheritdoc />
public virtual SmtpCommand CreateHelo(string domainOrAddress)
Expand All @@ -30,7 +30,13 @@ public virtual SmtpCommand CreateMail(IMailbox address, IReadOnlyDictionary<stri
/// <inheritdoc />
public virtual SmtpCommand CreateRcpt(IMailbox address)
{
return new RcptCommand(address);
return CreateRcpt(address, new Dictionary<string, string>());
}

/// <inheritdoc />
public virtual SmtpCommand CreateRcpt(IMailbox address, IReadOnlyDictionary<string, string> parameters)
{
return new RcptCommand(address, parameters);
}

/// <inheritdoc />
Expand Down
11 changes: 9 additions & 2 deletions src/SmtpServer/Protocol/SmtpParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> parameters) == false)
{
parameters = new Dictionary<string, string>();
}

command = _smtpCommandFactory.CreateRcpt(mailbox);
command = _smtpCommandFactory is IParameterizedSmtpCommandFactory parameterizedSmtpCommandFactory
? parameterizedSmtpCommandFactory.CreateRcpt(mailbox, parameters)
: _smtpCommandFactory.CreateRcpt(mailbox);
return true;
}

Expand Down
18 changes: 18 additions & 0 deletions src/SmtpServer/SmtpMessageRecipient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using SmtpServer.Mail;

namespace SmtpServer
{
sealed class SmtpMessageRecipient : IMessageRecipient
{
public SmtpMessageRecipient(IMailbox address, IReadOnlyDictionary<string, string> parameters)
{
Address = address;
Parameters = parameters ?? new Dictionary<string, string>();
}

public IMailbox Address { get; }

public IReadOnlyDictionary<string, string> Parameters { get; }
}
}
9 changes: 8 additions & 1 deletion src/SmtpServer/SmtpMessageTransaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace SmtpServer
/// <summary>
/// Smtp Message Transaction
/// </summary>
internal sealed class SmtpMessageTransaction : IMessageTransaction
internal sealed class SmtpMessageTransaction : IMessageTransaction, IParameterizedMessageTransaction
{
/// <summary>
/// Reset the current transaction.
Expand All @@ -16,6 +16,7 @@ public void Reset()
{
From = null;
To = new Collection<IMailbox>();
Recipients = new Collection<IMessageRecipient>();
Parameters = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
}

Expand All @@ -25,6 +26,12 @@ public void Reset()
/// <inheritdoc />
public IList<IMailbox> To { get; set; } = new Collection<IMailbox>();

/// <inheritdoc />
public Collection<IMessageRecipient> Recipients { get; private set; } = new Collection<IMessageRecipient>();

/// <inheritdoc />
IReadOnlyList<IMessageRecipient> IParameterizedMessageTransaction.Recipients => Recipients;

/// <inheritdoc />
public IReadOnlyDictionary<string, string> Parameters { get; set; } = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
}
Expand Down
Loading