diff --git a/Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj b/Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj
index e3cf5f9b6..7d2321f23 100644
--- a/Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj
+++ b/Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj
@@ -50,7 +50,6 @@
-
@@ -178,6 +177,10 @@
+
+
+
+
diff --git a/smtp4dev.sln b/smtp4dev.sln
index 57edbd25c..985b0f715 100644
--- a/smtp4dev.sln
+++ b/smtp4dev.sln
@@ -16,6 +16,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rnwood.Smtp4dev.Desktop", "
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rnwood.Smtp4dev.Desktop.Tests", "Rnwood.Smtp4dev.Desktop.Tests\Rnwood.Smtp4dev.Desktop.Tests.csproj", "{44686E8D-112A-4506-BD48-432E99D2D0DC}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "smtpserver", "smtpserver", "{C875E342-CCB7-4983-8017-7E06514635B2}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rnwood.SmtpServer", "smtpserver\Rnwood.SmtpServer\Rnwood.SmtpServer.csproj", "{0855B45D-AA9E-4683-AE6C-7057282D4E6B}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rnwood.SmtpServer.Tests", "smtpserver\Rnwood.SmtpServer.Tests\Rnwood.SmtpServer.Tests.csproj", "{BBABDD2E-7A9B-4406-BE15-B4EEE2FF037E}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -38,10 +44,22 @@ Global
{44686E8D-112A-4506-BD48-432E99D2D0DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{44686E8D-112A-4506-BD48-432E99D2D0DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{44686E8D-112A-4506-BD48-432E99D2D0DC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0855B45D-AA9E-4683-AE6C-7057282D4E6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0855B45D-AA9E-4683-AE6C-7057282D4E6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0855B45D-AA9E-4683-AE6C-7057282D4E6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0855B45D-AA9E-4683-AE6C-7057282D4E6B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BBABDD2E-7A9B-4406-BE15-B4EEE2FF037E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BBABDD2E-7A9B-4406-BE15-B4EEE2FF037E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BBABDD2E-7A9B-4406-BE15-B4EEE2FF037E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BBABDD2E-7A9B-4406-BE15-B4EEE2FF037E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {0855B45D-AA9E-4683-AE6C-7057282D4E6B} = {C875E342-CCB7-4983-8017-7E06514635B2}
+ {BBABDD2E-7A9B-4406-BE15-B4EEE2FF037E} = {C875E342-CCB7-4983-8017-7E06514635B2}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {533B7CBF-9EBE-4336-A215-A95484DD03FF}
EndGlobalSection
diff --git a/smtpserver/.editorconfig b/smtpserver/.editorconfig
new file mode 100644
index 000000000..b2b020ef2
--- /dev/null
+++ b/smtpserver/.editorconfig
@@ -0,0 +1,193 @@
+# editorconfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Default settings:
+# A newline ending every file
+# Use 4 spaces as indentation
+[*]
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[project.json]
+indent_size = 2
+
+# Generated code
+[*{_AssemblyInfo.cs,.notsupported.cs}]
+generated_code = true
+
+# C# files
+[*.cs]
+# New line preferences
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation preferences
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = one_less_than_current
+
+# Modifier preferences
+csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:suggestion
+
+# avoid this. unless absolutely necessary
+dotnet_style_qualification_for_field = false:suggestion
+dotnet_style_qualification_for_property = false:suggestion
+dotnet_style_qualification_for_method = false:suggestion
+dotnet_style_qualification_for_event = false:suggestion
+
+# Types: use keywords instead of BCL types, and permit var only when the type is clear
+csharp_style_var_for_built_in_types = false:suggestion
+csharp_style_var_when_type_is_apparent = false:none
+csharp_style_var_elsewhere = false:suggestion
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+
+# name all constant fields using PascalCase
+dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields
+dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style
+dotnet_naming_symbols.constant_fields.applicable_kinds = field
+dotnet_naming_symbols.constant_fields.required_modifiers = const
+dotnet_naming_style.pascal_case_style.capitalization = pascal_case
+
+# static fields should have s_ prefix
+dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion
+dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields
+dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style
+dotnet_naming_symbols.static_fields.applicable_kinds = field
+dotnet_naming_symbols.static_fields.required_modifiers = static
+dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected
+dotnet_naming_style.static_prefix_style.required_prefix = s_
+dotnet_naming_style.static_prefix_style.capitalization = camel_case
+
+# internal and private fields should be _camelCase
+dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion
+dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields
+dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style
+dotnet_naming_symbols.private_internal_fields.applicable_kinds = field
+dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal
+dotnet_naming_style.camel_case_underscore_style.required_prefix = _
+dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case
+
+# Code style defaults
+csharp_using_directive_placement = outside_namespace:suggestion
+dotnet_sort_system_directives_first = true
+csharp_prefer_braces = true:silent
+csharp_preserve_single_line_blocks = true:none
+csharp_preserve_single_line_statements = false:none
+csharp_prefer_static_local_function = true:suggestion
+csharp_prefer_simple_using_statement = false:none
+csharp_style_prefer_switch_expression = true:suggestion
+dotnet_style_readonly_field = true:suggestion
+
+# Expression-level preferences
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:silent
+dotnet_style_prefer_conditional_expression_over_return = true:silent
+csharp_prefer_simple_default_expression = true:suggestion
+
+# Expression-bodied members
+csharp_style_expression_bodied_methods = true:silent
+csharp_style_expression_bodied_constructors = true:silent
+csharp_style_expression_bodied_operators = true:silent
+csharp_style_expression_bodied_properties = true:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = true:silent
+
+# Pattern matching
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+
+# Null checking preferences
+csharp_style_throw_expression = true:suggestion
+csharp_style_conditional_delegate_call = true:suggestion
+
+# Other features
+csharp_style_prefer_index_operator = false:none
+csharp_style_prefer_range_operator = false:none
+csharp_style_pattern_local_over_anonymous_function = false:none
+
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_after_comma = true
+csharp_space_after_dot = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_around_declaration_statements = do_not_ignore
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_before_comma = false
+csharp_space_before_dot = false
+csharp_space_before_open_square_brackets = false
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+csharp_space_between_square_brackets = false
+
+# License header
+file_header_template = Licensed to the.NET Foundation under one or more agreements.\nThe.NET Foundation licenses this file to you under the MIT license.
+
+# C++ Files
+[*.{cpp,h,in}]
+curly_bracket_next_line = true
+indent_brace_style = Allman
+
+# Xml project files
+[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}]
+indent_size = 2
+
+[*.{csproj,vbproj,proj,nativeproj,locproj}]
+charset = utf-8
+
+# Xml build files
+[*.builds]
+indent_size = 2
+
+# Xml files
+[*.{xml,stylecop,resx,ruleset}]
+indent_size = 2
+
+# Xml config files
+[*.{props,targets,config,nuspec}]
+indent_size = 2
+
+# YAML config files
+[*.{yml,yaml}]
+indent_size = 2
+
+# Shell scripts
+[*.sh]
+end_of_line = lf
+
+[*.{cmd,bat}]
+end_of_line = crlf
diff --git a/smtpserver/.gitignore b/smtpserver/.gitignore
new file mode 100644
index 000000000..d330dffc8
--- /dev/null
+++ b/smtpserver/.gitignore
@@ -0,0 +1,9 @@
+.vs
+obj
+bin
+*.ncrunch*
+packages
+_ncrunch_*
+*.user
+/Rnwood.Smtp4dev/node_modules
+.idea
diff --git a/smtpserver/.vscode/launch.json b/smtpserver/.vscode/launch.json
new file mode 100644
index 000000000..fd62f91af
--- /dev/null
+++ b/smtpserver/.vscode/launch.json
@@ -0,0 +1,25 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": ".NET Core Launch (console)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "${workspaceFolder}/Rnwood.SmtpServer.Tests/bin/Debug/netcoreapp2.0/Rnwood.SmtpServer.Tests.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/Rnwood.SmtpServer.Tests",
+ "console": "internalConsole",
+ "stopAtEntry": false
+ },
+ {
+ "name": ".NET Core Attach",
+ "type": "coreclr",
+ "request": "attach",
+ "processId": "${command:pickProcess}"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/smtpserver/.vscode/settings.json b/smtpserver/.vscode/settings.json
new file mode 100644
index 000000000..64eac7480
--- /dev/null
+++ b/smtpserver/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "dotnet-test-explorer.testProjectPath": "**/*Tests.@(csproj|vbproj|fsproj)"
+}
\ No newline at end of file
diff --git a/smtpserver/.vscode/tasks.json b/smtpserver/.vscode/tasks.json
new file mode 100644
index 000000000..b14128c47
--- /dev/null
+++ b/smtpserver/.vscode/tasks.json
@@ -0,0 +1,44 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "publish",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "publish",
+ "${workspaceFolder}/Rnwood.SmtpServer.Tests/Rnwood.SmtpServer.Tests.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "watch",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "watch",
+ "run",
+ "${workspaceFolder}/Rnwood.SmtpServer.Tests/Rnwood.SmtpServer.Tests.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "build",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "-f",
+ "netcoreapp2.0",
+ "${workspaceFolder}/Rnwood.SmtpServer.Tests/Rnwood.SmtpServer.Tests.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/smtpserver/CodeMaid.config b/smtpserver/CodeMaid.config
new file mode 100644
index 000000000..6d369d576
--- /dev/null
+++ b/smtpserver/CodeMaid.config
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+ True
+
+
+ True
+
+
+
+
\ No newline at end of file
diff --git a/smtpserver/LICENSE.md b/smtpserver/LICENSE.md
new file mode 100644
index 000000000..a896104be
--- /dev/null
+++ b/smtpserver/LICENSE.md
@@ -0,0 +1,27 @@
+Copyright (c) 2009-2018, Rnwood.SmtpServer project contributors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of smtp4dev nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/smtpserver/README.md b/smtpserver/README.md
new file mode 100644
index 000000000..459192566
--- /dev/null
+++ b/smtpserver/README.md
@@ -0,0 +1,10 @@
+# Rnwood.SmtpServer
+
+A .NET SMTP server component, as used by Smtp4dev.
+
+[![Build status](https://ci.appveyor.com/api/projects/status/tay9sajnfh4vy2x0/branch/master?svg=true)](https://ci.appveyor.com/project/rnwood/smtpserver/branch/master)
+[![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)
+
+
+## License
+[![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)
\ No newline at end of file
diff --git a/smtpserver/Rnwood.SmtpServer.Tests/ASCIISevenBitTruncatingEncodingTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/ASCIISevenBitTruncatingEncodingTests.cs
new file mode 100644
index 000000000..c495af04e
--- /dev/null
+++ b/smtpserver/Rnwood.SmtpServer.Tests/ASCIISevenBitTruncatingEncodingTests.cs
@@ -0,0 +1,62 @@
+//
+// Copyright (c) Rnwood.SmtpServer project contributors. All rights reserved.
+// Licensed under the BSD license. See LICENSE.md file in the project root for full license information.
+//
+
+using Xunit;
+
+namespace Rnwood.SmtpServer.Tests;
+
+///
+/// Defines the
+///
+public class ASCIISevenBitTruncatingEncodingTests
+{
+ ///
+ /// The GetBytes_ASCIIChar_ReturnsOriginal
+ ///
+ [Fact]
+ public void GetBytes_ASCIIChar_ReturnsOriginal()
+ {
+ AsciiSevenBitTruncatingEncoding encoding = new AsciiSevenBitTruncatingEncoding();
+ byte[] bytes = encoding.GetBytes(new[] { 'a', 'b', 'c' }, 0, 3);
+
+ Assert.Equal(new[] { (byte)'a', (byte)'b', (byte)'c' }, bytes);
+ }
+
+ ///
+ /// The GetBytes_ExtendedChar_ReturnsTruncated
+ ///
+ [Fact]
+ public void GetBytes_ExtendedChar_ReturnsTruncated()
+ {
+ AsciiSevenBitTruncatingEncoding encoding = new AsciiSevenBitTruncatingEncoding();
+ byte[] bytes = encoding.GetBytes(new[] { (char)250 }, 0, 1);
+
+ Assert.Equal(new[] { (byte)'z' }, bytes);
+ }
+
+ ///
+ /// The GetChars_ASCIIChar_ReturnsOriginal
+ ///
+ [Fact]
+ public void GetChars_ASCIIChar_ReturnsOriginal()
+ {
+ AsciiSevenBitTruncatingEncoding encoding = new AsciiSevenBitTruncatingEncoding();
+ char[] chars = encoding.GetChars(new[] { (byte)'a', (byte)'b', (byte)'c' }, 0, 3);
+
+ Assert.Equal(new[] { 'a', 'b', 'c' }, chars);
+ }
+
+ ///
+ /// The GetChars_ExtendedChar_ReturnsTruncated
+ ///
+ [Fact]
+ public void GetChars_ExtendedChar_ReturnsTruncated()
+ {
+ AsciiSevenBitTruncatingEncoding encoding = new AsciiSevenBitTruncatingEncoding();
+ char[] chars = encoding.GetChars(new[] { (byte)250 }, 0, 1);
+
+ Assert.Equal(new[] { 'z' }, chars);
+ }
+}
diff --git a/smtpserver/Rnwood.SmtpServer.Tests/AbstractSessionTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/AbstractSessionTests.cs
new file mode 100644
index 000000000..0ac291a00
--- /dev/null
+++ b/smtpserver/Rnwood.SmtpServer.Tests/AbstractSessionTests.cs
@@ -0,0 +1,63 @@
+//
+// Copyright (c) Rnwood.SmtpServer project contributors. All rights reserved.
+// Licensed under the BSD license. See LICENSE.md file in the project root for full license information.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Moq;
+using Xunit;
+
+namespace Rnwood.SmtpServer.Tests;
+
+///
+/// Defines the
+///
+public abstract class AbstractSessionTests
+{
+ ///
+ ///
+ [Fact]
+ public async Task AddMessage()
+ {
+ IEditableSession session = GetSession();
+ Mock message = new Mock();
+
+ await session.AddMessage(message.Object);
+
+ IReadOnlyCollection messages = await session.GetMessages();
+ Assert.Single(messages);
+ Assert.Same(message.Object, messages.First());
+ }
+
+ ///
+ ///
+ [Fact]
+ public async Task AppendToLog()
+ {
+ IEditableSession session = GetSession();
+ await session.AppendLineToSessionLog("Blah1");
+ await session.AppendLineToSessionLog("Blah2");
+
+ string sessionLog = (await session.GetLog()).ReadToEnd();
+ Assert.Equal(new[] { "Blah1", "Blah2", "" },
+ sessionLog.Split(new[] { "\r\n" }, StringSplitOptions.None));
+ }
+
+ ///
+ /// The GetMessages_InitiallyEmpty
+ ///
+ [Fact]
+ public async Task GetMessages_InitiallyEmpty()
+ {
+ IEditableSession session = GetSession();
+ Assert.Empty(await session.GetMessages());
+ }
+
+ ///
+ ///
+ /// The
+ protected abstract IEditableSession GetSession();
+}
diff --git a/smtpserver/Rnwood.SmtpServer.Tests/ArgumentsParserTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/ArgumentsParserTests.cs
new file mode 100644
index 000000000..7ae949bb8
--- /dev/null
+++ b/smtpserver/Rnwood.SmtpServer.Tests/ArgumentsParserTests.cs
@@ -0,0 +1,50 @@
+//
+// Copyright (c) Rnwood.SmtpServer project contributors. All rights reserved.
+// Licensed under the BSD license. See LICENSE.md file in the project root for full license information.
+//
+
+using System.Linq;
+using Xunit;
+
+namespace Rnwood.SmtpServer.Tests;
+
+///
+/// Defines the
+///
+public class ArgumentsParserTests
+{
+ ///
+ /// The Parsing_FirstArgumentAferVerbWithColon_Split
+ ///
+ [Fact]
+ public void Parsing_FirstArgumentAferVerbWithColon_Split()
+ {
+ ArgumentsParser args = new ArgumentsParser("ARG1=VALUE:BLAH");
+ Assert.Single(args.Arguments);
+ Assert.Equal("ARG1=VALUE:BLAH", args.Arguments.First());
+ }
+
+ ///
+ /// The Parsing_MailFrom_EmailOnly
+ ///
+ [Fact]
+ public void Parsing_MailFrom_EmailOnly()
+ {
+ ArgumentsParser args = new ArgumentsParser(" ARG1 ARG2");
+ Assert.Equal("", args.Arguments.First());
+ Assert.Equal("ARG1", args.Arguments.ElementAt(1));
+ Assert.Equal("ARG2", args.Arguments.ElementAt(2));
+ }
+
+ ///
+ /// The Parsing_MailFrom_WithDisplayName
+ ///
+ [Fact]
+ public void Parsing_MailFrom_WithDisplayName()
+ {
+ ArgumentsParser args = new ArgumentsParser("> ARG1 ARG2");
+ Assert.Equal(">", args.Arguments.First());
+ Assert.Equal("ARG1", args.Arguments.ElementAt(1));
+ Assert.Equal("ARG2", args.Arguments.ElementAt(2));
+ }
+}
diff --git a/smtpserver/Rnwood.SmtpServer.Tests/ClientTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/ClientTests.cs
new file mode 100644
index 000000000..59bd55c6d
--- /dev/null
+++ b/smtpserver/Rnwood.SmtpServer.Tests/ClientTests.cs
@@ -0,0 +1,294 @@
+//
+// Copyright (c) Rnwood.SmtpServer project contributors. All rights reserved.
+// Licensed under the BSD license. See LICENSE.md file in the project root for full license information.
+//
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading.Tasks;
+using MailKit.Net.Smtp;
+using MailKit.Security;
+using MimeKit;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Operators;
+using Org.BouncyCastle.Crypto.Prng;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.Utilities;
+using Org.BouncyCastle.X509;
+using Xunit;
+using Xunit.Abstractions;
+using X509Certificate = Org.BouncyCastle.X509.X509Certificate;
+
+namespace Rnwood.SmtpServer.Tests;
+
+///
+/// Defines the
+///
+public partial class ClientTests
+{
+ ///
+ /// Defines the output
+ ///
+ private readonly ITestOutputHelper output;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The output
+ public ClientTests(ITestOutputHelper output) => this.output = output;
+
+ ///
+ /// The MailKit_NonSSL
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task MailKit_SmtpUtf8()
+ {
+ using (DefaultServer server = new DefaultServer(false, StandardSmtpPort.AssignAutomatically))
+ {
+ ConcurrentBag messages = new ConcurrentBag();
+
+ server.MessageReceivedEventHandler += (o, ea) =>
+ {
+ messages.Add(ea.Message);
+ return Task.CompletedTask;
+ };
+ server.Start();
+
+ await SendMessage_MailKit_Async(server, "ظػؿقط@to.com", "ظػؿقط@from.com").WithTimeout("sending message")
+ ;
+
+ Assert.Single(messages);
+ Assert.Equal("ظػؿقط@from.com", messages.First().From);
+
+ Assert.Equal("ظػؿقط@to.com", messages.First().Recipients.Single());
+ }
+ }
+
+ ///
+ /// The MailKit_NonSSL
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task MailKit_NonSSL()
+ {
+ using (DefaultServer server = new DefaultServer(false, StandardSmtpPort.AssignAutomatically))
+ {
+ ConcurrentBag messages = new ConcurrentBag();
+
+ server.MessageReceivedEventHandler += (o, ea) =>
+ {
+ messages.Add(ea.Message);
+ return Task.CompletedTask;
+ };
+ server.Start();
+
+ await SendMessage_MailKit_Async(server, "to@to.com").WithTimeout("sending message")
+ ;
+
+ Assert.Single(messages);
+ Assert.Equal("from@from.com", messages.First().From);
+ }
+ }
+
+ ///
+ /// Tests mailkit connecting using STARTTLS
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task MailKit_StartTLS()
+ {
+ using (DefaultServer server = new DefaultServer(false, Dns.GetHostName(),
+ (int)StandardSmtpPort.AssignAutomatically,
+ null, CreateSelfSignedCertificate()))
+ {
+ ConcurrentBag messages = new ConcurrentBag();
+
+ server.MessageReceivedEventHandler += (o, ea) =>
+ {
+ messages.Add(ea.Message);
+ return Task.CompletedTask;
+ };
+ server.Start();
+
+ await SendMessage_MailKit_Async(server, "to@to.com", secureSocketOptions: SecureSocketOptions.StartTls)
+ .WithTimeout("sending message");
+
+ Assert.Single(messages);
+ Assert.Equal("from@from.com", messages.First().From);
+ }
+ }
+
+ ///
+ /// Tests mailkit connecting using implicit TLS
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task MailKit_ImplicitTLS()
+ {
+ using (DefaultServer server = new DefaultServer(false, Dns.GetHostName(),
+ (int)StandardSmtpPort.AssignAutomatically,
+ CreateSelfSignedCertificate(), null))
+ {
+ ConcurrentBag messages = new ConcurrentBag();
+
+ server.MessageReceivedEventHandler += (o, ea) =>
+ {
+ messages.Add(ea.Message);
+ return Task.CompletedTask;
+ };
+ server.Start();
+
+ await SendMessage_MailKit_Async(server, "to@to.com",
+ secureSocketOptions: SecureSocketOptions.SslOnConnect).WithTimeout("sending message")
+ ;
+
+ Assert.Single(messages);
+ Assert.Equal("from@from.com", messages.First().From);
+ }
+ }
+
+ ///
+ /// The MailKit_NonSSL_StressTest
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task MailKit_NonSSL_StressTest()
+ {
+ using (DefaultServer server = new DefaultServer(false, StandardSmtpPort.AssignAutomatically))
+ {
+ ConcurrentBag messages = new ConcurrentBag();
+
+ server.MessageReceivedEventHandler += (o, ea) =>
+ {
+ messages.Add(ea.Message);
+ return Task.CompletedTask;
+ };
+ server.Start();
+
+ List sendingTasks = new List();
+
+ int numberOfThreads = 10;
+ int numberOfMessagesPerThread = 50;
+
+ for (int threadId = 0; threadId < numberOfThreads; threadId++)
+ {
+ int localThreadId = threadId;
+
+ sendingTasks.Add(Task.Run(async () =>
+ {
+ using (SmtpClient client = new SmtpClient())
+ {
+ await client.ConnectAsync("localhost", server.PortNumber);
+
+ for (int i = 0; i < numberOfMessagesPerThread; i++)
+ {
+ MimeMessage message = NewMessage(i + "@" + localThreadId, "from@from.com");
+
+ await client.SendAsync(message);
+ }
+
+ await client.DisconnectAsync(true);
+ }
+ }));
+ }
+
+ await Task.WhenAll(sendingTasks).WithTimeout(120, "sending messages");
+ Assert.Equal(numberOfMessagesPerThread * numberOfThreads, messages.Count);
+
+ for (int threadId = 0; threadId < numberOfThreads; threadId++)
+ {
+ for (int i = 0; i < numberOfMessagesPerThread; i++)
+ {
+ Assert.Contains(messages, m => m.Recipients.Any(t => t == i + "@" + threadId));
+ }
+ }
+ }
+ }
+
+ ///
+ ///
+ /// The toAddress
+ /// The
+ private static MimeMessage NewMessage(string toAddress, string fromAddress)
+ {
+ MimeMessage message = new MimeMessage();
+ message.From.Add(new MailboxAddress("", fromAddress));
+ message.To.Add(new MailboxAddress("", toAddress));
+ message.Subject = "subject";
+ message.Body = new TextPart("plain") { Text = "body" };
+ return message;
+ }
+
+ ///
+ ///
+ /// The server
+ /// The toAddress
+ /// A representing the async operation
+ private async Task SendMessage_MailKit_Async(DefaultServer server, string toAddress,
+ string fromAddress = "from@from.com", SecureSocketOptions secureSocketOptions = SecureSocketOptions.None)
+ {
+ MimeMessage message = NewMessage(toAddress, fromAddress);
+
+ using (SmtpClient client = new SmtpClient(new SmtpClientLogger(output)))
+ {
+ client.CheckCertificateRevocation = false;
+ client.ServerCertificateValidationCallback = (mysender, certificate, chain, sslPolicyErrors) =>
+ {
+ return true;
+ };
+ client.SslProtocols = SslProtocols.Tls12;
+ await client.ConnectAsync("localhost", server.PortNumber, secureSocketOptions);
+ await client.SendAsync(new FormatOptions { International = true }, message);
+ await client.DisconnectAsync(true);
+ }
+ }
+
+ private X509Certificate2 CreateSelfSignedCertificate()
+ {
+ CryptoApiRandomGenerator randomGenerator = new CryptoApiRandomGenerator();
+ SecureRandom random = new SecureRandom(randomGenerator);
+
+ X509V3CertificateGenerator certGenerator = new X509V3CertificateGenerator();
+ certGenerator.SetSubjectDN(new X509Name("CN=localhost"));
+ certGenerator.SetIssuerDN(new X509Name("CN=localhost"));
+
+ BigInteger serialNumber =
+ BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random);
+ certGenerator.SetSerialNumber(serialNumber);
+
+ certGenerator.SetNotBefore(DateTime.UtcNow.Date);
+ certGenerator.SetNotAfter(DateTime.UtcNow.Date.AddYears(10));
+
+ KeyGenerationParameters keyGenerationParameters = new KeyGenerationParameters(random, 2048);
+
+ RsaKeyPairGenerator keyPairGenerator = new RsaKeyPairGenerator();
+ keyPairGenerator.Init(keyGenerationParameters);
+ AsymmetricCipherKeyPair keyPair = keyPairGenerator.GenerateKeyPair();
+ certGenerator.SetPublicKey(keyPair.Public);
+
+ Asn1SignatureFactory signatureFactory = new Asn1SignatureFactory("SHA256WithRSA", keyPair.Private, random);
+ X509Certificate cert = certGenerator.Generate(signatureFactory);
+
+ Pkcs12Store store = new Pkcs12Store();
+ X509CertificateEntry certificateEntry = new X509CertificateEntry(cert);
+ store.SetCertificateEntry("cert", certificateEntry);
+ store.SetKeyEntry("cert", new AsymmetricKeyEntry(keyPair.Private), new[] { certificateEntry });
+ MemoryStream stream = new MemoryStream();
+ store.Save(stream, "".ToCharArray(), random);
+
+ return new X509Certificate2(
+ stream.ToArray(), "",
+ X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);
+ }
+}
diff --git a/smtpserver/Rnwood.SmtpServer.Tests/CommandEventArgsTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/CommandEventArgsTests.cs
new file mode 100644
index 000000000..f0e76b45c
--- /dev/null
+++ b/smtpserver/Rnwood.SmtpServer.Tests/CommandEventArgsTests.cs
@@ -0,0 +1,25 @@
+//
+// Copyright (c) Rnwood.SmtpServer project contributors. All rights reserved.
+// Licensed under the BSD license. See LICENSE.md file in the project root for full license information.
+//
+
+using Xunit;
+
+namespace Rnwood.SmtpServer.Tests;
+
+///
+/// Defines the
+///
+public class CommandEventArgsTests
+{
+ ///
+ ///
+ [Fact]
+ public void Command()
+ {
+ SmtpCommand command = new SmtpCommand("BLAH");
+ CommandEventArgs args = new CommandEventArgs(command);
+
+ Assert.Same(command, args.Command);
+ }
+}
diff --git a/smtpserver/Rnwood.SmtpServer.Tests/ConnectionTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/ConnectionTests.cs
new file mode 100644
index 000000000..006b166f6
--- /dev/null
+++ b/smtpserver/Rnwood.SmtpServer.Tests/ConnectionTests.cs
@@ -0,0 +1,184 @@
+//
+// Copyright (c) Rnwood.SmtpServer project contributors. All rights reserved.
+// Licensed under the BSD license. See LICENSE.md file in the project root for full license information.
+//
+
+using System;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Moq;
+using Rnwood.SmtpServer.Verbs;
+using Xunit;
+
+namespace Rnwood.SmtpServer.Tests;
+
+///
+/// Defines the
+///
+public class ConnectionTests
+{
+ ///
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task AbortMessage()
+ {
+ TestMocks mocks = new TestMocks();
+
+ Connection connection = await Connection
+ .Create(mocks.Server.Object, mocks.ConnectionChannel.Object, mocks.VerbMap.Object)
+ ;
+ await connection.NewMessage();
+
+ await connection.AbortMessage();
+ Assert.Null(connection.CurrentMessage);
+ }
+
+ ///
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task CommitMessage()
+ {
+ TestMocks mocks = new TestMocks();
+
+ Connection connection = await Connection
+ .Create(mocks.Server.Object, mocks.ConnectionChannel.Object, mocks.VerbMap.Object)
+ ;
+ IMessageBuilder messageBuilder = await connection.NewMessage();
+ IMessage message = await messageBuilder.ToMessage();
+
+ await connection.CommitMessage();
+ mocks.Session.Verify(s => s.AddMessage(message));
+ mocks.ServerBehaviour.Verify(b => b.OnMessageReceived(connection, message));
+ Assert.Null(connection.CurrentMessage);
+ }
+
+ ///
+ /// The Process_BadCommand_500Response
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task Process_BadCommand_500Response()
+ {
+ TestMocks mocks = new TestMocks();
+ mocks.ConnectionChannel.Setup(c => c.ReadLine()).ReturnsAsync("BADCOMMAND")
+ .Callback(() => mocks.Connection.Object.CloseConnection().Wait());
+
+ Connection connection = await Connection
+ .Create(mocks.Server.Object, mocks.ConnectionChannel.Object, mocks.VerbMap.Object)
+ ;
+ await connection.ProcessAsync();
+
+ mocks.ConnectionChannel.Verify(cc => cc.WriteLine(It.IsRegex("500 .*", RegexOptions.IgnoreCase)));
+ }
+
+ ///
+ /// The Process_EmptyCommand_NoResponse
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task Process_EmptyCommand_NoResponse()
+ {
+ TestMocks mocks = new TestMocks();
+
+ mocks.ConnectionChannel.Setup(c => c.ReadLine()).ReturnsAsync("")
+ .Callback(() => mocks.Connection.Object.CloseConnection().Wait());
+
+ Connection connection = await Connection
+ .Create(mocks.Server.Object, mocks.ConnectionChannel.Object, mocks.VerbMap.Object)
+ ;
+ await connection.ProcessAsync();
+
+ // Should only print service ready message
+ mocks.ConnectionChannel.Verify(
+ cc => cc.WriteLine(It.Is(s => !s.StartsWith("220 ", StringComparison.OrdinalIgnoreCase))),
+ Times.Never());
+ }
+
+ ///
+ /// The Process_GoodCommand_Processed
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task Process_GoodCommand_Processed()
+ {
+ TestMocks mocks = new TestMocks();
+ Mock mockVerb = new Mock();
+ mocks.VerbMap.Setup(v => v.GetVerbProcessor(It.IsAny())).Returns(mockVerb.Object)
+ .Callback(() => mocks.Connection.Object.CloseConnection().Wait());
+
+ mocks.ConnectionChannel.Setup(c => c.ReadLine()).ReturnsAsync("GOODCOMMAND");
+
+ Connection connection = await Connection
+ .Create(mocks.Server.Object, mocks.ConnectionChannel.Object, mocks.VerbMap.Object)
+ ;
+ await connection.ProcessAsync();
+
+ mockVerb.Verify(v => v.Process(It.IsAny(), It.IsAny()));
+ }
+
+ ///
+ /// The Process_GreetingWritten
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task Process_GreetingWritten()
+ {
+ TestMocks mocks = new TestMocks();
+ mocks.ConnectionChannel.Setup(c => c.WriteLine(It.IsAny()))
+ .Callback(() => mocks.Connection.Object.CloseConnection().Wait());
+
+ Connection connection = await Connection
+ .Create(mocks.Server.Object, mocks.ConnectionChannel.Object, mocks.VerbMap.Object)
+ ;
+ await connection.ProcessAsync();
+
+ mocks.ConnectionChannel.Verify(cc => cc.WriteLine(It.IsRegex("220 .*", RegexOptions.IgnoreCase)));
+ }
+
+ ///
+ /// The Process_SmtpServerExceptionThrow_ResponseWritten
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task Process_SmtpServerExceptionThrow_ResponseWritten()
+ {
+ TestMocks mocks = new TestMocks();
+ Mock mockVerb = new Mock();
+ mocks.VerbMap.Setup(v => v.GetVerbProcessor(It.IsAny())).Returns(mockVerb.Object);
+ mockVerb.Setup(v => v.Process(It.IsAny(), It.IsAny()))
+ .Returns(Task.FromException(new SmtpServerException(new SmtpResponse(500, "error"))));
+
+ mocks.ConnectionChannel.Setup(c => c.ReadLine()).ReturnsAsync("GOODCOMMAND")
+ .Callback(() => mocks.Connection.Object.CloseConnection().Wait());
+
+ Connection connection = await Connection
+ .Create(mocks.Server.Object, mocks.ConnectionChannel.Object, mocks.VerbMap.Object)
+ ;
+ await connection.ProcessAsync();
+
+ mocks.ConnectionChannel.Verify(cc => cc.WriteLine(It.IsRegex("500 error", RegexOptions.IgnoreCase)));
+ }
+
+ ///
+ /// The Process_TooManyBadCommands_Disconnected
+ ///
+ /// A representing the async operation
+ [Fact]
+ public async Task Process_TooManyBadCommands_Disconnected()
+ {
+ TestMocks mocks = new TestMocks();
+ mocks.ServerBehaviour.SetupGet(b => b.MaximumNumberOfSequentialBadCommands).Returns(2);
+
+ mocks.ConnectionChannel.Setup(c => c.ReadLine()).ReturnsAsync("BADCOMMAND");
+
+ Connection connection = await Connection
+ .Create(mocks.Server.Object, mocks.ConnectionChannel.Object, mocks.VerbMap.Object)
+ ;
+ await connection.ProcessAsync();
+
+ mocks.ConnectionChannel.Verify(c => c.ReadLine(), Times.Exactly(2));
+ mocks.ConnectionChannel.Verify(cc => cc.WriteLine(It.IsRegex("221 .*", RegexOptions.IgnoreCase)));
+ }
+}
diff --git a/smtpserver/Rnwood.SmtpServer.Tests/DefaultServerBehaviourTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/DefaultServerBehaviourTests.cs
new file mode 100644
index 000000000..1fcc474d6
--- /dev/null
+++ b/smtpserver/Rnwood.SmtpServer.Tests/DefaultServerBehaviourTests.cs
@@ -0,0 +1,71 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using Rnwood.SmtpServer.Extensions.Auth;
+using Xunit;
+
+namespace Rnwood.SmtpServer.Tests;
+
+public class DefaultServerBehaviourTests
+{
+ private readonly Mock connectionMock;
+
+ public DefaultServerBehaviourTests() => connectionMock = new Mock();
+
+ [Fact]
+ public void CanSetAuthMechanismsViaSmtpServer()
+ {
+ DefaultServer smtpServer = new DefaultServer(true);
+ smtpServer.Behaviour.EnabledAuthMechanisms.Clear();
+ smtpServer.Behaviour.EnabledAuthMechanisms.Add(new LoginMechanism());
+ smtpServer.Behaviour.EnabledAuthMechanisms.Count.Should().Be(1);
+ }
+
+ [Theory]
+ [ClassData(typeof(AuthMechanismData))]
+ public async Task EnsureDefaultBehaviourIsAllAUthMechanismsAreEnabled(IAuthMechanism authMechanism)
+ {
+ DefaultServerBehaviour defaultServerBehaviour = new DefaultServerBehaviour(true);
+ defaultServerBehaviour.EnabledAuthMechanisms.Should().NotBeNull();
+ bool enabled = await defaultServerBehaviour.IsAuthMechanismEnabled(connectionMock.Object, authMechanism)
+ ;
+ enabled.Should().BeTrue();
+ }
+
+ [Theory]
+ [ClassData(typeof(AuthMechanismData))]
+ public async Task WhenASupportedAuthMechanismIdentifierIsConfiguredThenVerifyOnlyTheyAreEnabled(
+ IAuthMechanism authMechanism)
+ {
+ DefaultServerBehaviour defaultServerBehaviour = new DefaultServerBehaviour(true);
+ PlainMechanism enabledMechanism = new PlainMechanism();
+ defaultServerBehaviour.EnabledAuthMechanisms.Clear();
+ defaultServerBehaviour.EnabledAuthMechanisms.Add(enabledMechanism);
+
+ bool enabled = await defaultServerBehaviour.IsAuthMechanismEnabled(connectionMock.Object, authMechanism)
+ ;
+ if (authMechanism.Identifier == enabledMechanism.Identifier)
+ {
+ enabled.Should().BeTrue();
+ }
+ else
+ {
+ enabled.Should().BeFalse();
+ }
+ }
+}
+
+public class AuthMechanismData : IEnumerable