Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Smtp SessionTimout #227

Merged
merged 12 commits into from
Sep 9, 2024
68 changes: 68 additions & 0 deletions Src/SmtpServer.Tests/RawSmtpClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

namespace SmtpServer.Tests
{
internal class RawSmtpClient : IDisposable
{
private readonly TcpClient _tcpClient;
private NetworkStream _networkStream;

internal RawSmtpClient(string host, int port)
{
_tcpClient = new TcpClient();
}

public void Dispose()
{
_networkStream?.Dispose();
_tcpClient.Dispose();
}

internal async Task<bool> ConnectAsync()
{
await _tcpClient.ConnectAsync(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9025));
_networkStream = _tcpClient.GetStream();

var greetingResponse = await WaitForDataAsync();
if (greetingResponse.StartsWith("220"))
{
return true;
}

return false;
}

internal async Task<string> SendCommandAsync(string command)
{
var commandData = Encoding.UTF8.GetBytes($"{command}\r\n");

await _networkStream.WriteAsync(commandData, 0, commandData.Length);
return await WaitForDataAsync();
}

internal async Task SendDataAsync(string data)
{
var mailData = Encoding.UTF8.GetBytes(data);

await _networkStream.WriteAsync(mailData, 0, mailData.Length);
}

internal async Task<string> WaitForDataAsync()
{
var buffer = new byte[1024];
int bytesRead;

while ((bytesRead = await _networkStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
var receivedData = Encoding.UTF8.GetString(buffer, 0, bytesRead);
return receivedData;
}

return null;
}
}
}
74 changes: 74 additions & 0 deletions Src/SmtpServer.Tests/SmtpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using SmtpServer.Storage;
using SmtpServer.Tests.Mocks;
using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Security.Authentication;
Expand Down Expand Up @@ -159,6 +160,79 @@ public void WillTimeoutWaitingForCommand()
}
}

[Fact]
public async Task WillSessionTimeoutDuringMailDataTransmission()
{
var sessionTimeout = TimeSpan.FromSeconds(5);
var commandWaitTimeout = TimeSpan.FromSeconds(1);

using var disposable = CreateServer(
serverOptions => serverOptions.CommandWaitTimeout(commandWaitTimeout),
endpointDefinition => endpointDefinition.SessionTimeout(sessionTimeout));

var stopwatch = new Stopwatch();
stopwatch.Start();

using var rawSmtpClient = new RawSmtpClient("127.0.0.1", 9025);
await rawSmtpClient.ConnectAsync();

var response = await rawSmtpClient.SendCommandAsync("helo test");
if (!response.StartsWith("250"))
{
Assert.Fail("helo command not successful");
}

response = await rawSmtpClient.SendCommandAsync("mail from:<[email protected]>");
if (!response.StartsWith("250"))
{
Assert.Fail("mail from command not successful");
}

response = await rawSmtpClient.SendCommandAsync("rcpt to:<[email protected]>");
if (!response.StartsWith("250"))
{
Assert.Fail("rcpt to command not successful");
}

response = await rawSmtpClient.SendCommandAsync("data");
if (!response.StartsWith("354"))
{
Assert.Fail("data command not successful");
}

string smtpResponse = null;

_ = Task.Run (async() =>
{
smtpResponse = await rawSmtpClient.WaitForDataAsync();
});

var isSessionCancelled = false;

try
{
for (var i = 0; i < 1000; i++)
{
await rawSmtpClient.SendDataAsync("some text part ");
await Task.Delay(100);
}
}
catch (IOException)
{
isSessionCancelled = true;
stopwatch.Stop();
}
catch (Exception exception)
{
Assert.Fail($"Wrong exception type {exception.GetType()}");
}

Assert.True(isSessionCancelled, "Smtp session is not cancelled");
Assert.Equal("554 \r\n221 The session has be cancelled.\r\n", smtpResponse);

Assert.True(stopwatch.Elapsed > sessionTimeout, "SessionTimeout not reached");
}

[Fact]
public void CanReturnSmtpResponseException_DoesNotQuit()
{
Expand Down
40 changes: 13 additions & 27 deletions Src/SmtpServer/EndpointDefinitionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public IEndpointDefinition Build()
{
var definition = new EndpointDefinition
{
ReadTimeout = TimeSpan.FromMinutes(2),
SessionTimeout = TimeSpan.FromMinutes(2),
SupportedSslProtocols = SslProtocols.Tls12,
};

Expand Down Expand Up @@ -103,13 +103,13 @@ public EndpointDefinitionBuilder AllowUnsecureAuthentication(bool value = true)
}

/// <summary>
/// Sets the read timeout to apply to stream operations.
/// Sets the session timeout to apply to the session.
/// </summary>
/// <param name="value">The timeout value to apply to read operations.</param>
/// <param name="value">The timeout value to apply to the Smtp session.</param>
/// <returns>A EndpointDefinitionBuilder to continue building on.</returns>
public EndpointDefinitionBuilder ReadTimeout(TimeSpan value)
public EndpointDefinitionBuilder SessionTimeout(TimeSpan value)
{
_setters.Add(options => options.ReadTimeout = value);
_setters.Add(options => options.SessionTimeout = value);

return this;
}
Expand Down Expand Up @@ -152,39 +152,25 @@ public EndpointDefinitionBuilder SupportedSslProtocols(SslProtocols value)

internal sealed class EndpointDefinition : IEndpointDefinition
{
/// <summary>
/// The IP endpoint to listen on.
/// </summary>
/// <inheritdoc />
public IPEndPoint Endpoint { get; set; }

/// <summary>
/// Indicates whether the endpoint is secure by default.
/// </summary>
/// <inheritdoc />
public bool IsSecure { get; set; }

/// <summary>
/// Gets a value indicating whether the client must authenticate in order to proceed.
/// </summary>
/// <inheritdoc />
public bool AuthenticationRequired { get; set; }

/// <summary>
/// Gets a value indicating whether authentication should be allowed on an unsecure session.
/// </summary>
/// <inheritdoc />
public bool AllowUnsecureAuthentication { get; set; }

/// <summary>
/// The timeout on each individual buffer read.
/// </summary>
public TimeSpan ReadTimeout { get; set; }
/// <inheritdoc />
public TimeSpan SessionTimeout { get; set; }

/// <summary>
/// Gets the Server Certificate factory to use when starting a TLS session.
/// </summary>
/// <inheritdoc />
public ICertificateFactory CertificateFactory { get; set; }

/// <summary>
/// The supported SSL protocols.
/// </summary>
/// <inheritdoc />
public SslProtocols SupportedSslProtocols { get; set; }
}

Expand Down
4 changes: 2 additions & 2 deletions Src/SmtpServer/IEndpointDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ public interface IEndpointDefinition
bool AllowUnsecureAuthentication { get; }

/// <summary>
/// The timeout on each individual buffer read.
/// The timeout of an Smtp session.
/// </summary>
TimeSpan ReadTimeout { get; }
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A session timeout should be added on top of, not in replace of the read timeout.

The ReadTimeout should be more efficent as it can be handled at a lower level so should still apply to individual read operations.

If we want to also support a session timeout, the then it should be in addition.

There are also mail clients which could create long running connections/sessions, so a session timeout should probably only be enabled by choice, not by default.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I think a SessionTimeout is something that should be handled at a higher level that the SmtpSession class. It probably belongs in the SmtpSessionManager... the Run method here takes in the global CancellationToken, but then it should probably create a linked token source and pass it down to the SmtpSession.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is, that ReadTimeout of the underlying NetworkStream does not affect ReadAsync which is being used by SmtpServer. It only affects synchronous reads.
#226

From the MS docs:

This property affects only synchronous reads performed by calling the Read method. This property does not affect asynchronous reads performed by calling the BeginRead or ReadAsync method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can move the linked CancellationToken logic to the SmtpSessionManager and add the logic in that location, is this ok with you?

internal void Run(SmtpSessionContext sessionContext, CancellationToken cancellationToken)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, just thinking about this a bit... so if we want to add the ability to cancel a session based on timeout, then potentially there might be other ways thats user want to cancel a session.. ie, maybe disk storage, or time of day, etc.

So in that sense, maybe we should allow the session timeout to be handled externally.

There is an SessionCreated event which allows the SessionContext to be modified outside of the core code, we could so something similar with a new event that allows the CancellationToken to be provided. The problem is that this even is probably actually called SessionCreated and the SessionCreated event we have now is really SessionStarted.

But say we had something like this

`
internal void Run(SmtpSessionContext sessionContext, CancellationToken cancellationToken)
{
var handle = new SmtpSessionHandle(new SmtpSession(sessionContext), sessionContext);
Add(handle);

var sessionCancellationToken = _smtpServer.OnSessionBegining(new SessionBeginingEventArgs(handle.SessionContext));

// create the linked token source here
...

handle.CompletionTask = RunAsync(handle, cancellationToken);
 ...

}
`

so the user could do

server.SessionBegining += (args) {
   args.CancellationToken = ...
}

thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I move the logic with the CancellationToken to the SmtpSessionManager. Do you then accept the PR? Or what is your wish as we continue here?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ReadTimeout currently has no effect at all, as it is ignored by ReadAsync. @tinohager do you wish to control/cancel the session, or is it "enough" to get the read timeout to work. Which should then close the session, as of my observation.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and also query how many sessions are active and open

We added public int SessionsCount => _sessions.SessionsCount; to SmtpServer to get the current sessions count, which was a quick solution for us.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@martinguenther I would like to have control over the open sessions and be able to close them manually, but this should not be part of this pull request.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think any manual control of the sessions should be handled by the CancellationToken to that specific session, so this is the functionality that we should expose.

Read-only information about the sessions can probably be exposed.

TimeSpan SessionTimeout { get; }

/// <summary>
/// Gets the Server Certificate factory to use when starting a TLS session.
Expand Down
11 changes: 8 additions & 3 deletions Src/SmtpServer/Net/EndpointListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,17 @@ public async Task<ISecurableDuplexPipe> GetPipeAsync(ISessionContext context, Ca
context.Properties.Add(RemoteEndPointKey, tcpClient.Client.RemoteEndPoint);

var stream = tcpClient.GetStream();
stream.ReadTimeout = (int)_endpointDefinition.ReadTimeout.TotalMilliseconds;

return new SecurableDuplexPipe(stream, () =>
{
tcpClient.Close();
tcpClient.Dispose();
try
{
tcpClient.Close();
tcpClient.Dispose();
}
catch (Exception)
{
}
});
}

Expand Down
9 changes: 6 additions & 3 deletions Src/SmtpServer/SmtpSessionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,18 @@ internal void Run(SmtpSessionContext sessionContext, CancellationToken cancellat

async Task RunAsync(SmtpSessionHandle handle, CancellationToken cancellationToken)
{
using var sessionReadTimeoutCancellationTokenSource = new CancellationTokenSource(handle.SessionContext.EndpointDefinition.SessionTimeout);
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, sessionReadTimeoutCancellationTokenSource.Token);

try
{
await UpgradeAsync(handle, cancellationToken);
await UpgradeAsync(handle, linkedTokenSource.Token);

cancellationToken.ThrowIfCancellationRequested();
linkedTokenSource.Token.ThrowIfCancellationRequested();

_smtpServer.OnSessionCreated(new SessionEventArgs(handle.SessionContext));

await handle.Session.RunAsync(cancellationToken);
await handle.Session.RunAsync(linkedTokenSource.Token);

_smtpServer.OnSessionCompleted(new SessionEventArgs(handle.SessionContext));
}
Expand Down
Loading