Skip to content

Commit d218936

Browse files
Copilotrnwood
andauthored
feat: add fluent builder pattern for SmtpServer API (#1892)
* Initial plan * feat(smtp): add fluent builder pattern for ServerOptions Co-authored-by: rnwood <[email protected]> * test(smtp): add backward compatibility tests for ServerOptions Co-authored-by: rnwood <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: rnwood <[email protected]>
1 parent 45b2303 commit d218936

File tree

8 files changed

+655
-39
lines changed

8 files changed

+655
-39
lines changed

Rnwood.Smtp4dev/Server/Smtp4devServer.cs

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,35 @@ private void CreateSmtpServer()
101101
}
102102
}
103103

104-
this.smtpServer = new Rnwood.SmtpServer.SmtpServer(new SmtpServer.ServerOptions(serverOptionsValue.AllowRemoteConnections, !serverOptionsValue.DisableIPv6, serverOptionsValue.HostName, serverOptionsValue.Port, serverOptionsValue.AuthenticationRequired,
105-
serverOptionsValue.SmtpEnabledAuthTypesWhenNotSecureConnection.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), serverOptionsValue.SmtpEnabledAuthTypesWhenSecureConnection.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
106-
serverOptionsValue.TlsMode == TlsMode.ImplicitTls ? cert : null,
107-
serverOptionsValue.TlsMode == TlsMode.StartTls ? cert : null,
108-
!string.IsNullOrWhiteSpace(serverOptionsValue.SslProtocols) ? serverOptionsValue.SslProtocols.Split(",", StringSplitOptions.RemoveEmptyEntries|StringSplitOptions.TrimEntries).Select(s => Enum.Parse<SslProtocols>(s, true)).Aggregate((current, protocol) => current | protocol) : SslProtocols.None,
109-
!string.IsNullOrWhiteSpace(serverOptionsValue.TlsCipherSuites) ? serverOptionsValue.TlsCipherSuites.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(s => Enum.Parse<TlsCipherSuite>(s, true)).ToArray() : null,
110-
serverOptionsValue.MaxMessageSize,
111-
bindAddress
112-
));
104+
var builder = SmtpServer.ServerOptions.Builder()
105+
.WithAllowRemoteConnections(serverOptionsValue.AllowRemoteConnections)
106+
.WithEnableIpV6(!serverOptionsValue.DisableIPv6)
107+
.WithDomainName(serverOptionsValue.HostName)
108+
.WithPort(serverOptionsValue.Port)
109+
.WithRequireAuthentication(serverOptionsValue.AuthenticationRequired)
110+
.WithNonSecureAuthMechanisms(serverOptionsValue.SmtpEnabledAuthTypesWhenNotSecureConnection.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
111+
.WithSecureAuthMechanisms(serverOptionsValue.SmtpEnabledAuthTypesWhenSecureConnection.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
112+
.WithImplicitTlsCertificate(serverOptionsValue.TlsMode == TlsMode.ImplicitTls ? cert : null)
113+
.WithStartTlsCertificate(serverOptionsValue.TlsMode == TlsMode.StartTls ? cert : null)
114+
.WithSslProtocols(!string.IsNullOrWhiteSpace(serverOptionsValue.SslProtocols)
115+
? serverOptionsValue.SslProtocols.Split(",", StringSplitOptions.RemoveEmptyEntries|StringSplitOptions.TrimEntries).Select(s => Enum.Parse<SslProtocols>(s, true)).Aggregate((current, protocol) => current | protocol)
116+
: SslProtocols.None)
117+
.WithMaxMessageSize(serverOptionsValue.MaxMessageSize);
118+
119+
if (bindAddress != null)
120+
{
121+
builder.WithBindAddress(bindAddress);
122+
}
123+
124+
if (!string.IsNullOrWhiteSpace(serverOptionsValue.TlsCipherSuites))
125+
{
126+
var cipherSuites = serverOptionsValue.TlsCipherSuites.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
127+
.Select(s => Enum.Parse<TlsCipherSuite>(s, true))
128+
.ToArray();
129+
builder.WithTlsCipherSuites(cipherSuites);
130+
}
131+
132+
this.smtpServer = new Rnwood.SmtpServer.SmtpServer(builder.Build());
113133
this.smtpServer.MessageCompletedEventHandler += OnMessageCompleted;
114134
this.smtpServer.MessageReceivedEventHandler += OnMessageReceived;
115135
this.smtpServer.SessionCompletedEventHandler += OnSessionCompleted;

smtpserver/README.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,103 @@ A .NET SMTP server component, as used by Smtp4dev.
55
[![Build status](https://ci.appveyor.com/api/projects/status/tay9sajnfh4vy2x0/branch/master?svg=true)](https://ci.appveyor.com/project/rnwood/smtpserver/branch/master)
66
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Frnwood%2Fsmtpserver.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Frnwood%2Fsmtpserver?ref=badge_shield)
77

8+
## Usage
9+
10+
### Creating a Server with the Fluent Builder API
11+
12+
The recommended way to create server options is using the fluent builder API:
13+
14+
```csharp
15+
using Rnwood.SmtpServer;
16+
17+
// Create server options using the builder
18+
var options = ServerOptions.Builder()
19+
.WithDomainName("smtp.example.com")
20+
.WithPort(25)
21+
.WithAllowRemoteConnections(true)
22+
.WithEnableIpV6(false)
23+
.Build();
24+
25+
// Create and start the server
26+
using var server = new SmtpServer(options);
27+
server.Start();
28+
```
29+
30+
### Builder Methods
31+
32+
The `ServerOptionsBuilder` provides the following methods:
33+
34+
- `WithDomainName(string)` - Set the domain name in server greeting
35+
- `WithPort(int)` - Set the TCP port number
36+
- `WithAllowRemoteConnections(bool)` - Allow/disallow remote connections
37+
- `WithEnableIpV6(bool)` - Enable/disable IPv6 dual stack
38+
- `WithRequireAuthentication(bool)` - Require authentication
39+
- `WithNonSecureAuthMechanisms(params string[])` - Set auth mechanisms for non-secure connections
40+
- `WithSecureAuthMechanisms(params string[])` - Set auth mechanisms for secure connections
41+
- `WithImplicitTlsCertificate(X509Certificate)` - Set certificate for implicit TLS
42+
- `WithStartTlsCertificate(X509Certificate)` - Set certificate for STARTTLS
43+
- `WithSslProtocols(SslProtocols)` - Set allowed SSL/TLS protocol versions
44+
- `WithTlsCipherSuites(params TlsCipherSuite[])` - Set allowed TLS cipher suites
45+
- `WithMaxMessageSize(long?)` - Set maximum message size in bytes
46+
- `WithBindAddress(IPAddress)` - Bind to specific IP address
47+
- `Build()` - Build the ServerOptions instance
48+
49+
### Examples
50+
51+
#### Basic Local Development Server
52+
53+
```csharp
54+
var options = ServerOptions.Builder()
55+
.WithDomainName("localhost")
56+
.WithPort(2525)
57+
.WithAllowRemoteConnections(false)
58+
.Build();
59+
60+
using var server = new SmtpServer(options);
61+
server.Start();
62+
```
63+
64+
#### Secure Server with TLS
65+
66+
```csharp
67+
var certificate = new X509Certificate2("server.pfx", "password");
68+
69+
var options = ServerOptions.Builder()
70+
.WithDomainName("secure.example.com")
71+
.WithPort(465)
72+
.WithAllowRemoteConnections(true)
73+
.WithImplicitTlsCertificate(certificate)
74+
.WithSslProtocols(SslProtocols.Tls12 | SslProtocols.Tls13)
75+
.Build();
76+
77+
using var server = new SmtpServer(options);
78+
server.Start();
79+
```
80+
81+
#### Server with Authentication
82+
83+
```csharp
84+
var options = ServerOptions.Builder()
85+
.WithDomainName("smtp.example.com")
86+
.WithPort(587)
87+
.WithRequireAuthentication(true)
88+
.WithSecureAuthMechanisms("PLAIN", "LOGIN")
89+
.WithStartTlsCertificate(certificate)
90+
.Build();
91+
92+
using var server = new SmtpServer(options);
93+
94+
// Set up authentication handler
95+
((ServerOptions)server.Options).AuthenticationCredentialsValidationRequiredEventHandler +=
96+
async (sender, e) =>
97+
{
98+
e.AuthenticationResult = ValidateCredentials(e.Credentials)
99+
? AuthenticationResult.Success
100+
: AuthenticationResult.Failure;
101+
};
102+
103+
server.Start();
104+
```
8105

9106
## License
10107
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Frnwood%2Fsmtpserver.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Frnwood%2Fsmtpserver?ref=badge_large)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// <copyright file="BackwardCompatibilityTests.cs" company="Rnwood.SmtpServer project contributors">
2+
// Copyright (c) Rnwood.SmtpServer project contributors. All rights reserved.
3+
// Licensed under the BSD license. See LICENSE.md file in the project root for full license information.
4+
// </copyright>
5+
6+
using System.Security.Authentication;
7+
using Xunit;
8+
9+
namespace Rnwood.SmtpServer.Tests;
10+
11+
/// <summary>
12+
/// Tests to ensure backward compatibility with existing code
13+
/// </summary>
14+
public class BackwardCompatibilityTests
15+
{
16+
[Fact]
17+
public void LegacyConstructor_StillWorks()
18+
{
19+
// This test ensures the old constructor is still public and accessible
20+
var options = new ServerOptions(
21+
allowRemoteConnections: false,
22+
enableIpV6: true,
23+
domainName: "test",
24+
portNumber: (int)StandardSmtpPort.AssignAutomatically,
25+
requireAuthentication: false,
26+
nonSecureAuthMechanismIds: new string[0],
27+
secureAuthMechanismNamesIds: new string[0],
28+
implcitTlsCertificate: null,
29+
startTlsCertificate: null,
30+
sslProtocols: SslProtocols.None,
31+
tlsCipherSuites: null,
32+
maxMessageSize: null
33+
);
34+
35+
Assert.NotNull(options);
36+
Assert.Equal("test", options.DomainName);
37+
}
38+
39+
[Fact]
40+
public void LegacyConstructor_CanCreateWorkingServer()
41+
{
42+
var options = new ServerOptions(
43+
false, false, "test", (int)StandardSmtpPort.AssignAutomatically, false,
44+
new string[0], new string[0], null, null, SslProtocols.None, null, null
45+
);
46+
47+
using var server = new SmtpServer(options);
48+
server.Start();
49+
50+
Assert.True(server.IsRunning);
51+
Assert.NotEmpty(server.ListeningEndpoints);
52+
53+
server.Stop();
54+
Assert.False(server.IsRunning);
55+
}
56+
}

smtpserver/Rnwood.SmtpServer.Tests/IPv6FallbackTests.cs

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,13 @@ public void SmtpServer_StartWithIPv6Any_ShouldFallbackToIPv4WhenIPv6Unavailable(
2222
// In environments where IPv6 is truly unavailable, the server should fall back to IPv4
2323

2424
// Create server options that would normally use IPv6
25-
var options = new ServerOptions(
26-
allowRemoteConnections: true,
27-
enableIpV6: true, // This would normally create IPv6Any listener
28-
domainName: "test",
29-
portNumber: (int)StandardSmtpPort.AssignAutomatically, // Use automatic port assignment
30-
requireAuthentication: false,
31-
nonSecureAuthMechanismIds: new string[0],
32-
secureAuthMechanismNamesIds: new string[0],
33-
implcitTlsCertificate: null,
34-
startTlsCertificate: null,
35-
sslProtocols: SslProtocols.None,
36-
tlsCipherSuites: null,
37-
maxMessageSize: null
38-
);
25+
var options = ServerOptions.Builder()
26+
.WithAllowRemoteConnections(true)
27+
.WithEnableIpV6(true) // This would normally create IPv6Any listener
28+
.WithDomainName("test")
29+
.WithPort((int)StandardSmtpPort.AssignAutomatically) // Use automatic port assignment
30+
.WithRequireAuthentication(false)
31+
.Build();
3932

4033
using (var server = new SmtpServer(options))
4134
{
@@ -78,20 +71,13 @@ public void SmtpServer_StartWithIPv6Any_ShouldFallbackToIPv4WhenIPv6Unavailable(
7871
public void SmtpServer_StartWithIPv6Loopback_ShouldFallbackToIPv4WhenIPv6Unavailable()
7972
{
8073
// Create server options that would normally use IPv6 loopback
81-
var options = new ServerOptions(
82-
allowRemoteConnections: false,
83-
enableIpV6: true, // This would normally create IPv6Loopback listener
84-
domainName: "test",
85-
portNumber: (int)StandardSmtpPort.AssignAutomatically, // Use automatic port assignment
86-
requireAuthentication: false,
87-
nonSecureAuthMechanismIds: new string[0],
88-
secureAuthMechanismNamesIds: new string[0],
89-
implcitTlsCertificate: null,
90-
startTlsCertificate: null,
91-
sslProtocols: SslProtocols.None,
92-
tlsCipherSuites: null,
93-
maxMessageSize: null
94-
);
74+
var options = ServerOptions.Builder()
75+
.WithAllowRemoteConnections(false)
76+
.WithEnableIpV6(true) // This would normally create IPv6Loopback listener
77+
.WithDomainName("test")
78+
.WithPort((int)StandardSmtpPort.AssignAutomatically) // Use automatic port assignment
79+
.WithRequireAuthentication(false)
80+
.Build();
9581

9682
using (var server = new SmtpServer(options))
9783
{

0 commit comments

Comments
 (0)