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 +{ + public IEnumerator GetEnumerator() + { + yield return new object[] { new CramMd5Mechanism() }; + yield return new object[] { new AnonymousMechanism() }; + yield return new object[] { new LoginMechanism() }; + yield return new object[] { new PlainMechanism() }; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AnonymousMechanismProcessorTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AnonymousMechanismProcessorTests.cs new file mode 100644 index 000000000..927eefa3e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AnonymousMechanismProcessorTests.cs @@ -0,0 +1,74 @@ +// +// 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.Threading.Tasks; +using Moq; +using Rnwood.SmtpServer.Extensions.Auth; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Extensions.Auth; + +/// +/// Defines the +/// +public class AnomymousMechanismProcessorTests +{ + /// + /// The ProcessResponse_Failure + /// + /// A representing the async operation + [Fact] + public async Task ProcessResponse_Failure() => + await ProcessResponseAsync(AuthenticationResult.Failure, AuthMechanismProcessorStatus.Failed) + ; + + /// + /// The ProcessResponse_Success + /// + /// A representing the async operation + [Fact] + public async Task ProcessResponse_Success() => + await ProcessResponseAsync(AuthenticationResult.Success, AuthMechanismProcessorStatus.Success) + ; + + /// + /// The ProcessResponse_TemporarilyFailure + /// + /// A representing the async operation + [Fact] + public async Task ProcessResponse_TemporarilyFailure() => + await ProcessResponseAsync(AuthenticationResult.TemporaryFailure, AuthMechanismProcessorStatus.Failed) + ; + + /// + /// + /// The authenticationResult + /// + /// The authMechanismProcessorStatus + /// + /// A representing the async operation + private async Task ProcessResponseAsync(AuthenticationResult authenticationResult, + AuthMechanismProcessorStatus authMechanismProcessorStatus) + { + TestMocks mocks = new TestMocks(); + mocks.ServerBehaviour.Setup( + b => + b.ValidateAuthenticationCredentials(mocks.Connection.Object, + It.IsAny())) + .ReturnsAsync(authenticationResult); + + AnonymousMechanismProcessor anonymousMechanismProcessor = + new AnonymousMechanismProcessor(mocks.Connection.Object); + AuthMechanismProcessorStatus result = + await anonymousMechanismProcessor.ProcessResponse(null); + + Assert.Equal(authMechanismProcessorStatus, result); + + if (authenticationResult == AuthenticationResult.Success) + { + Assert.IsType(anonymousMechanismProcessor.Credentials); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AuthExtensionProcessorTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AuthExtensionProcessorTests.cs new file mode 100644 index 000000000..94ba66f70 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AuthExtensionProcessorTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Moq; +using Rnwood.SmtpServer.Extensions.Auth; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Extensions.Auth; + +public class AuthExtensionProcessorTests +{ + [Fact] + public async Task GetEHLOKeywords_ReturnsIdentifiers() + { + TestMocks mocks = new TestMocks(); + mocks.ServerBehaviour.Setup(sb => sb.IsAuthMechanismEnabled( + It.IsAny(), + It.IsAny() + )).Returns((c, m) => + Task.FromResult(m.Identifier == "LOGIN" || m.Identifier == "PLAIN")); + AuthExtensionProcessor authExtensionProcessor = new AuthExtensionProcessor(mocks.Connection.Object); + string[] keywords = await authExtensionProcessor.GetEHLOKeywords(); + + Assert.Equal(2, keywords.Length); + Assert.Contains(keywords, k => k.StartsWith("AUTH ")); + Assert.Contains(keywords, k => k.StartsWith("AUTH=")); + + foreach (string keyword in keywords) + { + string[] ids = keyword.Split(new[] { ' ', '=' }, StringSplitOptions.None).Skip(1).ToArray(); + Assert.Equal(2, ids.Length); + Assert.Contains(ids, id => id == "LOGIN"); + Assert.Contains(ids, id => id == "PLAIN"); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AuthMechanismTest.cs b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AuthMechanismTest.cs new file mode 100644 index 000000000..9333af1c4 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/AuthMechanismTest.cs @@ -0,0 +1,38 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Tests.Extensions.Auth +{ + using System; + using System.Text; + + /// + /// Defines the + /// + public class AuthMechanismTest + { + /// + /// The EncodeBase64 + /// + /// The asciiString + /// The + protected static string EncodeBase64(string asciiString) + { + return Convert.ToBase64String(Encoding.ASCII.GetBytes(asciiString)); + } + + /// + /// The VerifyBase64Response + /// + /// The base64 + /// The expectedString + /// The + protected static bool VerifyBase64Response(string base64, string expectedString) + { + string decodedString = Encoding.ASCII.GetString(Convert.FromBase64String(base64)); + return decodedString.Equals(expectedString); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/CramMd5AuthenticationRequestTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/CramMd5AuthenticationRequestTests.cs new file mode 100644 index 000000000..1027bd0d9 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/CramMd5AuthenticationRequestTests.cs @@ -0,0 +1,37 @@ +// +// 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 Rnwood.SmtpServer.Extensions.Auth; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Extensions.Auth; + +/// +/// Defines the +/// +public class CramMd5AuthenticationRequestTests +{ + /// + /// The ValidateResponse_Invalid + /// + [Fact] + public void ValidateResponse_Invalid() + { + CramMd5AuthenticationCredentials authenticationCredentials = + new CramMd5AuthenticationCredentials("username", "challenge", "b26eafe32c337296f7870c68edd5e8a5"); + Assert.False(authenticationCredentials.ValidateResponse("password2")); + } + + /// + /// The ValidateResponse_Valid + /// + [Fact] + public void ValidateResponse_Valid() + { + CramMd5AuthenticationCredentials authenticationCredentials = + new CramMd5AuthenticationCredentials("username", "challenge", "b26eafe32c337296f7870c68edd5e8a5"); + Assert.True(authenticationCredentials.ValidateResponse("password")); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/CramMd5MechanismProcessorTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/CramMd5MechanismProcessorTests.cs new file mode 100644 index 000000000..6186399a4 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/CramMd5MechanismProcessorTests.cs @@ -0,0 +1,109 @@ +// +// 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.Threading.Tasks; +using Moq; +using Rnwood.SmtpServer.Extensions.Auth; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Extensions.Auth; + +/// +/// Defines the +/// +public class CramMd5MechanismProcessorTests : AuthMechanismTest +{ + /// + /// Defines the FAKEDATETIME + /// + private const int FAKEDATETIME = 10000; + + /// + /// Defines the FAKEDOMAIN + /// + private const string FAKEDOMAIN = "mockdomain"; + + /// + /// Defines the FAKERANDOM + /// + private const int FAKERANDOM = 1234; + + /// + /// The ProcessRepsonse_ChallengeReponse_BadFormat + /// + /// A representing the async operation + [Fact] + public async Task ProcessRepsonse_ChallengeReponse_BadFormat() => + await Assert.ThrowsAsync(async () => + { + TestMocks mocks = new(); + + string challenge = string.Format("{0}.{1}@{2}", FAKERANDOM, FAKEDATETIME, FAKEDOMAIN); + + CramMd5MechanismProcessor cramMd5MechanismProcessor = Setup(mocks, challenge); + await cramMd5MechanismProcessor.ProcessResponse("BLAH"); + }); + + /// + /// The ProcessRepsonse_GetChallenge + /// + /// A representing the async operation + [Fact] + public async Task ProcessRepsonse_GetChallenge() + { + TestMocks mocks = new(); + + CramMd5MechanismProcessor cramMd5MechanismProcessor = Setup(mocks); + AuthMechanismProcessorStatus result = + await cramMd5MechanismProcessor.ProcessResponse(null); + + string expectedResponse = string.Format("{0}.{1}@{2}", FAKERANDOM, FAKEDATETIME, FAKEDOMAIN); + + Assert.Equal(AuthMechanismProcessorStatus.Continue, result); + mocks.Connection.Verify( + c => c.WriteResponse( + It.Is(r => + r.Code == (int)StandardSmtpResponseCode.AuthenticationContinue && + VerifyBase64Response(r.Message, expectedResponse) + ) + ) + ); + } + + /// + /// The ProcessResponse_Response_BadBase64 + /// + /// A representing the async operation + [Fact] + public async Task ProcessResponse_Response_BadBase64() => + await Assert.ThrowsAsync(async () => + { + TestMocks mocks = new(); + + CramMd5MechanismProcessor cramMd5MechanismProcessor = Setup(mocks); + await cramMd5MechanismProcessor.ProcessResponse(null); + await cramMd5MechanismProcessor.ProcessResponse("rob blah"); + }); + + /// + /// + /// The mocks + /// The challenge + /// The + private CramMd5MechanismProcessor Setup(TestMocks mocks, string challenge = null) + { + Mock randomMock = new(); + randomMock.Setup(r => r.GenerateRandomInteger(It.IsAny(), It.IsAny())).Returns(FAKERANDOM); + + Mock dateMock = new(); + dateMock.Setup(d => d.GetCurrentDateTime()).Returns(new DateTime(FAKEDATETIME, DateTimeKind.Local)); + + mocks.ServerBehaviour.SetupGet(b => b.DomainName).Returns(FAKEDOMAIN); + + return new CramMd5MechanismProcessor(mocks.Connection.Object, randomMock.Object, dateMock.Object, + challenge); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/LoginMechanismProcessorTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/LoginMechanismProcessorTests.cs new file mode 100644 index 000000000..039c29f84 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Extensions/Auth/LoginMechanismProcessorTests.cs @@ -0,0 +1,119 @@ +// +// 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.Threading.Tasks; +using Moq; +using Rnwood.SmtpServer.Extensions.Auth; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Extensions.Auth; + +/// +/// Defines the +/// +public class LoginMechanismProcessorTests : AuthMechanismTest +{ + /// + /// The ProcessRepsonse_NoUsername_GetUsernameChallenge + /// + /// A representing the async operation + [Fact] + public async Task ProcessRepsonse_NoUsername_GetUsernameChallenge() + { + TestMocks mocks = new TestMocks(); + + LoginMechanismProcessor processor = Setup(mocks); + AuthMechanismProcessorStatus result = await processor.ProcessResponse(null); + + Assert.Equal(AuthMechanismProcessorStatus.Continue, result); + mocks.Connection.Verify(c => + c.WriteResponse( + It.Is(r => + r.Code == (int)StandardSmtpResponseCode.AuthenticationContinue && + VerifyBase64Response(r.Message, "Username:") + ) + ) + ); + } + + /// + /// The ProcessRepsonse_Username_GetPasswordChallenge + /// + /// A representing the async operation + [Fact] + public async Task ProcessRepsonse_Username_GetPasswordChallenge() + { + TestMocks mocks = new TestMocks(); + + LoginMechanismProcessor processor = Setup(mocks); + AuthMechanismProcessorStatus + result = await processor.ProcessResponse(EncodeBase64("rob")); + + Assert.Equal(AuthMechanismProcessorStatus.Continue, result); + + mocks.Connection.Verify(c => + c.WriteResponse( + It.Is(r => + VerifyBase64Response(r.Message, "Password:") + && r.Code == (int)StandardSmtpResponseCode.AuthenticationContinue + ) + ) + ); + } + + /// + /// The ProcessResponse_PasswordAcceptedAfterUserNameInInitialRequest + /// + /// A representing the async operation + [Fact] + public async Task ProcessResponse_PasswordAcceptedAfterUserNameInInitialRequest() + { + TestMocks mocks = new TestMocks(); + mocks.ServerBehaviour + .Setup(sb => + sb.ValidateAuthenticationCredentials(It.IsAny(), + It.IsAny())).Returns(Task.FromResult(AuthenticationResult.Success)); + + + LoginMechanismProcessor processor = Setup(mocks); + AuthMechanismProcessorStatus + result = await processor.ProcessResponse(EncodeBase64("rob")); + + Assert.Equal(AuthMechanismProcessorStatus.Continue, result); + + mocks.Connection.Verify(c => + c.WriteResponse( + It.Is(r => + VerifyBase64Response(r.Message, "Password:") + && r.Code == (int)StandardSmtpResponseCode.AuthenticationContinue + ) + ) + ); + + result = await processor.ProcessResponse(EncodeBase64("password")); + Assert.Equal(AuthMechanismProcessorStatus.Success, result); + } + + /// + /// The ProcessResponse_Response_BadBase64 + /// + /// A representing the async operation + [Fact] + public async Task ProcessResponse_Response_BadBase64() => + await Assert.ThrowsAsync(async () => + { + TestMocks mocks = new TestMocks(); + + LoginMechanismProcessor processor = Setup(mocks); + await processor.ProcessResponse(null); + await processor.ProcessResponse("rob blah"); + }); + + /// + /// + /// The mocks + /// The + private LoginMechanismProcessor Setup(TestMocks mocks) => new(mocks.Connection.Object); +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/FileMessageBuilderTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/FileMessageBuilderTests.cs new file mode 100644 index 000000000..46e323094 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/FileMessageBuilderTests.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 System.IO; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public class FileMessageBuilderTests : MessageBuilderTests +{ + /// + /// + /// The + protected override IMessageBuilder GetInstance() + { + FileInfo tempFile = new FileInfo(Path.GetTempFileName()); + + TestMocks mocks = new TestMocks(); + return new FileMessageBuilder(tempFile, false); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/GlobalSuppressions.cs b/smtpserver/Rnwood.SmtpServer.Tests/GlobalSuppressions.cs new file mode 100644 index 000000000..0b254d514 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/GlobalSuppressions.cs @@ -0,0 +1,10 @@ +// +// 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.Diagnostics.CodeAnalysis; + +[assembly: + SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "OK for tests", + Scope = "assembly")] diff --git a/smtpserver/Rnwood.SmtpServer.Tests/MemorySessionTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/MemorySessionTests.cs new file mode 100644 index 000000000..a8940863e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/MemorySessionTests.cs @@ -0,0 +1,20 @@ +// +// 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.Net; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public class MemorySessionTests : AbstractSessionTests +{ + /// + /// + /// The + protected override IEditableSession GetSession() => new MemorySession(IPAddress.Loopback, DateTime.Now); +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/MessageBuilderTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/MessageBuilderTests.cs new file mode 100644 index 000000000..95acb50c2 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/MessageBuilderTests.cs @@ -0,0 +1,65 @@ +// +// 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.IO; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public abstract class MessageBuilderTests +{ + /// + /// + [Fact] + public void AddTo() + { + IMessageBuilder builder = GetInstance(); + + builder.Recipients.Add("foo@bar.com"); + builder.Recipients.Add("bar@foo.com"); + + Assert.Equal(2, builder.Recipients.Count); + Assert.Equal("foo@bar.com", builder.Recipients.ElementAt(0)); + Assert.Equal("bar@foo.com", builder.Recipients.ElementAt(1)); + } + + /// + /// The WriteData_Accepted + /// + /// A representing the async operation + [Fact] + public async Task WriteData_Accepted() + { + IMessageBuilder builder = GetInstance(); + + byte[] writtenBytes = new byte[64 * 1024]; + new Random().NextBytes(writtenBytes); + + using (Stream stream = await builder.WriteData()) + { + stream.Write(writtenBytes, 0, writtenBytes.Length); + } + + byte[] readBytes; + using (Stream stream = await builder.GetData()) + { + readBytes = new byte[stream.Length]; + stream.Read(readBytes, 0, readBytes.Length); + } + + Assert.Equal(writtenBytes, readBytes); + } + + /// + /// + /// The + protected abstract IMessageBuilder GetInstance(); +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/MessageEventArgsTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/MessageEventArgsTests.cs new file mode 100644 index 000000000..c527c2855 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/MessageEventArgsTests.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 MessageEventArgsTests +{ + /// + /// + [Fact] + public void Message() + { + IMessage message = new MemoryMessage(); + MessageEventArgs messageEventArgs = new MessageEventArgs(message); + + Assert.Same(message, messageEventArgs.Message); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/ParameterParserTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/ParameterParserTests.cs new file mode 100644 index 000000000..580002c12 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/ParameterParserTests.cs @@ -0,0 +1,49 @@ +// +// 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 ParameterParserTests +{ + /// + /// + [Fact] + public void MultipleParameters() + { + ParameterParser parameterParser = new ParameterParser("KEYA=VALUEA", "KEYB=VALUEB", "KEYC"); + + Assert.Equal(3, parameterParser.Parameters.Count); + Assert.Equal(new Parameter("KEYA", "VALUEA"), parameterParser.Parameters.First()); + Assert.Equal(new Parameter("KEYB", "VALUEB"), parameterParser.Parameters.ElementAt(1)); + Assert.Equal(new Parameter("KEYC", null), parameterParser.Parameters.ElementAt(2)); + } + + /// + /// + [Fact] + public void NoParameters() + { + ParameterParser parameterParser = new ParameterParser(); + + Assert.Empty(parameterParser.Parameters); + } + + /// + /// + [Fact] + public void SingleParameter() + { + ParameterParser parameterParser = new ParameterParser("KEYA=VALUEA"); + + Assert.Single(parameterParser.Parameters); + Assert.Equal(new Parameter("KEYA", "VALUEA"), parameterParser.Parameters.First()); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/ParameterProcessorMapTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/ParameterProcessorMapTests.cs new file mode 100644 index 000000000..e0dd07b81 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/ParameterProcessorMapTests.cs @@ -0,0 +1,108 @@ +// +// 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.Threading.Tasks; +using Moq; +using Xunit; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public class ParameterProcessorMapTests +{ + /// + /// The GetProcessor_NotRegistered_Null + /// + [Fact] + public void GetProcessor_NotRegistered_Null() + { + ParameterProcessorMap map = new ParameterProcessorMap(); + Assert.Null(map.GetProcessor("BLAH")); + } + + /// + /// The GetProcessor_Registered_Returned + /// + [Fact] + public void GetProcessor_Registered_Returned() + { + Mock processor = new Mock(); + + ParameterProcessorMap map = new ParameterProcessorMap(); + map.SetProcessor("BLAH", processor.Object); + + Assert.Same(processor.Object, map.GetProcessor("BLAH")); + } + + /// + /// The GetProcessor_RegisteredDifferentCase_Returned + /// + [Fact] + public void GetProcessor_RegisteredDifferentCase_Returned() + { + Mock processor = new Mock(); + + ParameterProcessorMap map = new ParameterProcessorMap(); + map.SetProcessor("blah", processor.Object); + + Assert.Same(processor.Object, map.GetProcessor("BLAH")); + } + + /// + /// The Process_KnownParameters_Processed + /// + /// A representing the async operation + [Fact] + public async Task Process_KnownParameters_Processed() + { + TestMocks mocks = new TestMocks(); + Mock keyAProcessor = new Mock(); + Mock keyBProcessor = new Mock(); + + ParameterProcessorMap map = new ParameterProcessorMap(); + map.SetProcessor("keya", keyAProcessor.Object); + map.SetProcessor("keyb", keyBProcessor.Object); + + await map.Process(mocks.Connection.Object, new[] { "KEYA=VALUEA", "KEYB=VALUEB" }, true) + ; + + keyAProcessor.Verify(p => p.SetParameter(mocks.Connection.Object, "KEYA", "VALUEA")); + keyBProcessor.Verify(p => p.SetParameter(mocks.Connection.Object, "KEYB", "VALUEB")); + } + + /// + /// The Process_NoParameters_Accepted + /// + /// A representing the async operation + [Fact] + public async Task Process_NoParameters_Accepted() + { + TestMocks mocks = new TestMocks(); + + ParameterProcessorMap map = new ParameterProcessorMap(); + await map.Process(mocks.Connection.Object, new string[] { }, true); + Assert.True(true); + } + + /// + /// The Process_UnknownParameter_Throws + /// + /// A representing the async operation + [Fact] + public async Task Process_UnknownParameter_Throws() + { + SmtpServerException e = await Assert.ThrowsAsync(async () => + { + TestMocks mocks = new TestMocks(); + + ParameterProcessorMap map = new ParameterProcessorMap(); + await map.Process(mocks.Connection.Object, new[] { "KEYA=VALUEA" }, true); + }); + + Assert.Equal("Parameter KEYA is not recognised", e.Message); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/ParameterTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/ParameterTests.cs new file mode 100644 index 000000000..2b8b0c429 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/ParameterTests.cs @@ -0,0 +1,48 @@ +// +// 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 ParameterTests +{ + /// + /// The Equality_Equal + /// + [Fact] + public void Equality_Equal() => + Assert.True(new Parameter("KEYA", "VALUEA").Equals(new Parameter("KEYa", "VALUEA"))); + + /// + /// The Equality_NotEqual + /// + [Fact] + public void Equality_NotEqual() => + Assert.False(new Parameter("KEYb", "VALUEb").Equals(new Parameter("KEYa", "VALUEA"))); + + /// + /// + [Fact] + public void Name() + { + Parameter p = new Parameter("name", "value"); + + Assert.Equal("name", p.Name); + } + + /// + /// + [Fact] + public void Value() + { + Parameter p = new Parameter("name", "value"); + + Assert.Equal("value", p.Value); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Properties/AssemblyInfo.cs b/smtpserver/Rnwood.SmtpServer.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..69d9e34fd --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,37 @@ +// +// 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.Reflection; +using System.Runtime.InteropServices; + + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. + +[assembly: AssemblyTitle("Rnwood.SmtpServer.Tests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. + +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM + +[assembly: Guid("756bec71-22da-4b6d-935f-a91e84661596")] +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] diff --git a/smtpserver/Rnwood.SmtpServer.Tests/RandomIntegerGeneratorTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/RandomIntegerGeneratorTests.cs new file mode 100644 index 000000000..9fd75b145 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/RandomIntegerGeneratorTests.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 RandomIntegerGeneratorTests +{ + /// + /// + [Fact] + public void GenerateRandomInteger() + { + RandomIntegerGenerator randomNumberGenerator = new RandomIntegerGenerator(); + int randomNumber = randomNumberGenerator.GenerateRandomInteger(-100, 100); + Assert.True(randomNumber >= -100); + Assert.True(randomNumber <= 100); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Rnwood.SmtpServer.Tests.csproj b/smtpserver/Rnwood.SmtpServer.Tests/Rnwood.SmtpServer.Tests.csproj new file mode 100644 index 000000000..5f7be47d5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Rnwood.SmtpServer.Tests.csproj @@ -0,0 +1,46 @@ + + + + 3.0 + net6.0;net462 + Rnwood.SmtpServer.Tests + Library + latest + + Rnwood.SmtpServer.Tests + true + 6.0.0 + false + false + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.6.2\mscorlib.dll + + + + diff --git a/smtpserver/Rnwood.SmtpServer.Tests/ServerTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/ServerTests.cs new file mode 100644 index 000000000..9dc900c3f --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/ServerTests.cs @@ -0,0 +1,179 @@ +// +// 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.Net.Sockets; +using System.Threading.Tasks; +using Xunit; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public class ServerTests +{ + /// + /// The Start_CanConnect + /// + [Fact] + public async Task Start_CanConnect() + { + using (SmtpServer server = StartServer()) + { + using (TcpClient client = new TcpClient()) + { + await client.ConnectAsync("localhost", server.PortNumber); + Assert.True(client.Connected); + } + + server.Stop(); + } + + } + + /// + /// The Start_IsRunning + /// + [Fact] + public void Start_IsRunning() + { + using (SmtpServer server = StartServer()) + { + Assert.True(server.IsRunning); + } + } + + /// Tests that the port number is returned via the PortNumber property when AssignAutomatically is used. + [Fact] + public void StartOnAutomaticPort_PortNumberReturned() + { + SmtpServer server = new DefaultServer(false, StandardSmtpPort.AssignAutomatically); + server.Start(); + Assert.NotEqual(0, server.PortNumber); + } + + /// + /// The StartOnInusePort_StartupExceptionThrown + /// + [SkippableFact] + public void StartOnInusePort_StartupExceptionThrown() + { + //Exclusive port use is only available on Windows. + //On other platforms the listener with the most specific address will receive + //all the connections. + Skip.IfNot(Environment.OSVersion.Platform == PlatformID.Win32NT); + + using (SmtpServer server1 = new DefaultServer(false, StandardSmtpPort.AssignAutomatically)) + { + server1.Start(); + + using (SmtpServer server2 = new DefaultServer(false, server1.PortNumber)) + { + Assert.Throws(() => { server2.Start(); }); + } + } + } + + /// + /// The Stop_CannotConnect + /// + /// A representing the async operation + [Fact] + public async Task Stop_CannotConnect() + { + using (SmtpServer server = StartServer()) + { + int portNumber = server.PortNumber; + server.Stop(); + + using TcpClient client = new TcpClient(); + await Assert.ThrowsAnyAsync(async () => + await client.ConnectAsync("localhost", portNumber) + ); + } + } + + /// + /// The Stop_KillConnectionFalse_ConnectionsNotKilled + /// + /// A representing the async operation + [Fact] + public async Task Stop_KillConnectionFalse_ConnectionsNotKilled() + { + SmtpServer server = StartServer(); + + Task serverTask = Task.Run(async () => + { + await Task.Run(() => server.WaitForNextConnection()).WithTimeout("waiting for next server connection") + ; + Assert.Single(server.ActiveConnections); + + await Task.Run(() => server.Stop(false)).WithTimeout("stopping server"); + Assert.Single(server.ActiveConnections); + await Task.Run(() => server.KillConnections()).WithTimeout("killing connections"); + Assert.Empty(server.ActiveConnections); + }); + + using (TcpClient client = new TcpClient()) + { + await client.ConnectAsync("localhost", server.PortNumber).WithTimeout("waiting for client to connect") + ; + await serverTask.WithTimeout(30, "waiting for server task to complete"); + } + } + + /// + /// The Stop_KillConnectionsTrue_ConnectionsKilled + /// + /// A representing the async operation + [Fact] + public async Task Stop_KillConnectionsTrue_ConnectionsKilled() + { + SmtpServer server = StartServer(); + + Task serverTask = Task.Run(async () => + { + await Task.Run(() => server.WaitForNextConnection()) + .WithTimeout("waiting for next server connection"); + Assert.Single(server.ActiveConnections); + await Task.Run(() => server.Stop(true)).WithTimeout("stopping server"); + Assert.Empty(server.ActiveConnections); + }); + + using TcpClient client = new TcpClient(); + await client.ConnectAsync("localhost", server.PortNumber) + .WithTimeout("waiting for client to connect"); + await serverTask.WithTimeout(30, "waiting for server task to complete"); + } + + /// + /// The Stop_NotRunning + /// + [Fact] + public void Stop_NotRunning() + { + using (SmtpServer server = StartServer()) + { + server.Stop(); + Assert.False(server.IsRunning); + } + } + + /// + /// + /// The + private SmtpServer NewServer() => new DefaultServer(false, StandardSmtpPort.AssignAutomatically); + + /// + /// + /// The + private SmtpServer StartServer() + { + SmtpServer server = NewServer(); + server.Start(); + return server; + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/SessionEventArgsTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/SessionEventArgsTests.cs new file mode 100644 index 000000000..6a46cbeda --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/SessionEventArgsTests.cs @@ -0,0 +1,26 @@ +// +// 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 SessionEventArgsTests +{ + /// + /// + [Fact] + public void Session() + { + TestMocks mocks = new TestMocks(); + + SessionEventArgs s = new SessionEventArgs(mocks.Session.Object); + + Assert.Equal(s.Session, mocks.Session.Object); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/SmtpClientLogger.cs b/smtpserver/Rnwood.SmtpServer.Tests/SmtpClientLogger.cs new file mode 100644 index 000000000..165c05591 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/SmtpClientLogger.cs @@ -0,0 +1,61 @@ +// +// 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; +using MailKit; +using Xunit.Abstractions; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public partial class ClientTests +{ + /// + /// Defines the + /// + internal class SmtpClientLogger : IProtocolLogger + { + /// + /// Defines the testOutput + /// + private readonly ITestOutputHelper testOutput; + + /// + /// Initializes a new instance of the class. + /// + /// The testOutput + public SmtpClientLogger(ITestOutputHelper testOutput) => this.testOutput = testOutput; + + /// + /// + public void Dispose() => testOutput.WriteLine("*** DISCONNECT"); + + /// + /// + /// The buffer + /// The offset + /// The count + public void LogClient(byte[] buffer, int offset, int count) => + testOutput.WriteLine(">>> " + Encoding.UTF8.GetString(buffer, offset, count).Replace("\r", "\\r") + .Replace("\n", "\\n\n")); + + /// + /// + /// The uri + public void LogConnect(Uri uri) => testOutput.WriteLine($"*** CONNECT {uri}"); + + /// + /// + /// The buffer + /// The offset + /// The count + public void LogServer(byte[] buffer, int offset, int count) => + testOutput.WriteLine("<<< " + Encoding.UTF8.GetString(buffer, offset, count).Replace("\r", "\\r") + .Replace("\n", "\\n\n")); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/SmtpCommandTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/SmtpCommandTests.cs new file mode 100644 index 000000000..e31dfce23 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/SmtpCommandTests.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 Xunit; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public class SmtpCommandTests +{ + /// + /// The Parsing_ArgsSeparatedByColon + /// + [Fact] + public void Parsing_ArgsSeparatedByColon() + { + SmtpCommand command = new SmtpCommand("DATA:ARGS"); + Assert.True(command.IsValid); + Assert.Equal("DATA", command.Verb); + Assert.Equal("ARGS", command.ArgumentsText); + } + + /// + /// The Parsing_ArgsSeparatedBySpace + /// + [Fact] + public void Parsing_ArgsSeparatedBySpace() + { + SmtpCommand command = new SmtpCommand("DATA ARGS"); + Assert.True(command.IsValid); + Assert.Equal("DATA", command.Verb); + Assert.Equal("ARGS", command.ArgumentsText); + } + + /// + /// The Parsing_SingleToken + /// + [Fact] + public void Parsing_SingleToken() + { + SmtpCommand command = new SmtpCommand("DATA"); + Assert.True(command.IsValid); + Assert.Equal("DATA", command.Verb); + Assert.Equal("", command.ArgumentsText); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/SmtpResponseTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/SmtpResponseTests.cs new file mode 100644 index 000000000..29678eaae --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/SmtpResponseTests.cs @@ -0,0 +1,114 @@ +// +// 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 SmtpResponseTests +{ + /// + /// + [Fact] + public void Code() + { + SmtpResponse r = new SmtpResponse(1, "Blah"); + Assert.Equal(1, r.Code); + } + + /// + /// The Equality_Equal + /// + [Fact] + public void Equality_Equal() => + Assert.True( + new SmtpResponse(StandardSmtpResponseCode.OK, "OK").Equals(new SmtpResponse(StandardSmtpResponseCode.OK, + "OK"))); + + /// + /// The Equality_NotEqual + /// + [Fact] + public void Equality_NotEqual() => + Assert.False( + new SmtpResponse(StandardSmtpResponseCode.SyntaxErrorCommandUnrecognised, "Eror").Equals( + new SmtpResponse(StandardSmtpResponseCode.OK, "OK"))); + + /// + /// The IsError_Error + /// + [Fact] + public void IsError_Error() + { + SmtpResponse r = new SmtpResponse(500, "An error happened"); + Assert.True(r.IsError); + } + + /// + /// The IsError_NotError + /// + [Fact] + public void IsError_NotError() + { + SmtpResponse r = new SmtpResponse(200, "No error happened"); + Assert.False(r.IsError); + } + + /// + /// The IsSuccess_Error + /// + [Fact] + public void IsSuccess_Error() + { + SmtpResponse r = new SmtpResponse(500, "An error happened"); + Assert.False(r.IsSuccess); + } + + /// + /// The IsSuccess_NotError + /// + [Fact] + public void IsSuccess_NotError() + { + SmtpResponse r = new SmtpResponse(200, "No error happened"); + Assert.True(r.IsSuccess); + } + + /// + /// + [Fact] + public void Message() + { + SmtpResponse r = new SmtpResponse(1, "Blah"); + Assert.Equal("Blah", r.Message); + } + + /// + /// The ToString_MultiLineMessage + /// + [Fact] + public void ToString_MultiLineMessage() + { + SmtpResponse r = new SmtpResponse(200, "Multi line message line 1\r\n" + + "Multi line message line 2\r\n" + + "Multi line message line 3"); + Assert.Equal("200-Multi line message line 1\r\n" + + "200-Multi line message line 2\r\n" + + "200 Multi line message line 3\r\n", r.ToString()); + } + + /// + /// The ToString_SingleLineMessage + /// + [Fact] + public void ToString_SingleLineMessage() + { + SmtpResponse r = new SmtpResponse(200, "Single line message"); + Assert.Equal("200 Single line message\r\n", r.ToString()); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/SmtpServerExceptionTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/SmtpServerExceptionTests.cs new file mode 100644 index 000000000..ec478382f --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/SmtpServerExceptionTests.cs @@ -0,0 +1,39 @@ +// +// 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 Xunit; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public class SmtpServerExceptionTests +{ + /// + /// + [Fact] + public void InnerException() + { + Exception innerException = new Exception(); + + SmtpServerException e = new SmtpServerException( + new SmtpResponse(StandardSmtpResponseCode.ExceededStorageAllocation, "Blah"), innerException); + + Assert.Same(innerException, e.InnerException); + } + + /// + /// + [Fact] + public void SmtpResponse() + { + SmtpResponse smtpResponse = new SmtpResponse(StandardSmtpResponseCode.ExceededStorageAllocation, "Blah"); + SmtpServerException e = new SmtpServerException(smtpResponse); + + Assert.Same(smtpResponse, e.SmtpResponse); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/SmtpStreamReaderTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/SmtpStreamReaderTests.cs new file mode 100644 index 000000000..e44495fe1 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/SmtpStreamReaderTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Rnwood.SmtpServer.Tests; + +public class SmtpStreamReaderTests +{ + [Fact] + public async Task ReadLine_WithFallbackChars() => + await Test("MAIL FROM \r\n", Encoding.GetEncoding("iso-8859-1"), + new[] { "MAIL FROM " }); + + [Fact] + public async Task ReadLine_WithAnsiChars() => + await Test("MAIL FROM \r\n", Encoding.GetEncoding("iso-8859-1"), + new[] { "MAIL FROM " }); + + [Fact] + public async Task ReadLine_WithUtf8Chars() => + await Test("MAIL FROM <ظػؿقط >\r\n", Encoding.UTF8, + new[] { "MAIL FROM <ظػؿقط >" }); + + + [Fact] + public async Task ReadLine_MutipleLinesInBuffer() => + await Test("aaa\r\nbbb\r\nccc\r\n", Encoding.UTF8, new[] { "aaa", "bbb", "ccc" }); + + private async Task Test(string data, Encoding encoding, string[] expectedLines) + { + byte[] dataBytes = encoding.GetBytes(data); + using (Stream stream = new MemoryStream(dataBytes)) + { + using (SmtpStreamReader ssr = new SmtpStreamReader(stream, Encoding.GetEncoding("iso-8859-1"), false)) + { + using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) + { + List receivedLines = new List(); + + string receivedLine; + + while ((receivedLine = await ssr.ReadLineAsync(cts.Token)) != null) + { + receivedLines.Add(receivedLine); + } + + byte[] receivedBytes = encoding.GetBytes(string.Join("\r\n", receivedLines)); + byte[] expectedBytes = encoding.GetBytes(string.Join("\r\n", expectedLines)); + Assert.Equal(expectedBytes, receivedBytes); + } + } + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/TaskExtensions.cs b/smtpserver/Rnwood.SmtpServer.Tests/TaskExtensions.cs new file mode 100644 index 000000000..ec819e301 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/TaskExtensions.cs @@ -0,0 +1,46 @@ +// +// 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.Diagnostics; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public static class TaskExtensions +{ + /// + /// + /// The task + /// The seconds + /// The descriptionOfTask + /// A representing the async operation + public static async Task WithTimeout(this Task task, int seconds, string descriptionOfTask) + { + if (Debugger.IsAttached) + { + await task; + return; + } + + Task completedTask = await Task.WhenAny(task, Task.Delay(seconds * 1000)); + + if (completedTask != task) + { + throw new TimeoutException("Timeout waiting for " + descriptionOfTask); + } + } + + /// + /// + /// The task + /// The descriptionOfTask + /// A representing the async operation + public static async Task WithTimeout(this Task task, string descriptionOfTask) => + await WithTimeout(task, 10, descriptionOfTask); +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/TcpClientConnectionChannelTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/TcpClientConnectionChannelTests.cs new file mode 100644 index 000000000..ad7a5ead9 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/TcpClientConnectionChannelTests.cs @@ -0,0 +1,53 @@ +// +// 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.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public class TcpClientConnectionChannelTests +{ + /// + /// The ReadLineAsync_ThrowsOnConnectionClose + /// + /// A representing the async operation + [Fact] + public async Task ReadLineAsync_ThrowsOnConnectionClose() + { + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + + try + { + listener.Start(); + Task acceptTask = listener.AcceptTcpClientAsync(); + + TcpClient client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port) + ; + + using (TcpClient serverTcpClient = await acceptTask) + { + TcpClientConnectionChannel channel = new TcpClientConnectionChannel(serverTcpClient, Encoding.Default); + client.Dispose(); + + await Assert.ThrowsAsync(async () => + { + await channel.ReadLine(); + }); + } + } + finally + { + listener.Stop(); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/TestMocks.cs b/smtpserver/Rnwood.SmtpServer.Tests/TestMocks.cs new file mode 100644 index 000000000..90c13459e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/TestMocks.cs @@ -0,0 +1,128 @@ +// +// 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.Net; +using System.Text; +using System.Threading.Tasks; +using Moq; +using Rnwood.SmtpServer.Extensions; +using Rnwood.SmtpServer.Extensions.Auth; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer.Tests; + +/// +/// Defines the +/// +public class TestMocks +{ + /// + /// Initializes a new instance of the class. + /// + public TestMocks() + { + Connection = new Mock(MockBehavior.Strict); + ConnectionChannel = new Mock(MockBehavior.Strict); + Session = new Mock(IPAddress.Loopback, DateTime.Now) { CallBase = true }; + Server = new Mock(MockBehavior.Strict); + ServerBehaviour = new Mock(MockBehavior.Strict); + MessageBuilder = new Mock { CallBase = true }; + VerbMap = new Mock { CallBase = true }; + + ServerBehaviour.Setup( + sb => sb.OnCreateNewSession(It.IsAny())).ReturnsAsync(Session.Object); + ServerBehaviour.Setup(s => s.FallbackEncoding).Returns(Encoding.GetEncoding("iso-8859-1")); + ServerBehaviour.Setup(sb => sb.OnCreateNewMessage(It.IsAny())) + .ReturnsAsync(MessageBuilder.Object); + ServerBehaviour.Setup(sb => sb.GetExtensions(It.IsAny())) + .ReturnsAsync(new IExtension[0]); + ServerBehaviour.Setup(sb => sb.OnSessionCompleted(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + ServerBehaviour.SetupGet(sb => sb.DomainName).Returns("tests"); + ServerBehaviour.Setup(sb => sb.IsSSLEnabled(It.IsAny())).Returns(Task.FromResult(false)); + ServerBehaviour.Setup(sb => sb.OnSessionStarted(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + ServerBehaviour + .Setup(sb => + sb.OnMessageRecipientAdding(It.IsAny(), It.IsAny(), + It.IsAny())).Returns(Task.CompletedTask); + ServerBehaviour.Setup(sb => sb.OnMessageStart(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + ServerBehaviour.Setup(sb => sb.OnMessageReceived(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + ServerBehaviour.Setup(sb => sb.OnMessageCompleted(It.IsAny())).Returns(Task.CompletedTask); + ServerBehaviour.Setup(sb => sb.OnCommandReceived(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + ServerBehaviour.SetupGet(sb => sb.MaximumNumberOfSequentialBadCommands).Returns(0); + ServerBehaviour + .Setup(sb => + sb.ValidateAuthenticationCredentials(It.IsAny(), + It.IsAny())).Returns(Task.FromResult(AuthenticationResult.Failure)); + + Connection.SetupAllProperties(); + Connection.SetupGet(c => c.Session).Returns(Session.Object); + Connection.SetupGet(c => c.Server).Returns(Server.Object); + Connection.Setup(s => s.CloseConnection()).Returns(() => ConnectionChannel.Object.Close()); + Connection.SetupGet(s => s.ExtensionProcessors).Returns(new IExtensionProcessor[0]); + Connection.SetupGet(c => c.VerbMap).Returns(VerbMap.Object); + Connection.Setup(c => c.WriteResponse(It.IsAny())).Returns(Task.CompletedTask); + Connection.Setup(c => c.CommitMessage()).Returns(Task.CompletedTask); + Connection.Setup(c => c.AbortMessage()).Returns(Task.CompletedTask); + + Server.SetupGet(s => s.Behaviour).Returns(ServerBehaviour.Object); + + bool isConnected = true; + ConnectionChannel.Setup(s => s.IsConnected).Returns(() => isConnected); + ConnectionChannel.Setup(s => s.Close()).Returns(() => Task.Run(() => isConnected = false)); + ConnectionChannel.Setup(s => s.ClientIPAddress).Returns(IPAddress.Loopback); + ConnectionChannel.Setup(s => s.WriteLine(It.IsAny())).Returns(Task.CompletedTask); + ConnectionChannel.Setup(s => s.Flush()).Returns(Task.CompletedTask); + } + + /// + /// Gets the Connection + /// + public Mock Connection { get; } + + /// + /// Gets the ConnectionChannel + /// + public Mock ConnectionChannel { get; } + + /// + /// Gets the MessageBuilder + /// + public Mock MessageBuilder { get; } + + /// + /// Gets the Server + /// + public Mock Server { get; } + + /// + /// Gets the ServerBehaviour + /// + public Mock ServerBehaviour { get; } + + /// + /// Gets the Session + /// + public Mock Session { get; } + + /// + /// Gets the VerbMap + /// + public Mock VerbMap { get; } + + /// + /// + /// The responseCode + public void VerifyWriteResponse(StandardSmtpResponseCode responseCode, Times times) => + Connection.Verify(c => c.WriteResponse(It.Is(r => r.Code == (int)responseCode)), times); + + public void VerifyWriteResponse(StandardSmtpResponseCode responseCode) => + VerifyWriteResponse(responseCode, Times.Once()); +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/DataVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/DataVerbTests.cs new file mode 100644 index 000000000..e72e7a0d5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/DataVerbTests.cs @@ -0,0 +1,199 @@ +// +// 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.IO; +using System.Text; +using System.Threading.Tasks; +using Moq; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class DataVerbTests +{ + /// + /// The Data_8BitData_PassedThrough + /// + /// A representing the async operation + [Fact] + public async Task Data_8BitData_PassedThrough() + { + string data = ((char)(0x41 + 128)).ToString(); + await TestGoodDataAsync(new[] { data, "." }, data); + } + + /// + /// The Data_AboveSizeLimit_Rejected + /// + /// A representing the async operation + [Fact] + public async Task Data_AboveSizeLimit_Rejected() + { + TestMocks mocks = new TestMocks(); + + MemoryMessageBuilder messageBuilder = new MemoryMessageBuilder(); + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(messageBuilder); + mocks.ServerBehaviour.Setup(b => b.GetMaximumMessageSize(It.IsAny())).ReturnsAsync(10); + + string[] messageData = { new('x', 11), "." }; + int messageLine = 0; + mocks.Connection.Setup(c => c.ReadLineBytes()).Returns(() => + Task.FromResult(Encoding.ASCII.GetBytes(messageData[messageLine++]))); + + DataVerb verb = new DataVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("DATA")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.StartMailInputEndWithDot); + mocks.VerifyWriteResponse(StandardSmtpResponseCode.ExceededStorageAllocation); + + mocks.Connection.Verify(c=>c.AbortMessage()); + } + + /// + /// The Data_DoubleDots_Unescaped + /// + /// A representing the async operation + [Fact] + public async Task Data_DoubleDots_Unescaped() => + //Check escaping of end of message character ".." is decoded to "." + //but the .. after B should be left alone + await TestGoodDataAsync(new[] { "A", "..", "B..", "." }, "A\r\n.\r\nB.."); + + /// + /// The Data_EmptyMessage_Accepted + /// + /// A representing the async operation + [Fact] + public async Task Data_EmptyMessage_Accepted() => await TestGoodDataAsync(new[] { "." }, ""); + + /// + /// The Data_ExactlySizeLimit_Accepted + /// + /// A representing the async operation + [Fact] + public async Task Data_ExactlySizeLimit_Accepted() + { + TestMocks mocks = new TestMocks(); + + MemoryMessageBuilder messageBuilder = new MemoryMessageBuilder(); + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(messageBuilder); + mocks.ServerBehaviour.Setup(b => b.GetMaximumMessageSize(It.IsAny())).ReturnsAsync(10); + + string[] messageData = { new('x', 10), "." }; + int messageLine = 0; + mocks.Connection.Setup(c => c.ReadLineBytes()) + .Returns(() => Task.FromResult(Encoding.UTF8.GetBytes(messageData[messageLine++]))); + + DataVerb verb = new DataVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("DATA")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.StartMailInputEndWithDot); + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + + + mocks.Connection.Verify(c=>c.CommitMessage()); + } + + [Fact] + public async Task Data_BehaviourThrowsErrorOnCompletedMessage_Rejected() + { + TestMocks mocks = new TestMocks(); + + MemoryMessageBuilder messageBuilder = new MemoryMessageBuilder(); + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(messageBuilder); + mocks.ServerBehaviour.Setup(b => b.GetMaximumMessageSize(It.IsAny())).ReturnsAsync(10); + mocks.ServerBehaviour.Setup(b => b.OnMessageCompleted(It.IsAny())).ThrowsAsync(new SmtpServerException(new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, "No thanks!"))); + + string[] messageData = { new('x', 10), "." }; + int messageLine = 0; + mocks.Connection.Setup(c => c.ReadLineBytes()) + .Returns(() => Task.FromResult(Encoding.UTF8.GetBytes(messageData[messageLine++]))); + + DataVerb verb = new DataVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("DATA")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.StartMailInputEndWithDot); + mocks.VerifyWriteResponse(StandardSmtpResponseCode.TransactionFailed); + + mocks.Connection.Verify(c=>c.AbortMessage()); + } + + /// + /// The Data_NoCurrentMessage_ReturnsError + /// + /// A representing the async operation + [Fact] + public async Task Data_NoCurrentMessage_ReturnsError() + { + TestMocks mocks = new TestMocks(); + + DataVerb verb = new DataVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("DATA")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.BadSequenceOfCommands); + } + + /// + /// The Data_WithinSizeLimit_Accepted + /// + /// A representing the async operation + [Fact] + public async Task Data_WithinSizeLimit_Accepted() + { + TestMocks mocks = new TestMocks(); + + MemoryMessageBuilder messageBuilder = new MemoryMessageBuilder(); + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(messageBuilder); + mocks.ServerBehaviour.Setup(b => b.GetMaximumMessageSize(It.IsAny())).ReturnsAsync(10); + + string[] messageData = { new('x', 9), "." }; + int messageLine = 0; + mocks.Connection.Setup(c => c.ReadLineBytes()) + .Returns(() => Task.FromResult(Encoding.UTF8.GetBytes(messageData[messageLine++]))); + + DataVerb verb = new DataVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("DATA")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.StartMailInputEndWithDot); + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + + mocks.Connection.Verify(c=>c.CommitMessage()); + } + + /// + /// + /// The messageData + /// The expectedData + /// A representing the async operation + private async Task TestGoodDataAsync(string[] messageData, string expectedData) + { + TestMocks mocks = new TestMocks(); + + MemoryMessageBuilder messageBuilder = new MemoryMessageBuilder(); + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(messageBuilder); + mocks.ServerBehaviour.Setup(b => b.GetMaximumMessageSize(It.IsAny())) + .ReturnsAsync((long?)null); + + int messageLine = 0; + mocks.Connection.Setup(c => c.ReadLineBytes()) + .Returns(() => Task.FromResult(Encoding.UTF8.GetBytes(messageData[messageLine++]))); + + DataVerb verb = new DataVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("DATA")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.StartMailInputEndWithDot); + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + + + mocks.Connection.Verify(c=>c.CommitMessage()); + + using StreamReader dataReader = + new StreamReader(await messageBuilder.GetData(), Encoding.UTF8); + Assert.Equal(expectedData, dataReader.ReadToEnd()); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/EhloVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/EhloVerbTests.cs new file mode 100644 index 000000000..5411dcac2 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/EhloVerbTests.cs @@ -0,0 +1,114 @@ +// +// 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.Threading.Tasks; +using Moq; +using Rnwood.SmtpServer.Extensions; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class EhloVerbTests +{ + /// + /// The Process_NoArguments_Accepted + /// + /// A representing the async operation + [Fact] + public async Task Process_NoArguments_Accepted() + { + TestMocks mocks = new TestMocks(); + EhloVerb ehloVerb = new EhloVerb(); + await ehloVerb.Process(mocks.Connection.Object, new SmtpCommand("EHLO")); + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + + mocks.Session.VerifySet(s => s.ClientName = ""); + } + + /// + /// The Process_RecordsClientName + /// + /// A representing the async operation + [Fact] + public async Task Process_RecordsClientName() + { + TestMocks mocks = new TestMocks(); + EhloVerb ehloVerb = new EhloVerb(); + await ehloVerb.Process(mocks.Connection.Object, new SmtpCommand("EHLO foobar")); + + mocks.Session.VerifySet(s => s.ClientName = "foobar"); + } + + /// + /// The Process_RespondsWith250 + /// + /// A representing the async operation + [Fact] + public async Task Process_RespondsWith250() + { + TestMocks mocks = new TestMocks(); + Mock mockExtensionProcessor1 = new Mock(); + mockExtensionProcessor1.Setup(ep => ep.GetEHLOKeywords()).ReturnsAsync(new[] { "EXTN1" }); + Mock mockExtensionProcessor2 = new Mock(); + mockExtensionProcessor2.Setup(ep => ep.GetEHLOKeywords()).ReturnsAsync(new[] { "EXTN2A", "EXTN2B" }); + + mocks.Connection.SetupGet(c => c.ExtensionProcessors).Returns(new[] + { + mockExtensionProcessor1.Object, mockExtensionProcessor2.Object + }); + + EhloVerb ehloVerb = new EhloVerb(); + await ehloVerb.Process(mocks.Connection.Object, new SmtpCommand("EHLO foobar")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + } + + /// + /// The Process_RespondsWithExtensionKeywords + /// + /// A representing the async operation + [Fact] + public async Task Process_RespondsWithExtensionKeywords() + { + TestMocks mocks = new TestMocks(); + Mock mockExtensionProcessor1 = new Mock(); + mockExtensionProcessor1.Setup(ep => ep.GetEHLOKeywords()).ReturnsAsync(new[] { "EXTN1" }); + Mock mockExtensionProcessor2 = new Mock(); + mockExtensionProcessor2.Setup(ep => ep.GetEHLOKeywords()).ReturnsAsync(new[] { "EXTN2A", "EXTN2B" }); + + mocks.Connection.SetupGet(c => c.ExtensionProcessors).Returns(new[] + { + mockExtensionProcessor1.Object, mockExtensionProcessor2.Object + }); + + EhloVerb ehloVerb = new EhloVerb(); + await ehloVerb.Process(mocks.Connection.Object, new SmtpCommand("EHLO foobar")); + + mocks.Connection.Verify(c => c.WriteResponse(It.Is(r => + r.Message.Contains("EXTN1") && + r.Message.Contains("EXTN2A") && + r.Message.Contains("EXTN2B") + ))); + } + + /// + /// The Process_SaidHeloAlready_Allowed + /// + /// A representing the async operation + [Fact] + public async Task Process_SaidHeloAlready_Allowed() + { + TestMocks mocks = new TestMocks(); + + + EhloVerb verb = new EhloVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("EHLO foo.blah")); + await verb.Process(mocks.Connection.Object, new SmtpCommand("EHLO foo.blah")); + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK, Times.Exactly(2)); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/HeloVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/HeloVerbTests.cs new file mode 100644 index 000000000..f8eefeb17 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/HeloVerbTests.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 System.Threading.Tasks; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class HeloVerbTests +{ + /// + /// + /// A representing the async operation + [Fact] + public async Task SayHelo() + { + TestMocks mocks = new TestMocks(); + + HeloVerb verb = new HeloVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("HELO foo.blah")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + mocks.Session.VerifySet(s => s.ClientName = "foo.blah"); + } + + /// + /// The SayHelo_NoName + /// + /// A representing the async operation + [Fact] + public async Task SayHelo_NoName() + { + TestMocks mocks = new TestMocks(); + + HeloVerb verb = new HeloVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("HELO")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + mocks.Session.VerifySet(s => s.ClientName = ""); + } + + /// + /// The SayHeloTwice_ReturnsError + /// + /// A representing the async operation + [Fact] + public async Task SayHeloTwice_ReturnsError() + { + TestMocks mocks = new TestMocks(); + mocks.Session.SetupGet(s => s.ClientName).Returns("already.said.helo"); + + HeloVerb verb = new HeloVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("HELO foo.blah")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.BadSequenceOfCommands); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/MailFromVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/MailFromVerbTests.cs new file mode 100644 index 000000000..af3be3037 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/MailFromVerbTests.cs @@ -0,0 +1,126 @@ +// +// 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.Threading.Tasks; +using Moq; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class MailFromVerbTests +{ + /// + /// The Process_Address_Bracketed + /// + /// A representing the async operation + [Fact] + public async Task Process_Address_Bracketed() => + await Process_AddressAsync("", "rob@rnwood.co.uk", StandardSmtpResponseCode.OK) + ; + + /// + /// The Process_Address_BracketedWithName + /// + /// A representing the async operation + [Fact] + public async Task Process_Address_BracketedWithName() => + await Process_AddressAsync(">", "Robert Wood ", + StandardSmtpResponseCode.OK); + + /// + /// The Process_Address_NonAsciiChars_Rejected + /// + /// A representing the async operation + [Fact] + public async Task Process_NonAsciiChars_SmtpUtf8_Accepted() => + await Process_AddressAsync("<ظػؿقط >", "ظػؿقط ", + StandardSmtpResponseCode.OK, eightBitMessage: true); + + /// + /// The Process_Address_Plain + /// + /// A representing the async operation + [Fact] + public async Task Process_Address_Plain() => + await Process_AddressAsync("rob@rnwood.co.uk", "rob@rnwood.co.uk", StandardSmtpResponseCode.OK) + ; + + /// + /// The Process_AlreadyGivenFrom_ErrorResponse + /// + /// A representing the async operation + [Fact] + public async Task Process_AlreadyGivenFrom_ErrorResponse() + { + TestMocks mocks = new TestMocks(); + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(new Mock().Object); + + MailFromVerb mailFromVerb = new MailFromVerb(); + await mailFromVerb.Process(mocks.Connection.Object, new SmtpCommand("FROM ")) + ; + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.BadSequenceOfCommands); + } + + /// + /// The Process_MissingAddress_ErrorResponse + /// + /// A representing the async operation + [Fact] + public async Task Process_MissingAddress_ErrorResponse() + { + TestMocks mocks = new TestMocks(); + + MailFromVerb mailFromVerb = new MailFromVerb(); + await mailFromVerb.Process(mocks.Connection.Object, new SmtpCommand("FROM")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.SyntaxErrorInCommandArguments); + } + + /// + /// The Process_AddressAsync + /// + /// The address + /// The expectedParsedAddress + /// The expectedResponse + /// A representing the async operation + private async Task Process_AddressAsync(string address, string expectedParsedAddress, + StandardSmtpResponseCode expectedResponse, bool asException = false, bool eightBitMessage = false) + { + TestMocks mocks = new TestMocks(); + Mock message = new Mock(); + message.SetupGet(m => m.EightBitTransport).Returns(eightBitMessage); + IMessageBuilder currentMessage = null; + mocks.Connection.Setup(c => c.NewMessage()).ReturnsAsync(() => + { + currentMessage = message.Object; + return currentMessage; + }); + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(() => currentMessage); + + MailFromVerb mailFromVerb = new MailFromVerb(); + + if (!asException) + { + await mailFromVerb.Process(mocks.Connection.Object, new SmtpCommand("FROM " + address)) + ; + mocks.VerifyWriteResponse(expectedResponse); + } + else + { + SmtpServerException e = await Assert.ThrowsAsync(() => + mailFromVerb.Process(mocks.Connection.Object, new SmtpCommand("FROM " + address))); + Assert.Equal((int)expectedResponse, e.SmtpResponse.Code); + } + + if (expectedParsedAddress != null) + { + message.VerifySet(m => m.From = expectedParsedAddress); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/NoopVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/NoopVerbTests.cs new file mode 100644 index 000000000..584eaba4d --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/NoopVerbTests.cs @@ -0,0 +1,30 @@ +// +// 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.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class NoopVerbTests +{ + /// + /// + /// A representing the async operation + [Fact] + public async Task Noop() + { + TestMocks mocks = new TestMocks(); + + NoopVerb verb = new NoopVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("NOOP")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/QuitVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/QuitVerbTests.cs new file mode 100644 index 000000000..8c68ab565 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/QuitVerbTests.cs @@ -0,0 +1,30 @@ +// +// 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.Threading.Tasks; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class QuitVerbTests +{ + /// + /// The Quit_RespondsWithClosingChannel + /// + /// A representing the async operation + [Fact] + public async Task Quit_RespondsWithClosingChannel() + { + TestMocks mocks = new TestMocks(); + + QuitVerb quitVerb = new QuitVerb(); + await quitVerb.Process(mocks.Connection.Object, new SmtpCommand("QUIT")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.ClosingTransmissionChannel); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/RcptToVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/RcptToVerbTests.cs new file mode 100644 index 000000000..a2a32d3df --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/RcptToVerbTests.cs @@ -0,0 +1,112 @@ +// +// 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 System.Threading.Tasks; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class RcptToVerbTests +{ + /// + /// + /// A representing the async operation + [Fact] + public async Task EmailAddressOnly() => + await TestGoodAddressAsync("", "rob@rnwood.co.uk"); + + /// + /// + /// A representing the async operation + [Fact] + public async Task EmailAddressWithDisplayName() => + //Should this format be accepted???? + await TestGoodAddressAsync(">", "Robert Wood") + ; + + /// + /// The EmptyAddress_ReturnsError + /// + /// A representing the async operation + [Fact] + public async Task EmptyAddress_ReturnsError() => await TestBadAddressAsync("<>"); + + /// + /// The MismatchedBraket_ReturnsError + /// + /// A representing the async operation + [Fact] + public async Task MismatchedBraket_ReturnsError() + { + await TestBadAddressAsync(""); + } + + /// + /// The UnbraketedAddress_ReturnsError + /// + /// A representing the async operation + [Fact] + public async Task UnbraketedAddress_ReturnsError() => + await TestBadAddressAsync("rob@rnwood.co.uk"); + + + [Fact] + public async Task NonAsciiAddress_SmtpUtf8_Accepted() => + await TestGoodAddressAsync("<ظػؿقط >", "ظػؿقط ", true) + ; + + + /// + /// + /// The address + /// A representing the async operation + private async Task TestBadAddressAsync(string address, bool asException = false) + { + TestMocks mocks = new TestMocks(); + MemoryMessageBuilder messageBuilder = new MemoryMessageBuilder(); + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(messageBuilder); + + RcptToVerb verb = new RcptToVerb(); + + if (!asException) + { + await verb.Process(mocks.Connection.Object, new SmtpCommand("TO " + address)); + mocks.VerifyWriteResponse(StandardSmtpResponseCode.SyntaxErrorInCommandArguments); + } + else + { + SmtpServerException e = await Assert + .ThrowsAsync(() => + verb.Process(mocks.Connection.Object, new SmtpCommand("TO " + address))); + Assert.Equal((int)StandardSmtpResponseCode.SyntaxErrorInCommandArguments, e.SmtpResponse.Code); + } + + Assert.Equal(0, messageBuilder.Recipients.Count); + } + + /// + /// + /// The address + /// The expectedAddress + /// A representing the async operation + private async Task TestGoodAddressAsync(string address, string expectedAddress, bool eightBit = false) + { + TestMocks mocks = new TestMocks(); + MemoryMessageBuilder messageBuilder = new MemoryMessageBuilder(); + messageBuilder.EightBitTransport = eightBit; + mocks.Connection.SetupGet(c => c.CurrentMessage).Returns(messageBuilder); + + RcptToVerb verb = new RcptToVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("TO " + address)); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + Assert.Equal(expectedAddress, messageBuilder.Recipients.First()); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/RsetVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/RsetVerbTests.cs new file mode 100644 index 000000000..fc737fef7 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/RsetVerbTests.cs @@ -0,0 +1,34 @@ +// +// 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.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class RsetVerbTests +{ + /// + /// + /// A representing the async operation + [Fact] + public async Task ProcessAsync() + { + TestMocks mocks = new TestMocks(); + + mocks.Connection.Setup(c => c.AbortMessage()).Returns(Task.CompletedTask).Verifiable(); + + RsetVerb verb = new RsetVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("RSET")); + + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.OK); + mocks.Connection.Verify(); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/StartTlsVerbTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/StartTlsVerbTests.cs new file mode 100644 index 000000000..abae49b67 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/StartTlsVerbTests.cs @@ -0,0 +1,35 @@ +// +// 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.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Moq; +using Rnwood.SmtpServer.Extensions; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class StartTlsVerbTests +{ + /// + /// The NoCertificateAvailable_ReturnsErrorResponse + /// + /// A representing the async operation + [Fact] + public async Task NoCertificateAvailable_ReturnsErrorResponse() + { + TestMocks mocks = new TestMocks(); + mocks.ServerBehaviour.Setup(b => b.GetSSLCertificate(It.IsAny())) + .ReturnsAsync((X509Certificate)null); + + StartTlsVerb verb = new StartTlsVerb(); + await verb.Process(mocks.Connection.Object, new SmtpCommand("STARTTLS")); + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.CommandNotImplemented); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/VerbMapTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/VerbMapTests.cs new file mode 100644 index 000000000..2431f598c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/VerbMapTests.cs @@ -0,0 +1,101 @@ +// +// 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 Moq; +using Rnwood.SmtpServer.Verbs; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class VerbMapTests +{ + /// + /// The GetVerbProcessor_NoRegisteredVerb_ReturnsNull + /// + [Fact] + public void GetVerbProcessor_NoRegisteredVerb_ReturnsNull() + { + VerbMap verbMap = new VerbMap(); + + Assert.Null(verbMap.GetVerbProcessor("VERB")); + } + + /// + /// The GetVerbProcessor_RegisteredVerb_ReturnsVerb + /// + [Fact] + public void GetVerbProcessor_RegisteredVerb_ReturnsVerb() + { + VerbMap verbMap = new VerbMap(); + Mock verbMock = new Mock(); + + verbMap.SetVerbProcessor("verb", verbMock.Object); + + Assert.Same(verbMock.Object, verbMap.GetVerbProcessor("verb")); + } + + /// + /// The GetVerbProcessor_RegisteredVerbWithDifferentCase_ReturnsVerb + /// + [Fact] + public void GetVerbProcessor_RegisteredVerbWithDifferentCase_ReturnsVerb() + { + VerbMap verbMap = new VerbMap(); + Mock verbMock = new Mock(); + + verbMap.SetVerbProcessor("vErB", verbMock.Object); + + Assert.Same(verbMock.Object, verbMap.GetVerbProcessor("VERB")); + } + + /// + /// The SetVerbProcessor_RegisteredVerbAgain_UpdatesRegistration + /// + [Fact] + public void SetVerbProcessor_RegisteredVerbAgain_UpdatesRegistration() + { + VerbMap verbMap = new VerbMap(); + Mock verbMock1 = new Mock(); + Mock verbMock2 = new Mock(); + verbMap.SetVerbProcessor("verb", verbMock1.Object); + + verbMap.SetVerbProcessor("veRb", verbMock2.Object); + + Assert.Same(verbMock2.Object, verbMap.GetVerbProcessor("verb")); + } + + /// + /// The SetVerbProcessor_RegisteredVerbAgainDifferentCaseWithNull_ClearsRegistration + /// + [Fact] + public void SetVerbProcessor_RegisteredVerbAgainDifferentCaseWithNull_ClearsRegistration() + { + VerbMap verbMap = new VerbMap(); + Mock verbMock = new Mock(); + verbMap.SetVerbProcessor("verb", verbMock.Object); + + verbMap.SetVerbProcessor("vErb", null); + + Assert.Null(verbMap.GetVerbProcessor("verb")); + } + + /// + /// The SetVerbProcessor_RegisteredVerbAgainWithNull_ClearsRegistration + /// + [Fact] + public void SetVerbProcessor_RegisteredVerbAgainWithNull_ClearsRegistration() + { + VerbMap verbMap = new VerbMap(); + Mock verbMock = new Mock(); + verbMap.SetVerbProcessor("verb", verbMock.Object); + + verbMap.SetVerbProcessor("verb", null); + + Assert.Null(verbMap.GetVerbProcessor("verb")); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.Tests/Verbs/VerbWithSubCommandsTests.cs b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/VerbWithSubCommandsTests.cs new file mode 100644 index 000000000..ce5fd0ed1 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.Tests/Verbs/VerbWithSubCommandsTests.cs @@ -0,0 +1,53 @@ +// +// 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.Threading.Tasks; +using Moq; +using Rnwood.SmtpServer.Verbs; +using Xunit; + +namespace Rnwood.SmtpServer.Tests.Verbs; + +/// +/// Defines the +/// +public class VerbWithSubCommandsTests +{ + /// + /// The ProcessAsync_RegisteredSubCommand_Processed + /// + /// A representing the async operation + [Fact] + public async Task ProcessAsync_RegisteredSubCommand_Processed() + { + TestMocks mocks = new TestMocks(); + + Mock verbWithSubCommands = new Mock { CallBase = true }; + Mock verb = new Mock(); + verbWithSubCommands.Object.SubVerbMap.SetVerbProcessor("SUBCOMMAND1", verb.Object); + + await verbWithSubCommands.Object.Process(mocks.Connection.Object, new SmtpCommand("VERB SUBCOMMAND1")) + ; + + verb.Verify(v => v.Process(mocks.Connection.Object, new SmtpCommand("SUBCOMMAND1"))); + } + + /// + /// The ProcessAsync_UnregisteredSubCommand_ErrorResponse + /// + /// A representing the async operation + [Fact] + public async Task ProcessAsync_UnregisteredSubCommand_ErrorResponse() + { + TestMocks mocks = new TestMocks(); + + Mock verbWithSubCommands = new Mock { CallBase = true }; + + await verbWithSubCommands.Object.Process(mocks.Connection.Object, new SmtpCommand("VERB SUBCOMMAND1")) + ; + + mocks.VerifyWriteResponse(StandardSmtpResponseCode.CommandParameterNotImplemented); + } +} diff --git a/smtpserver/Rnwood.SmtpServer.sln b/smtpserver/Rnwood.SmtpServer.sln new file mode 100644 index 000000000..18a7f1f13 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.28803.352 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rnwood.SmtpServer", "Rnwood.SmtpServer\Rnwood.SmtpServer.csproj", "{C376BE9A-B775-4C75-8AFC-267C7B28817F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rnwood.SmtpServer.Tests", "Rnwood.SmtpServer.Tests\Rnwood.SmtpServer.Tests.csproj", "{86D80D1D-732D-4BBD-8CE7-CD185E24C91C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{71E2C199-A23D-4024-8165-06D4A71982F8}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C376BE9A-B775-4C75-8AFC-267C7B28817F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C376BE9A-B775-4C75-8AFC-267C7B28817F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C376BE9A-B775-4C75-8AFC-267C7B28817F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C376BE9A-B775-4C75-8AFC-267C7B28817F}.Release|Any CPU.Build.0 = Release|Any CPU + {86D80D1D-732D-4BBD-8CE7-CD185E24C91C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86D80D1D-732D-4BBD-8CE7-CD185E24C91C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86D80D1D-732D-4BBD-8CE7-CD185E24C91C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86D80D1D-732D-4BBD-8CE7-CD185E24C91C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {890DDC84-2D51-4854-A468-1668639F5721} + EndGlobalSection +EndGlobal diff --git a/smtpserver/Rnwood.SmtpServer.sln.DotSettings b/smtpserver/Rnwood.SmtpServer.sln.DotSettings new file mode 100644 index 000000000..ace03b0c9 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer.sln.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/smtpserver/Rnwood.SmtpServer/ASCIISevenBitTruncatingEncoding.cs b/smtpserver/Rnwood.SmtpServer/ASCIISevenBitTruncatingEncoding.cs new file mode 100644 index 000000000..55b81d1aa --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ASCIISevenBitTruncatingEncoding.cs @@ -0,0 +1,208 @@ +// +// 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 System.Text; + +namespace Rnwood.SmtpServer; + +/// +/// An ASCII encoding where the highest order bit is zeroed. +/// +/// +public class AsciiSevenBitTruncatingEncoding : Encoding +{ + private readonly Encoding asciiEncoding; + + /// + /// Initializes a new instance of the class. + /// + public AsciiSevenBitTruncatingEncoding() => + asciiEncoding = GetEncoding("ASCII", new EncodingFallback(), new DecodingFallback()); + + /// + /// When overridden in a derived class, calculates the number of bytes produced by encoding a set of characters from + /// the specified character array. + /// + /// The character array containing the set of characters to encode. + /// The index of the first character to encode. + /// The number of characters to encode. + /// + /// The number of bytes produced by encoding the specified characters. + /// + public override int GetByteCount(char[] chars, int index, int count) => + asciiEncoding.GetByteCount(chars, index, count); + + /// + /// When overridden in a derived class, encodes a set of characters from the specified character array into the + /// specified byte array. + /// + /// The character array containing the set of characters to encode. + /// The index of the first character to encode. + /// The number of characters to encode. + /// The byte array to contain the resulting sequence of bytes. + /// The index at which to start writing the resulting sequence of bytes. + /// + /// The actual number of bytes written into bytes. + /// + public override int GetBytes(char[] chars, int charIndex, int charCount, byte[] bytes, int byteIndex) => + asciiEncoding.GetBytes(chars, charIndex, charCount, bytes, byteIndex); + + /// + /// When overridden in a derived class, calculates the number of characters produced by decoding a sequence of bytes + /// from the specified byte array. + /// + /// The byte array containing the sequence of bytes to decode. + /// The index of the first byte to decode. + /// The number of bytes to decode. + /// + /// The number of characters produced by decoding the specified sequence of bytes. + /// + public override int GetCharCount(byte[] bytes, int index, int count) => + asciiEncoding.GetCharCount(bytes, index, count); + + /// + /// When overridden in a derived class, decodes a sequence of bytes from the specified byte array into the specified + /// character array. + /// + /// The byte array containing the sequence of bytes to decode. + /// The index of the first byte to decode. + /// The number of bytes to decode. + /// The character array to contain the resulting set of characters. + /// The index at which to start writing the resulting set of characters. + /// + /// The actual number of characters written into chars. + /// + public override int GetChars(byte[] bytes, int byteIndex, int byteCount, char[] chars, int charIndex) => + asciiEncoding.GetChars(bytes, byteIndex, byteCount, chars, charIndex); + + /// + /// When overridden in a derived class, calculates the maximum number of bytes produced by encoding the specified + /// number of characters. + /// + /// The number of characters to encode. + /// + /// The maximum number of bytes produced by encoding the specified number of characters. + /// + public override int GetMaxByteCount(int charCount) => asciiEncoding.GetMaxByteCount(charCount); + + /// + /// When overridden in a derived class, calculates the maximum number of characters produced by decoding the specified + /// number of bytes. + /// + /// The number of bytes to decode. + /// + /// The maximum number of characters produced by decoding the specified number of bytes. + /// + public override int GetMaxCharCount(int byteCount) => asciiEncoding.GetMaxCharCount(byteCount); + + private sealed class DecodingFallback : DecoderFallback + { + /// + /// Gets the maximum number of characters the current object can return. + /// + public override int MaxCharCount => 1; + + /// + /// Initializes a new instance of the class. + /// + /// + /// An object that provides a fallback buffer for a decoder. + /// + public override DecoderFallbackBuffer CreateFallbackBuffer() => new Buffer(); + + private sealed class Buffer : DecoderFallbackBuffer + { + private int fallbackIndex; + + private string fallbackString; + + public override int Remaining => fallbackString.Length - fallbackIndex; + + public override bool Fallback(byte[] bytesUnknown, int index) + { + fallbackString = ASCII.GetString(bytesUnknown.Select(b => (byte)(b & 127)).ToArray()); + fallbackIndex = 0; + + return true; + } + + public override char GetNextChar() + { + if (Remaining > 0) + { + return fallbackString[fallbackIndex++]; + } + + return '\0'; + } + + public override bool MovePrevious() + { + if (fallbackIndex > 0) + { + fallbackIndex--; + return true; + } + + return false; + } + } + } + + private sealed class EncodingFallback : EncoderFallback + { + public override int MaxCharCount => 1; + + public override EncoderFallbackBuffer CreateFallbackBuffer() => new Buffer(); + + private sealed class Buffer : EncoderFallbackBuffer + { + private char @char; + + private bool charRead; + + public override int Remaining => !charRead ? 1 : 0; + + public override bool Fallback(char charUnknown, int index) + { + @char = FallbackChar(charUnknown); + charRead = false; + return true; + } + + public override bool Fallback(char charUnknownHigh, char charUnknownLow, int index) + { + @char = FallbackChar(charUnknownLow); + charRead = false; + return true; + } + + public override char GetNextChar() + { + if (!charRead) + { + charRead = true; + return @char; + } + + return '\0'; + } + + public override bool MovePrevious() + { + if (charRead) + { + charRead = false; + return true; + } + + return false; + } + + private static char FallbackChar(char charUnknown) => (char)(charUnknown & 127); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/AbstractSession.cs b/smtpserver/Rnwood.SmtpServer/AbstractSession.cs new file mode 100644 index 000000000..715e71e41 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/AbstractSession.cs @@ -0,0 +1,116 @@ +// +// 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.IO; +using System.Net; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Extensions.Auth; + +namespace Rnwood.SmtpServer; + +/// +/// Provides a base implementation for . +/// +/// +public abstract class AbstractSession : IEditableSession +{ + private readonly List messages; + + /// + /// Initializes a new instance of the class. + /// + /// The client address. + /// The start date. + protected AbstractSession(IPAddress clientAddress, DateTime startDate) + { + messages = new List(); + ClientAddress = clientAddress; + StartDate = startDate; + } + + /// + public virtual bool Authenticated { get; set; } + + /// + public virtual IAuthenticationCredentials AuthenticationCredentials { get; set; } + + /// + public virtual IPAddress ClientAddress { get; set; } + + /// + public virtual string ClientName { get; set; } + + /// + public virtual bool CompletedNormally { get; set; } + + /// + public virtual DateTime? EndDate { get; set; } + + /// + public virtual bool SecureConnection { get; set; } + + /// + public virtual Exception SessionError { get; set; } + + /// + public virtual SessionErrorType SessionErrorType { get; set; } + + /// + public virtual DateTime StartDate { get; set; } + + /// + public virtual Task AddMessage(IMessage message) + { + messages.Add(message); + return Task.CompletedTask; + } + + /// + public abstract Task AppendLineToSessionLog(string text); + + /// + public virtual Task IncrementBadCommandCounter() + { + NumberOfBadCommandsInARow++; + return Task.CompletedTask; + } + + /// + public virtual Task ResetBadCommandCounter() + { + NumberOfBadCommandsInARow = 0; + return Task.CompletedTask; + } + + /// + public abstract Task GetLog(); + + /// + public virtual Task> GetMessages() => + Task.FromResult>(messages.AsReadOnly()); + + /// + public int NumberOfBadCommandsInARow { get; protected set; } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// + /// true to release both managed and unmanaged resources; false to release only + /// unmanaged resources. + /// + protected abstract void Dispose(bool disposing); +} diff --git a/smtpserver/Rnwood.SmtpServer/ArgumentsParser.cs b/smtpserver/Rnwood.SmtpServer/ArgumentsParser.cs new file mode 100644 index 000000000..47ae0b3c5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ArgumentsParser.cs @@ -0,0 +1,84 @@ +// +// 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.Collections.Generic; +using System.Text; + +namespace Rnwood.SmtpServer; + +/// +/// Parses SMTP command arguments into an array of arguments. +/// Arguments are separated by spaces or are enclosed within <>s which may contain spaces and balanced <>s. +/// Example: +/// <Robert Wood<rob@rnwood.co.uk>> ARG2 ARG3 +/// Results in 3 arguments. +/// +public class ArgumentsParser +{ + /// + /// Initializes a new instance of the class. + /// + /// The text to parse. + public ArgumentsParser(string text) + { + Text = text; + Arguments = ParseArguments(text); + } + + /// + /// Gets the arguments parsed from the text. + /// + /// + /// The arguments. + /// + public IReadOnlyCollection Arguments { get; private set; } + + /// + /// Gets the Text which was parsed. + /// + public string Text { get; private set; } + + private static string[] ParseArguments(string argumentsText) + { + int ltCount = 0; + List arguments = new List(); + StringBuilder currentArgument = new StringBuilder(); + foreach (char character in argumentsText) + { + switch (character) + { + case '<': + ltCount++; + goto default; + case '>': + ltCount--; + goto default; + case ' ': + if (ltCount == 0) + { + arguments.Add(currentArgument.ToString()); + currentArgument = new StringBuilder(); + } + else + { + goto default; + } + + break; + + default: + currentArgument.Append(character); + break; + } + } + + if (currentArgument.Length != 0) + { + arguments.Add(currentArgument.ToString()); + } + + return arguments.ToArray(); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/AsyncEventHandler.cs b/smtpserver/Rnwood.SmtpServer/AsyncEventHandler.cs new file mode 100644 index 000000000..3e0ac612d --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/AsyncEventHandler.cs @@ -0,0 +1,20 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Represents an async event handler which accepts an parameter and a +/// parameter and returns a . +/// +/// The type of the second param. +/// The sender. +/// The e. +/// A task representing the async operation. +public delegate Task AsyncEventHandler(object sender, T e) + where T : EventArgs; diff --git a/smtpserver/Rnwood.SmtpServer/AuthenticationCredentialsValidationEventArgs.cs b/smtpserver/Rnwood.SmtpServer/AuthenticationCredentialsValidationEventArgs.cs new file mode 100644 index 000000000..598b263b8 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/AuthenticationCredentialsValidationEventArgs.cs @@ -0,0 +1,41 @@ +// +// 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 Rnwood.SmtpServer.Extensions.Auth; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class AuthenticationCredentialsValidationEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The session + /// The credentials. + public AuthenticationCredentialsValidationEventArgs(ISession session, IAuthenticationCredentials credentials) + { + Credentials = credentials; + Session = session; + } + + /// + /// Gets the session + /// + public ISession Session { get; private set; } + + /// + /// Gets or sets the AuthenticationResult. + /// + public AuthenticationResult AuthenticationResult { get; set; } + + /// + /// Gets the Credentials. + /// + public IAuthenticationCredentials Credentials { get; private set; } +} diff --git a/smtpserver/Rnwood.SmtpServer/BadBase64Exception.cs b/smtpserver/Rnwood.SmtpServer/BadBase64Exception.cs new file mode 100644 index 000000000..979a7a664 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/BadBase64Exception.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 System; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class BadBase64Exception : SmtpServerException +{ + /// + /// Initializes a new instance of the class. + /// + /// The smtpResponse. + public BadBase64Exception(SmtpResponse smtpResponse) + : base(smtpResponse) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The smtpResponse. + /// The innerException. + public BadBase64Exception(SmtpResponse smtpResponse, Exception innerException) + : base(smtpResponse, innerException) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public BadBase64Exception(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + public BadBase64Exception() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// + /// The exception that is the cause of the current exception, or a null reference (Nothing in + /// Visual Basic) if no inner exception is specified. + /// + public BadBase64Exception(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/smtpserver/Rnwood.SmtpServer/CloseNotifyingMemoryStream.cs b/smtpserver/Rnwood.SmtpServer/CloseNotifyingMemoryStream.cs new file mode 100644 index 000000000..5f98e0266 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/CloseNotifyingMemoryStream.cs @@ -0,0 +1,35 @@ +// +// 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.IO; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the which is a memory stream that fires an event when disposed. +/// +internal class CloseNotifyingMemoryStream : MemoryStream +{ + /// + /// Occurs when the stream is disposed. + /// + public event EventHandler Closing; + + /// + /// Releases the unmanaged resources used by the class and optionally + /// releases the managed resources. + /// + /// The disposing. + protected override void Dispose(bool disposing) + { + if (disposing) + { + Closing?.Invoke(this, EventArgs.Empty); + } + + base.Dispose(disposing); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/CommandEventArgs.cs b/smtpserver/Rnwood.SmtpServer/CommandEventArgs.cs new file mode 100644 index 000000000..41a6a9b22 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/CommandEventArgs.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 System; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class CommandEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The command. + public CommandEventArgs(SmtpCommand command) => Command = command; + + /// + /// Gets the Command. + /// + public SmtpCommand Command { get; private set; } +} diff --git a/smtpserver/Rnwood.SmtpServer/Connection.cs b/smtpserver/Rnwood.SmtpServer/Connection.cs new file mode 100644 index 000000000..21c9c8d8c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Connection.cs @@ -0,0 +1,325 @@ +// +// 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.Globalization; +using System.IO; +using System.Linq; +using System.Net.Security; +using System.Reflection; +using System.Runtime.Versioning; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Extensions; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Represents a single SMTP server from a client to the server. +/// +public class Connection : IConnection +{ + private readonly string id; + + /// + /// Initializes a new instance of the class. + /// + /// The server. + /// The session. + /// The connection channel. + /// The verb map. + /// The extension processors. + internal Connection(ISmtpServer server, IEditableSession session, IConnectionChannel connectionChannel, + IVerbMap verbMap, Func extensionProcessors) + { + id = $"[RemoteIP={connectionChannel.ClientIPAddress}]"; + + ConnectionChannel = connectionChannel; + ConnectionChannel.ClosedEventHandler += OnConnectionChannelClosed; + + VerbMap = verbMap; + Session = session; + Server = server; + ExtensionProcessors = extensionProcessors(this).ToArray(); + } + + private IConnectionChannel ConnectionChannel { get; } + + /// + public event AsyncEventHandler ConnectionClosedEventHandler; + + /// + public IMessageBuilder CurrentMessage { get; private set; } + + /// + public MailVerb MailVerb => (MailVerb)VerbMap.GetVerbProcessor("MAIL"); + + /// + public ISmtpServer Server { get; } + + /// + public IEditableSession Session { get; } + + /// + public IVerbMap VerbMap { get; } + + /// + /// Gets a list of extensions which are available for this connection. + /// + public IReadOnlyCollection ExtensionProcessors { get; } + + /// + public Task AbortMessage() + { + CurrentMessage = null; + return Task.CompletedTask; + } + + /// + public async Task ApplyStreamFilter(Func> filter) => + await ConnectionChannel.ApplyStreamFilter(filter).ConfigureAwait(false); + + /// + public async Task CloseConnection() => await ConnectionChannel.Close().ConfigureAwait(false); + + /// + public async Task CommitMessage() + { + IMessage message = await CurrentMessage.ToMessage().ConfigureAwait(false); + await Session.AddMessage(message).ConfigureAwait(false); + CurrentMessage = null; + + await Server.Behaviour.OnMessageReceived(this, message).ConfigureAwait(false); + } + + /// + public async Task NewMessage() + { + CurrentMessage = await Server.Behaviour.OnCreateNewMessage(this).ConfigureAwait(false); + CurrentMessage.Session = Session; + return CurrentMessage; + } + + /// + public async Task ReadLine() + { + string text = await ConnectionChannel.ReadLine().ConfigureAwait(false); + await Session.AppendLineToSessionLog(text).ConfigureAwait(false); + return text; + } + + /// + public async Task WriteResponse(SmtpResponse response) => + await WriteLineAndFlush(response.ToString().TrimEnd()).ConfigureAwait(false); + + /// + public async Task ReadLineBytes() + { + byte[] data = await ConnectionChannel.ReadLineBytes(); + + await Session.AppendLineToSessionLog(Encoding.GetEncoding("ISO-8859-1").GetString(data)).ConfigureAwait(false); + + return data; + } + + /// + /// Returns a that represents this instance. + /// + /// + /// A that represents this instance. + /// + public override string ToString() => id; + + /// + /// Creates the a connection for the specified server and channel.. + /// + /// The server. + /// The connection channel. + /// The verb map. + /// An representing the async operation. + internal static async Task Create(ISmtpServer server, IConnectionChannel connectionChannel, + IVerbMap verbMap) + { + IEditableSession session = await server.Behaviour.OnCreateNewSession(connectionChannel).ConfigureAwait(false); + IEnumerable extensions = + await server.Behaviour.GetExtensions(connectionChannel).ConfigureAwait(false); + + IExtensionProcessor[] CreateConnectionExtensions(IConnection c) + { + return extensions.Select(e => e.CreateExtensionProcessor(c)).ToArray(); + } + + Connection result = new Connection(server, session, connectionChannel, verbMap, CreateConnectionExtensions); + return result; + } + + /// + /// Start the Tls stream. + /// + /// stream. + /// A representing the result of the asynchronous operation. + internal async Task StartImplicitTls(Stream s) + { + SslStream sslStream = new SslStream(s); + + SslProtocols sslProtos; + + string ver = Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkName; + if (ver == null || !ver.StartsWith(".NETCoreApp,")) + { + sslProtos = SslProtocols.Tls12 | SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Ssl3 | + SslProtocols.Ssl2; + } + else + { + sslProtos = SslProtocols.None; + } + + X509Certificate cert = + await Server.Behaviour.GetSSLCertificate(this).ConfigureAwait(false); + + await sslStream.AuthenticateAsServerAsync(cert, false, sslProtos, false).ConfigureAwait(false); + return sslStream; + } + + /// + /// Starts processing of this connection. + /// + /// A representing the async operation. + internal async Task ProcessAsync() + { + try + { + await Server.Behaviour.OnSessionStarted(this, Session).ConfigureAwait(false); + + if (await Server.Behaviour.IsSSLEnabled(this).ConfigureAwait(false)) + { + await ConnectionChannel.ApplyStreamFilter(StartImplicitTls).ConfigureAwait(false); + + Session.SecureConnection = true; + } + + await WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.ServiceReady, + Server.Behaviour.DomainName + " smtp4dev ready")).ConfigureAwait(false); + + while (ConnectionChannel.IsConnected) + { + await ReadAndProcessNextCommand().ConfigureAwait(false); + } + } + catch (IOException ioException) + { + Session.SessionError = ioException; + Session.SessionErrorType = SessionErrorType.NetworkError; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception exception) + { + Session.SessionError = exception; + Session.SessionErrorType = SessionErrorType.UnexpectedException; + } +#pragma warning restore CA1031 // Do not catch general exception types + + await CloseConnection().ConfigureAwait(false); + + Session.EndDate = DateTime.Now; + await Server.Behaviour.OnSessionCompleted(this, Session).ConfigureAwait(false); + } + + private async Task ReadAndProcessNextCommand() + { + bool badCommand = false; + SmtpCommand command = new SmtpCommand(await ReadLine().ConfigureAwait(false)); + await Server.Behaviour.OnCommandReceived(this, command).ConfigureAwait(false); + + if (command.IsValid) + { + badCommand = !await TryProcessCommand(command).ConfigureAwait(false); + } + else if (!command.IsEmpty) + { + badCommand = true; + } + + if (badCommand) + { + await Session.IncrementBadCommandCounter().ConfigureAwait(false); + + if (Server.Behaviour.MaximumNumberOfSequentialBadCommands > 0 && + Session.NumberOfBadCommandsInARow >= Server.Behaviour.MaximumNumberOfSequentialBadCommands) + { + await WriteResponse(new SmtpResponse(StandardSmtpResponseCode.ClosingTransmissionChannel, + "Too many bad commands. Bye!")).ConfigureAwait(false); + await CloseConnection().ConfigureAwait(false); + } + else + { + await WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.SyntaxErrorCommandUnrecognised, + "Command unrecognised")).ConfigureAwait(false); + } + } + else + { + await Session.ResetBadCommandCounter().ConfigureAwait(false); + } + } + + private async Task TryProcessCommand(SmtpCommand command) + { + IVerb verbProcessor = VerbMap.GetVerbProcessor(command.Verb); + + if (verbProcessor != null) + { + try + { + await verbProcessor.Process(this, command).ConfigureAwait(false); + } + catch (SmtpServerException exception) + { + await WriteResponse(exception.SmtpResponse).ConfigureAwait(false); + } + + return true; + } + + return false; + } + + /// + /// Writes a line of text to the client. + /// + /// + /// The text optionally containing placeholders into which + /// are subtituted using . + /// + /// The arguments which are formatted into . + /// + /// The . + /// + protected async Task WriteLineAndFlush(string text, params object[] args) + { + string formattedText = string.Format(CultureInfo.InvariantCulture, text, args); + await Session.AppendLineToSessionLog(formattedText).ConfigureAwait(false); + await ConnectionChannel.WriteLine(formattedText).ConfigureAwait(false); + await ConnectionChannel.Flush().ConfigureAwait(false); + } + + private async Task OnConnectionChannelClosed(object sender, EventArgs eventArgs) + { + ConnectionEventArgs connEventArgs = new ConnectionEventArgs(this); + + foreach (Delegate handler + in ConnectionClosedEventHandler?.GetInvocationList() ?? Enumerable.Empty()) + { + await ((Task)handler.DynamicInvoke(this, connEventArgs)).ConfigureAwait(false); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/ConnectionEventArgs.cs b/smtpserver/Rnwood.SmtpServer/ConnectionEventArgs.cs new file mode 100644 index 000000000..25c9475f7 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ConnectionEventArgs.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 System; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class ConnectionEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The connection. + public ConnectionEventArgs(IConnection connection) => Connection = connection; + + /// + /// Gets the Connection. + /// + public IConnection Connection { get; private set; } +} diff --git a/smtpserver/Rnwood.SmtpServer/ConnectionUnexpectedlyClosedException.cs b/smtpserver/Rnwood.SmtpServer/ConnectionUnexpectedlyClosedException.cs new file mode 100644 index 000000000..b6c8642ab --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ConnectionUnexpectedlyClosedException.cs @@ -0,0 +1,41 @@ +// +// 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.IO; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class ConnectionUnexpectedlyClosedException : IOException +{ + /// + /// Initializes a new instance of the class. + /// + public ConnectionUnexpectedlyClosedException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + public ConnectionUnexpectedlyClosedException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The innerException. + public ConnectionUnexpectedlyClosedException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/smtpserver/Rnwood.SmtpServer/CurrentDateTimeProvider.cs b/smtpserver/Rnwood.SmtpServer/CurrentDateTimeProvider.cs new file mode 100644 index 000000000..ce852b601 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/CurrentDateTimeProvider.cs @@ -0,0 +1,18 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Implements using the real local date time. +/// +/// +internal class CurrentDateTimeProvider : ICurrentDateTimeProvider +{ + /// + public DateTime GetCurrentDateTime() => DateTime.Now; +} diff --git a/smtpserver/Rnwood.SmtpServer/DataAccessMode.cs b/smtpserver/Rnwood.SmtpServer/DataAccessMode.cs new file mode 100644 index 000000000..8a91a8228 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/DataAccessMode.cs @@ -0,0 +1,22 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer; + +/// +/// Defines the DataAccessMode. +/// +public enum DataAccessMode +{ + /// + /// Defines the ForReading + /// + ForReading, + + /// + /// Defines the ForWriting + /// + ForWriting +} diff --git a/smtpserver/Rnwood.SmtpServer/DefaultServer.cs b/smtpserver/Rnwood.SmtpServer/DefaultServer.cs new file mode 100644 index 000000000..eff1839f9 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/DefaultServer.cs @@ -0,0 +1,148 @@ +// +// 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.Net; +using System.Security.Cryptography.X509Certificates; + +namespace Rnwood.SmtpServer; + +/// +/// A default subclass of which provides a default behaviour which is suitable for many +/// simple +/// applications. +/// +/// +public class DefaultServer : SmtpServer +{ + /// + /// Initializes a new instance of the class. + /// Initializes a new SMTP over SSL server on port 465 using the + /// supplied SSL certificate. + /// + /// if set to true remote collections are allowed. + /// The SSL certificate to use for the server. + public DefaultServer(bool allowRemoteConnections, X509Certificate sslCertificate) + : this(allowRemoteConnections, 465, sslCertificate) + { + } + + /// + /// Initializes a new instance of the class. + /// Initializes a new SMTP server on port 25. + /// + /// if set to true remote connections are allowed. + public DefaultServer(bool allowRemoteConnections) + : this(allowRemoteConnections, Dns.GetHostName(), 25, null, null) + { + } + + /// + /// Initializes a new instance of the class. + /// Initializes a new SMTP server on the specified port number. + /// + /// if set to true remote connections are allowed. + /// The port number. + public DefaultServer(bool allowRemoteConnections, int portNumber) + : this(allowRemoteConnections, Dns.GetHostName(), portNumber, null, null) + { + } + + /// + /// Initializes a new instance of the class. + /// Initializes a new SMTP over SSL server on the specified port number + /// using the supplied SSL certificate. + /// + /// if set to true remote connections are allowed. + /// The port number. + /// The TLS certificate to use for implicit TLS. + public DefaultServer(bool allowRemoteConnections, int portNumber, X509Certificate implicitTlsCertificate) + : this(allowRemoteConnections, Dns.GetHostName(), portNumber, implicitTlsCertificate, null) + { + } + + /// + /// Initializes a new instance of the class. + /// Initializes a new SMTP over SSL or SMTP with STARTTLS server on the specified port number + /// using the supplied SSL certificate. + /// + /// if set to true remote connections are allowed. + /// The domain name the server will send in greeting. + /// The port number. + /// The TLS certificate to use for implicit TLS. + /// The TLS certificate to use for STARTTLS. + public DefaultServer(bool allowRemoteConnections, string domainName, int portNumber, + X509Certificate implicitTlsCertificate, X509Certificate startTlsCertificate) + : this(new DefaultServerBehaviour(allowRemoteConnections, domainName, portNumber, implicitTlsCertificate, + startTlsCertificate)) + { + } + + /// + /// Initializes a new instance of the class. + /// Initializes a new SMTP server on the specified standard port number. + /// + /// if set to true connection from remote computers are allowed. + /// The standard port (or auto) to use. + public DefaultServer(bool allowRemoteConnections, StandardSmtpPort port) + : this(new DefaultServerBehaviour(allowRemoteConnections, (int)port)) + { + } + + private DefaultServer(DefaultServerBehaviour behaviour) + : base(behaviour) + { + } + + /// + /// Gets the Behaviour. + /// + protected new DefaultServerBehaviour Behaviour => (DefaultServerBehaviour)base.Behaviour; + + /// + /// Occurs when authentication results need to be validated. + /// + public event AsyncEventHandler + AuthenticationCredentialsValidationRequiredEventHandler + { + add => Behaviour.AuthenticationCredentialsValidationRequiredEventHandler += value; + remove => Behaviour.AuthenticationCredentialsValidationRequiredEventHandler -= value; + } + + /// + /// Occurs when a message has been fully received but not yet acknowledged by the server. + /// + public event AsyncEventHandler MessageCompletedEventHandler + { + add => Behaviour.MessageCompletedEventHandler += value; + remove => Behaviour.MessageCompletedEventHandler -= value; + } + + /// + /// Occurs when a message has been received and acknowledged by the server. + /// + public event AsyncEventHandler MessageReceivedEventHandler + { + add => Behaviour.MessageReceivedEventHandler += value; + remove => Behaviour.MessageReceivedEventHandler -= value; + } + + /// + /// Occurs when a session is terminated. + /// + public event AsyncEventHandler SessionCompletedEventHandler + { + add => Behaviour.SessionCompletedEventHandler += value; + remove => Behaviour.SessionCompletedEventHandler -= value; + } + + /// + /// Occurs when a new session is started, when a new client connects to the server. + /// + public event AsyncEventHandler SessionStartedHandler + { + add => Behaviour.SessionStartedEventHandler += value; + remove => Behaviour.SessionStartedEventHandler -= value; + } +} diff --git a/smtpserver/Rnwood.SmtpServer/DefaultServerBehaviour.cs b/smtpserver/Rnwood.SmtpServer/DefaultServerBehaviour.cs new file mode 100644 index 000000000..997d2922e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/DefaultServerBehaviour.cs @@ -0,0 +1,284 @@ +// +// 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.Net; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Extensions; +using Rnwood.SmtpServer.Extensions.Auth; + +namespace Rnwood.SmtpServer; + +/// +/// Implements a default which is suitable for many basic uses. +/// +/// +public class DefaultServerBehaviour : IServerBehaviour +{ + private readonly bool allowRemoteConnections; + + private readonly X509Certificate implcitTlsCertificate; + private readonly X509Certificate startTlsCertificate; + + /// + /// Initializes a new instance of the class. + /// + /// if set to true remote connections to the server are allowed. + public DefaultServerBehaviour(bool allowRemoteConnections) + : this(allowRemoteConnections, 25, null, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// if set to true remote connections to the server are allowed. + /// The port number. + public DefaultServerBehaviour(bool allowRemoteConnections, int portNumber) + : this(allowRemoteConnections, portNumber, null, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// if set to true remote connections to the server are allowed. + /// The port number. + /// The TLS certificate to use for implicit TLS. + public DefaultServerBehaviour(bool allowRemoteConnections, int portNumber, X509Certificate implicitTlsCertificate) + : this(allowRemoteConnections, portNumber, implicitTlsCertificate, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// if set to true remote connections to the server are allowed. + /// The port number. + /// The TLS certificate to use for implicit TLS. + /// The TLS certificate to use for STARTTLS. + public DefaultServerBehaviour( + bool allowRemoteConnections, + int portNumber, + X509Certificate implicitTlsCertificate, + X509Certificate startTlsCertificate) + : this(allowRemoteConnections, Dns.GetHostName(), portNumber, implicitTlsCertificate, startTlsCertificate) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// if set to true remote connections to the server are allowed. + /// The domain name the server will send in greeting. + /// The port number. + /// The TLS certificate to use for implicit TLS. + /// The TLS certificate to use for STARTTLS. + public DefaultServerBehaviour( + bool allowRemoteConnections, + string domainName, + int portNumber, + X509Certificate implcitTlsCertificate, + X509Certificate startTlsCertificate) + { + DomainName = domainName; + PortNumber = portNumber; + this.implcitTlsCertificate = implcitTlsCertificate; + this.startTlsCertificate = startTlsCertificate; + this.allowRemoteConnections = allowRemoteConnections; + } + + /// + /// Initializes a new instance of the class. + /// + /// if set to true remote connections to the server are allowed. + /// The TLS certificate to use for implicit TLS. + public DefaultServerBehaviour(bool allowRemoteConnections, X509Certificate implcitTlsCertificate) + : this(allowRemoteConnections, 587, implcitTlsCertificate, null) + { + } + + /// + /// Gets or sets a List of active Auth Mechanism Identifiers. + /// + public HashSet EnabledAuthMechanisms { get; set; } = + new(AuthMechanisms.All()); + + /// + public virtual string DomainName { get; } + + /// + public virtual IPAddress IpAddress => allowRemoteConnections ? IPAddress.Any : IPAddress.Loopback; + + /// + public int MaximumNumberOfSequentialBadCommands => 10; + + /// + public virtual int PortNumber { get; } + + /// + public virtual Encoding FallbackEncoding => Encoding.GetEncoding("iso-8859-1"); + + /// + public virtual Task> GetExtensions(IConnectionChannel connectionChannel) + { + List extensions = new List(new IExtension[] + { + new EightBitMimeExtension(), new SizeExtension(), new SmtpUtfEightExtension() + }); + + if (startTlsCertificate != null) + { + extensions.Add(new StartTlsExtension()); + } + + if (AuthenticationCredentialsValidationRequiredEventHandler != null) + { + extensions.Add(new AuthExtension()); + } + + return Task.FromResult>(extensions); + } + + /// + public virtual Task GetMaximumMessageSize(IConnection connection) => Task.FromResult(null); + + /// + public virtual Task GetReceiveTimeout(IConnectionChannel connectionChannel) => + Task.FromResult(new TimeSpan(0, 0, 30)); + + /// + public virtual Task GetSendTimeout(IConnectionChannel connectionChannel) => + Task.FromResult(new TimeSpan(0, 0, 30)); + + /// + public virtual Task GetSSLCertificate(IConnection connection) => + Task.FromResult(implcitTlsCertificate ?? startTlsCertificate); + + /// + public virtual Task IsAuthMechanismEnabled(IConnection connection, IAuthMechanism authMechanism) => + Task.FromResult( + EnabledAuthMechanisms.Contains(authMechanism)); + + /// + public Task IsSessionLoggingEnabled(IConnection connection) => Task.FromResult(false); + + /// + public Task IsSSLEnabled(IConnection connection) => Task.FromResult(implcitTlsCertificate != null); + + /// + public virtual Task OnCommandReceived(IConnection connection, SmtpCommand command) => + CommandReceivedEventHandler?.Invoke(this, new CommandEventArgs(command)) ?? Task.CompletedTask; + + /// + public virtual Task OnCreateNewMessage(IConnection connection) => + Task.FromResult(new MemoryMessageBuilder()); + + /// + public virtual Task OnCreateNewSession(IConnectionChannel connectionChannel) => + Task.FromResult(new MemorySession(connectionChannel.ClientIPAddress, DateTime.Now)); + + /// + public virtual Task OnMessageCompleted(IConnection connection) => + MessageCompletedEventHandler?.Invoke(this, new ConnectionEventArgs(connection)) ?? Task.CompletedTask; + + /// + public virtual Task OnMessageReceived(IConnection connection, IMessage message) => + MessageReceivedEventHandler?.Invoke(this, new MessageEventArgs(message)) ?? Task.CompletedTask; + + /// + public virtual Task OnMessageRecipientAdding(IConnection connection, IMessageBuilder message, string recipient) => + MessageRecipientAddingEventHandler?.Invoke(this, new RecipientAddingEventArgs(message, recipient)) ?? + Task.CompletedTask; + + /// + public virtual Task OnMessageStart(IConnection connection, string from) => + MessageStartEventHandler?.Invoke(this, new MessageStartEventArgs(connection.Session, from)) ?? + Task.CompletedTask; + + /// + public virtual Task OnSessionCompleted(IConnection connection, ISession session) => + SessionCompletedEventHandler?.Invoke(this, new SessionEventArgs(session)) ?? Task.CompletedTask; + + /// + public virtual Task OnSessionStarted(IConnection connection, ISession session) => + SessionStartedEventHandler?.Invoke(this, new SessionEventArgs(session)) ?? Task.CompletedTask; + + /// + public virtual async Task ValidateAuthenticationCredentials( + IConnection connection, + IAuthenticationCredentials authenticationRequest) + { + AsyncEventHandler handlers = + AuthenticationCredentialsValidationRequiredEventHandler; + + if (handlers != null) + { + var tasks = handlers.GetInvocationList() + .Cast>() + .Select(h => + { + AuthenticationCredentialsValidationEventArgs args = + new AuthenticationCredentialsValidationEventArgs(connection.Session, authenticationRequest); + return new { Args = args, Task = h(this, args) }; + }); + + await Task.WhenAll(tasks.Select(t => t.Task).ToArray()).ConfigureAwait(false); + + AuthenticationResult? failureResult = tasks + .Select(t => t.Args.AuthenticationResult) + .FirstOrDefault(r => r != AuthenticationResult.Success); + + return failureResult.GetValueOrDefault(AuthenticationResult.Success); + } + + return AuthenticationResult.Failure; + } + + /// + /// Occurs when authentication credential provided by the client need to be validated. + /// + public event AsyncEventHandler + AuthenticationCredentialsValidationRequiredEventHandler; + + /// + /// Occurs when a command is received from a client. + /// + public event AsyncEventHandler CommandReceivedEventHandler; + + /// + /// Occurs when a message has been requested for a message. + /// + public event AsyncEventHandler MessageRecipientAddingEventHandler; + + /// + /// Occurs when a message is received but not yet committed. + /// + public event AsyncEventHandler MessageCompletedEventHandler; + + /// + /// Occurs when a message is received and committed. + /// + public event AsyncEventHandler MessageReceivedEventHandler; + + /// + /// Occurs when a client session is closed. + /// + public event AsyncEventHandler SessionCompletedEventHandler; + + /// + /// Occurs when a new session is created, when a client connects to the server. + /// + public event AsyncEventHandler SessionStartedEventHandler; + + /// + /// Occurs when a new message is started. + /// + public event AsyncEventHandler MessageStartEventHandler; +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousAuthenticationCredentials.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousAuthenticationCredentials.cs new file mode 100644 index 000000000..48e84b563 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousAuthenticationCredentials.cs @@ -0,0 +1,18 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class AnonymousAuthenticationCredentials : IAuthenticationCredentials +{ + /// + public string Type + { + get => "NONE"; + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousMechanism.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousMechanism.cs new file mode 100644 index 000000000..7edfd7946 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousMechanism.cs @@ -0,0 +1,30 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the which implements ANONYMOUS authentication. +/// +public class AnonymousMechanism : IAuthMechanism +{ + /// + public string Identifier => "ANONYMOUS"; + + /// + public bool IsPlainText => false; + + /// + public IAuthMechanismProcessor CreateAuthMechanismProcessor(IConnection connection) => + new AnonymousMechanismProcessor(connection); + + /// + public override bool Equals(object obj) => + obj is AnonymousMechanism mechanism && + Identifier == mechanism.Identifier; + + /// + public override int GetHashCode() => Identifier.GetHashCode(); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousMechanismProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousMechanismProcessor.cs new file mode 100644 index 000000000..68151a2d9 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AnonymousMechanismProcessor.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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class AnonymousMechanismProcessor : IAuthMechanismProcessor +{ + /// + /// Initializes a new instance of the class. + /// + /// The connection. + public AnonymousMechanismProcessor(IConnection connection) => Connection = connection; + + /// + /// Gets the connection this processor is for. + /// + /// + /// The connection. + /// + protected IConnection Connection { get; } + + /// + public IAuthenticationCredentials Credentials { get; private set; } + + /// + public async Task ProcessResponse(string data) + { + Credentials = new AnonymousAuthenticationCredentials(); + + AuthenticationResult result = + await Connection.Server.Behaviour.ValidateAuthenticationCredentials(Connection, Credentials) + .ConfigureAwait(false); + + switch (result) + { + case AuthenticationResult.Success: + return AuthMechanismProcessorStatus.Success; + + default: + return AuthMechanismProcessorStatus.Failed; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthExtension.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthExtension.cs new file mode 100644 index 000000000..0d94e5b29 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthExtension.cs @@ -0,0 +1,16 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class AuthExtension : IExtension +{ + /// + public IExtensionProcessor CreateExtensionProcessor(IConnection connection) => + new AuthExtensionProcessor(connection); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthExtensionProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthExtensionProcessor.cs new file mode 100644 index 000000000..0ad4366e6 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthExtensionProcessor.cs @@ -0,0 +1,89 @@ +// +// 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; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Implements the AUTH extension for a connection. +/// +/// +public class AuthExtensionProcessor : IExtensionProcessor +{ + /// + /// Defines the connection. + /// + private readonly IConnection connection; + + /// + /// Initializes a new instance of the class. + /// + /// The connection. + public AuthExtensionProcessor(IConnection connection) + { + this.connection = connection; + MechanismMap = new AuthMechanismMap(); + foreach (IAuthMechanism authMechanism in AuthMechanisms.All()) + { + MechanismMap.Add(authMechanism); + } + + connection.VerbMap.SetVerbProcessor("AUTH", new AuthVerb(this)); + } + + /// + /// Gets the mechanism map which manages the list of available auth mechanisms. + /// + /// + /// The mechanism map. + /// + public AuthMechanismMap MechanismMap { get; } + + /// + public async Task GetEHLOKeywords() + { + IAuthMechanism[] mechanisms = (await GetEnabledAuthMechanisms().ConfigureAwait(false)).ToArray(); + + if (mechanisms.Any()) + { + string mids = string.Join(" ", mechanisms.Select(m => m.Identifier)); + + return new[] { "AUTH=" + mids, "AUTH " + mids }; + } + + return Array.Empty(); + } + + /// + /// Determines whether the specified auth mechanism is enabled for the current connection. + /// + /// The mechanism. + /// A representing the async operation which yields true if enabled. + public async Task IsMechanismEnabled(IAuthMechanism mechanism) => await connection.Server.Behaviour + .IsAuthMechanismEnabled(connection, mechanism).ConfigureAwait(false); + + /// + /// Returns a sequence of all enabled auth mechanisms for the current connection. + /// + /// A representing the async operation. + protected async Task> GetEnabledAuthMechanisms() + { + List result = new List(); + + foreach (IAuthMechanism mechanism in MechanismMap.GetAll()) + { + if (await connection.Server.Behaviour.IsAuthMechanismEnabled(connection, mechanism).ConfigureAwait(false)) + { + result.Add(mechanism); + } + } + + return result; + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismMap.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismMap.cs new file mode 100644 index 000000000..67b96f6b5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismMap.cs @@ -0,0 +1,44 @@ +// +// 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; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class AuthMechanismMap +{ + /// + /// Defines the map. + /// + private readonly Dictionary map = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds an auth mechanism to the map. + /// + /// The mechanism. + public void Add(IAuthMechanism mechanism) => map[mechanism.Identifier] = mechanism; + + /// + /// Gets the auth mechanism which has been registered for the given identifier. + /// + /// The identifier. + /// The . + public IAuthMechanism Get(string identifier) + { + map.TryGetValue(identifier, out IAuthMechanism result); + + return result; + } + + /// + /// Gets all registered auth mechanisms. + /// + /// The . + public IEnumerable GetAll() => map.Values; +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismProcessor.cs new file mode 100644 index 000000000..dbaa88b82 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismProcessor.cs @@ -0,0 +1,59 @@ +// +// 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; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public abstract class AuthMechanismProcessor : IAuthMechanismProcessor +{ + /// + /// Initializes a new instance of the class. + /// + /// The connection. + protected AuthMechanismProcessor(IConnection connection) => Connection = connection; + + /// + /// Gets the connection this processor is for. + /// + /// + /// The connection. + /// + public IConnection Connection { get; private set; } + + /// + public IAuthenticationCredentials Credentials { get; protected set; } + + /// + public abstract Task ProcessResponse(string data); + + /// + /// Decodes a base64 encoded ASCII string and throws an exception if invalid. + /// + /// The data. + /// The decoded ASCII string. + /// If the base64 encoded string is invalid. + protected static string DecodeBase64(string data) + { + try + { + return Encoding.ASCII.GetString(Convert.FromBase64String(data)); + } + catch (FormatException) + { + throw new BadBase64Exception(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationFailure, + "Bad Base64 data")); + } + } + + protected static string EncodeBase64(string asciiString) => + Convert.ToBase64String(Encoding.ASCII.GetBytes(asciiString)); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismProcessorStatus.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismProcessorStatus.cs new file mode 100644 index 000000000..cbd4357e4 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanismProcessorStatus.cs @@ -0,0 +1,29 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the AuthMechanismProcessorStatus. +/// +#pragma warning disable CA1717 // Only FlagsAttribute enums should have plural names +public enum AuthMechanismProcessorStatus +{ + /// + /// Defines the Continue + /// + Continue, + + /// + /// Defines the Failed + /// + Failed, + + /// + /// Defines the Success + /// + Success +} +#pragma warning restore CA1717 // Only FlagsAttribute enums should have plural names diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanisms.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanisms.cs new file mode 100644 index 000000000..b25281852 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthMechanisms.cs @@ -0,0 +1,26 @@ +// +// 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.Collections.Generic; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Authentication Mechanisms. +/// +public static class AuthMechanisms +{ + /// + /// Return enumerable of all valid Auth Mechanisms. + /// + /// Enumerable collection of AuthMechanisms. + public static IEnumerable All() + { + yield return new CramMd5Mechanism(); + yield return new PlainMechanism(); + yield return new LoginMechanism(); + yield return new AnonymousMechanism(); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthVerb.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthVerb.cs new file mode 100644 index 000000000..7dbdcde10 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthVerb.cs @@ -0,0 +1,109 @@ +// +// 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 System.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class AuthVerb : IVerb +{ + /// + /// Initializes a new instance of the class. + /// + /// The authExtensionProcessor. + public AuthVerb(AuthExtensionProcessor authExtensionProcessor) => AuthExtensionProcessor = authExtensionProcessor; + + /// + /// Gets the AuthExtensionProcessor. + /// + public AuthExtensionProcessor AuthExtensionProcessor { get; } + + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + ArgumentsParser argumentsParser = new ArgumentsParser(command.ArgumentsText); + + if (argumentsParser.Arguments.Count > 0) + { + if (connection.Session.Authenticated) + { + throw new SmtpServerException(new SmtpResponse( + StandardSmtpResponseCode.BadSequenceOfCommands, + "Already authenticated")); + } + + string mechanismId = argumentsParser.Arguments.First(); + IAuthMechanism mechanism = AuthExtensionProcessor.MechanismMap.Get(mechanismId); + + if (mechanism == null) + { + throw new SmtpServerException( + new SmtpResponse( + StandardSmtpResponseCode.CommandParameterNotImplemented, + "Specified AUTH mechanism not supported")); + } + + if (!await AuthExtensionProcessor.IsMechanismEnabled(mechanism).ConfigureAwait(false)) + { + throw new SmtpServerException( + new SmtpResponse( + StandardSmtpResponseCode.AuthenticationFailure, + "Specified AUTH mechanism not allowed right now (might require secure connection etc)")); + } + + IAuthMechanismProcessor authMechanismProcessor = + mechanism.CreateAuthMechanismProcessor(connection); + + string initialData = null; + if (argumentsParser.Arguments.Count > 1) + { + initialData = string.Join(" ", argumentsParser.Arguments.Skip(1).ToArray()); + } + + AuthMechanismProcessorStatus status = + await authMechanismProcessor.ProcessResponse(initialData).ConfigureAwait(false); + while (status == AuthMechanismProcessorStatus.Continue) + { + string response = await connection.ReadLine().ConfigureAwait(false); + + if (response == "*") + { + await connection + .WriteResponse(new SmtpResponse(StandardSmtpResponseCode.SyntaxErrorInCommandArguments, + "Authentication aborted")).ConfigureAwait(false); + return; + } + + status = await authMechanismProcessor.ProcessResponse(response).ConfigureAwait(false); + } + + if (status == AuthMechanismProcessorStatus.Success) + { + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationOK, + "Authenticated OK")).ConfigureAwait(false); + connection.Session.Authenticated = true; + connection.Session.AuthenticationCredentials = authMechanismProcessor.Credentials; + } + else + { + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationFailure, + "Authentication failure")).ConfigureAwait(false); + } + } + else + { + throw new SmtpServerException(new SmtpResponse( + StandardSmtpResponseCode.SyntaxErrorInCommandArguments, + "Must specify AUTH mechanism as a parameter")); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthenticationResult.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthenticationResult.cs new file mode 100644 index 000000000..e2fc30e29 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/AuthenticationResult.cs @@ -0,0 +1,27 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer; + +/// +/// Defines the AuthenticationResult. +/// +public enum AuthenticationResult +{ + /// + /// Defines the Success + /// + Success, + + /// + /// Defines the Failure + /// + Failure, + + /// + /// Defines the TemporaryFailure + /// + TemporaryFailure +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5AuthenticationCredentials.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5AuthenticationCredentials.cs new file mode 100644 index 000000000..43fa49522 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5AuthenticationCredentials.cs @@ -0,0 +1,68 @@ +// +// 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.Security.Cryptography; +using System.Text; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class CramMd5AuthenticationCredentials : IAuthenticationCredentials +{ + /// + /// Initializes a new instance of the class. + /// + /// The username. + /// The challenge. + /// The challengeResponse. + public CramMd5AuthenticationCredentials(string username, string challenge, string challengeResponse) + { + Username = username; + ChallengeResponse = challengeResponse; + Challenge = challenge; + } + + /// + /// Gets the Challenge. + /// + public string Challenge { get; } + + /// + /// Gets the ChallengeResponse. + /// + public string ChallengeResponse { get; } + + /// + /// Gets the Username. + /// + public string Username { get; private set; } + + /// + /// Validates the response sent by the client against a password specified in clear text. + /// + /// The password. + /// + /// The . + /// + public bool ValidateResponse(string password) + { +#pragma warning disable CA5351 + HMACMD5 hmacmd5 = new HMACMD5(Encoding.ASCII.GetBytes(password)); + string expectedResponse = BitConverter.ToString(hmacmd5.ComputeHash(Encoding.ASCII.GetBytes(Challenge))) + .Replace("-", string.Empty); +#pragma warning restore CA5351 + + return string.Equals(expectedResponse, ChallengeResponse, StringComparison.OrdinalIgnoreCase); + } + + /// + public string Type + { + get => "CRAMMD5"; + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5Mechanism.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5Mechanism.cs new file mode 100644 index 000000000..b9f74ac6f --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5Mechanism.cs @@ -0,0 +1,30 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the implementing the CRAM-MD5 auth mechanism. +/// +public class CramMd5Mechanism : IAuthMechanism +{ + /// + public string Identifier => "CRAM-MD5"; + + /// + public bool IsPlainText => false; + + /// + public IAuthMechanismProcessor CreateAuthMechanismProcessor(IConnection connection) => + new CramMd5MechanismProcessor(connection, new RandomIntegerGenerator(), new CurrentDateTimeProvider()); + + /// + public override bool Equals(object obj) => + obj is CramMd5Mechanism mechanism && + Identifier == mechanism.Identifier; + + /// + public override int GetHashCode() => Identifier.GetHashCode(); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5MechanismProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5MechanismProcessor.cs new file mode 100644 index 000000000..c6eb22598 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/CramMd5MechanismProcessor.cs @@ -0,0 +1,107 @@ +// +// 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.Globalization; +using System.Text; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class CramMd5MechanismProcessor : AuthMechanismProcessor +{ + /// + /// Defines the dateTimeProvider. + /// + private readonly ICurrentDateTimeProvider dateTimeProvider; + + /// + /// Defines the random. + /// + private readonly IRandomIntegerGenerator random; + + /// + /// Defines the challenge. + /// + private string challenge; + + /// + /// Initializes a new instance of the class. + /// + /// The connection. + /// The random. + /// The dateTimeProvider. + public CramMd5MechanismProcessor(IConnection connection, IRandomIntegerGenerator random, + ICurrentDateTimeProvider dateTimeProvider) + : base(connection) + { + this.random = random; + this.dateTimeProvider = dateTimeProvider; + } + + /// + /// Initializes a new instance of the class. + /// + /// The connection. + /// The random. + /// The dateTimeProvider. + /// The challenge. + public CramMd5MechanismProcessor(IConnection connection, IRandomIntegerGenerator random, + ICurrentDateTimeProvider dateTimeProvider, string challenge) + : this(connection, random, dateTimeProvider) => + this.challenge = challenge; + + /// + public override async Task ProcessResponse(string data) + { + if (this.challenge == null) + { + StringBuilder challengeStringBuilder = new StringBuilder(); + challengeStringBuilder.Append(random.GenerateRandomInteger(0, short.MaxValue)); + challengeStringBuilder.Append("."); + challengeStringBuilder.Append(dateTimeProvider.GetCurrentDateTime().Ticks.ToString(CultureInfo.InvariantCulture)); + challengeStringBuilder.Append("@"); + challengeStringBuilder.Append(Connection.Server.Behaviour.DomainName); + this.challenge = challengeStringBuilder.ToString(); + + string base64Challenge = Convert.ToBase64String(Encoding.ASCII.GetBytes(challengeStringBuilder.ToString())); + await Connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationContinue, + base64Challenge)).ConfigureAwait(false); + return AuthMechanismProcessorStatus.Continue; + } + + string response = DecodeBase64(data); + string[] responseparts = response.Split(' '); + + if (responseparts.Length != 2) + { + throw new SmtpServerException(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationFailure, + "Response in incorrect format - should be USERNAME RESPONSE")); + } + + string username = responseparts[0]; + string hash = responseparts[1]; + + Credentials = new CramMd5AuthenticationCredentials(username, challenge, hash); + + AuthenticationResult result = + await Connection.Server.Behaviour.ValidateAuthenticationCredentials(Connection, Credentials) + .ConfigureAwait(false); + + switch (result) + { + case AuthenticationResult.Success: + return AuthMechanismProcessorStatus.Success; + + default: + return AuthMechanismProcessorStatus.Failed; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthMechanism.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthMechanism.cs new file mode 100644 index 000000000..abfad6a9b --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthMechanism.cs @@ -0,0 +1,31 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the which implements a single authentication mechansim for the server. +/// +public interface IAuthMechanism +{ + /// + /// Gets the identifier for this AUTH mechanism as declared by the server in the EHELO response. + /// + string Identifier { get; } + + /// + /// Gets a value indicating whether credentials are sent using plain text. + /// + bool IsPlainText { get; } + + /// + /// Creates an authentication mechanism processor for the provided connection. + /// + /// The connection. + /// + /// The . + /// + IAuthMechanismProcessor CreateAuthMechanismProcessor(IConnection connection); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthMechanismProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthMechanismProcessor.cs new file mode 100644 index 000000000..fe9b7802e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthMechanismProcessor.cs @@ -0,0 +1,29 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the which implements the state machine for a particular auth +/// mechnism for a single client connection. +/// +public interface IAuthMechanismProcessor +{ + /// + /// Gets the Credentials supplied during this authentication. + /// + IAuthenticationCredentials Credentials { get; } + + /// + /// Processes a response from the client and returns the result of the auth operation. + /// + /// The data. + /// + /// A representing the async operation. + /// + Task ProcessResponse(string data); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthenticationCredentials.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthenticationCredentials.cs new file mode 100644 index 000000000..5bf233c2d --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/IAuthenticationCredentials.cs @@ -0,0 +1,16 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; +/// +/// Represents credentials supplied by the client. +/// +public interface IAuthenticationCredentials +{ + /// + /// Gets a string representing the type of this credential. + /// + string Type { get; } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginAuthenticationCredentials.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginAuthenticationCredentials.cs new file mode 100644 index 000000000..64adc2148 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginAuthenticationCredentials.cs @@ -0,0 +1,22 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class LoginAuthenticationCredentials : UsernameAndPasswordAuthenticationCredentials +{ + /// + /// Initializes a new instance of the class. + /// + /// The username. + /// The password. + public LoginAuthenticationCredentials(string username, string password) + : base(username, password) + { + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginMechanism.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginMechanism.cs new file mode 100644 index 000000000..0b8c35cd7 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginMechanism.cs @@ -0,0 +1,30 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the implementing the plain text LOGIN auth mechanism. +/// +public class LoginMechanism : IAuthMechanism +{ + /// + public string Identifier => "LOGIN"; + + /// + public bool IsPlainText => true; + + /// + public IAuthMechanismProcessor CreateAuthMechanismProcessor(IConnection connection) => + new LoginMechanismProcessor(connection); + + /// + public override bool Equals(object obj) => + obj is LoginMechanism mechanism && + Identifier == mechanism.Identifier; + + /// + public override int GetHashCode() => Identifier.GetHashCode(); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginMechanismProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginMechanismProcessor.cs new file mode 100644 index 000000000..997eaeeaa --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/LoginMechanismProcessor.cs @@ -0,0 +1,111 @@ +// +// 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; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class LoginMechanismProcessor : AuthMechanismProcessor +{ + /// + /// Defines the username. + /// + private string username; + + /// + /// Initializes a new instance of the class. + /// + /// The connection. + public LoginMechanismProcessor(IConnection connection) + : base(connection) => + State = States.Initial; + + private States State { get; set; } + + /// + public override async Task ProcessResponse(string data) + { + if (State == States.Initial && data != null) + { + State = States.WaitingForUsername; + } + + switch (State) + { + case States.Initial: + await Connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationContinue, + Convert.ToBase64String( + Encoding.ASCII.GetBytes("Username:")))).ConfigureAwait(false); + State = States.WaitingForUsername; + return AuthMechanismProcessorStatus.Continue; + + case States.WaitingForUsername: + + username = DecodeBase64(data); + + await Connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationContinue, + Convert.ToBase64String( + Encoding.ASCII.GetBytes("Password:")))).ConfigureAwait(false); + State = States.WaitingForPassword; + return AuthMechanismProcessorStatus.Continue; + + case States.WaitingForPassword: + string password = DecodeBase64(data); + State = States.Completed; + + Credentials = new LoginAuthenticationCredentials(username, password); + + AuthenticationResult result = + await Connection.Server.Behaviour.ValidateAuthenticationCredentials( + Connection, + Credentials).ConfigureAwait(false); + + switch (result) + { + case AuthenticationResult.Success: + return AuthMechanismProcessorStatus.Success; + + default: + return AuthMechanismProcessorStatus.Failed; + } + + default: + throw new NotImplementedException(); + } + } + + /// + /// Defines the States. + /// + private enum States + { + /// + /// Defines the Initial + /// + Initial, + + /// + /// Defines the WaitingForUsername + /// + WaitingForUsername, + + /// + /// Defines the WaitingForPassword + /// + WaitingForPassword, + + /// + /// Defines the Completed + /// + Completed + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainAuthenticationCredentials.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainAuthenticationCredentials.cs new file mode 100644 index 000000000..69a194c6e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainAuthenticationCredentials.cs @@ -0,0 +1,22 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class PlainAuthenticationCredentials : UsernameAndPasswordAuthenticationCredentials +{ + /// + /// Initializes a new instance of the class. + /// + /// The username. + /// The password. + public PlainAuthenticationCredentials(string username, string password) + : base(username, password) + { + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainMechanism.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainMechanism.cs new file mode 100644 index 000000000..9e87972c5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainMechanism.cs @@ -0,0 +1,30 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the which implements the PLAIN auth mechanism. +/// +public class PlainMechanism : IAuthMechanism +{ + /// + public string Identifier => "PLAIN"; + + /// + public bool IsPlainText => true; + + /// + public IAuthMechanismProcessor CreateAuthMechanismProcessor(IConnection connection) => + new PlainMechanismProcessor(connection); + + /// + public override bool Equals(object obj) => + obj is PlainMechanism mechanism && + Identifier == mechanism.Identifier; + + /// + public override int GetHashCode() => Identifier.GetHashCode(); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainMechanismProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainMechanismProcessor.cs new file mode 100644 index 000000000..df63e44b5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/PlainMechanismProcessor.cs @@ -0,0 +1,91 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public class PlainMechanismProcessor : AuthMechanismProcessor, IAuthMechanismProcessor +{ + /// + /// Defines the States. + /// + public enum ProcessingState + { + /// + /// Defines the Initial + /// + Initial, + + /// + /// Defines the AwaitingResponse + /// + AwaitingResponse + } + + /// + /// Initializes a new instance of the class. + /// + /// The connection. + public PlainMechanismProcessor(IConnection connection) + : base(connection) + { + } + + /// + /// Gets or sets the State. + /// + private ProcessingState State { get; set; } + + /// + public override async Task ProcessResponse(string data) + { + if (string.IsNullOrEmpty(data)) + { + if (State == ProcessingState.AwaitingResponse) + { + throw new SmtpServerException(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationFailure, + "Missing auth data")); + } + + await Connection + .WriteResponse(new SmtpResponse(StandardSmtpResponseCode.AuthenticationContinue, string.Empty)) + .ConfigureAwait(false); + State = ProcessingState.AwaitingResponse; + return AuthMechanismProcessorStatus.Continue; + } + + string decodedData = DecodeBase64(data); + string[] decodedDataParts = decodedData.Split('\0'); + + if (decodedDataParts.Length != 3) + { + throw new SmtpServerException(new SmtpResponse( + StandardSmtpResponseCode.AuthenticationFailure, + "Auth data in incorrect format")); + } + + string username = decodedDataParts[1]; + string password = decodedDataParts[2]; + + Credentials = new PlainAuthenticationCredentials(username, password); + + AuthenticationResult result = + await Connection.Server.Behaviour.ValidateAuthenticationCredentials(Connection, Credentials) + .ConfigureAwait(false); + switch (result) + { + case AuthenticationResult.Success: + return AuthMechanismProcessorStatus.Success; + + default: + return AuthMechanismProcessorStatus.Failed; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/Auth/UsernameAndPasswordAuthenticationCredentials.cs b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/UsernameAndPasswordAuthenticationCredentials.cs new file mode 100644 index 000000000..aa4052756 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/Auth/UsernameAndPasswordAuthenticationCredentials.cs @@ -0,0 +1,36 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions.Auth; + +/// +/// Defines the . +/// +public abstract class UsernameAndPasswordAuthenticationCredentials : IAuthenticationCredentials +{ + /// + /// Initializes a new instance of the class. + /// + /// The username. + /// The password. + protected UsernameAndPasswordAuthenticationCredentials(string username, string password) + { + Username = username; + Password = password; + } + + /// + /// Gets the Password. + /// + public string Password { get; private set; } + + /// + /// Gets the Username. + /// + public string Username { get; private set; } + + /// + public string Type { get => "USERNAME_PASSWORD"; } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/EightBitMimeExtension.cs b/smtpserver/Rnwood.SmtpServer/Extensions/EightBitMimeExtension.cs new file mode 100644 index 000000000..51067f928 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/EightBitMimeExtension.cs @@ -0,0 +1,74 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions; + +/// +/// Defines the . +/// +public class EightBitMimeExtension : IExtension +{ + /// + /// Initializes a new instance of the class. + /// + public EightBitMimeExtension() + { + } + + /// + public IExtensionProcessor CreateExtensionProcessor(IConnection connection) => + new EightBitMimeExtensionProcessor(connection); + + /// + /// Defines the . + /// + private sealed class EightBitMimeExtensionProcessor : ExtensionProcessor + { + /// + /// Initializes a new instance of the class. + /// + /// The connection. + public EightBitMimeExtensionProcessor(IConnection connection) + : base(connection) + { + MailVerb mailVerbProcessor = connection.MailVerb; + MailFromVerb mailFromProcessor = mailVerbProcessor.FromSubVerb; + mailFromProcessor.ParameterProcessorMap.SetProcessor("BODY", new EightBitMimeBodyParameter()); + } + + /// + public override Task GetEHLOKeywords() => Task.FromResult(new[] { "8BITMIME" }); + } + + private sealed class EightBitMimeBodyParameter : IParameterProcessor + { + public Task SetParameter(IConnection connection, string key, string value) + { + if (key.Equals("BODY", StringComparison.OrdinalIgnoreCase)) + { + if (value.Equals("8BITMIME", StringComparison.CurrentCultureIgnoreCase)) + { + connection.CurrentMessage.EightBitTransport = true; + } + else if (value.Equals("7BIT", StringComparison.OrdinalIgnoreCase)) + { + connection.CurrentMessage.EightBitTransport = false; + } + else + { + throw new SmtpServerException( + new SmtpResponse( + StandardSmtpResponseCode.SyntaxErrorInCommandArguments, + "BODY parameter value invalid - must be either 7BIT or 8BITMIME")); + } + } + + return Task.CompletedTask; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/ExtensionProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/ExtensionProcessor.cs new file mode 100644 index 000000000..1c4741d68 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/ExtensionProcessor.cs @@ -0,0 +1,31 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions; + +/// +/// Defines the . +/// +public abstract class ExtensionProcessor : IExtensionProcessor +{ + /// + /// Initializes a new instance of the class. + /// + /// The connection. + protected ExtensionProcessor(IConnection connection) => Connection = connection; + + /// + /// Gets the Connection. + /// + public IConnection Connection { get; private set; } + + /// + /// Returns the EHLO keywords which advertise this extension to the client. + /// + /// A representing the async operation. + public abstract Task GetEHLOKeywords(); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/IExtension.cs b/smtpserver/Rnwood.SmtpServer/Extensions/IExtension.cs new file mode 100644 index 000000000..d8fd16aa1 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/IExtension.cs @@ -0,0 +1,21 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Extensions; + +/// +/// Defines the . +/// +public interface IExtension +{ + /// + /// Creates the extension processor for a connection. + /// + /// The connection. + /// + /// The . + /// + IExtensionProcessor CreateExtensionProcessor(IConnection connection); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/IExtensionProcessor.cs b/smtpserver/Rnwood.SmtpServer/Extensions/IExtensionProcessor.cs new file mode 100644 index 000000000..872dc0ed3 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/IExtensionProcessor.cs @@ -0,0 +1,20 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions; + +/// +/// Defines the . +/// +public interface IExtensionProcessor +{ + /// + /// Returns a sequence of EHLO keywords which are output to advertise the support for this extension to the client. + /// + /// A representing the async operation. + Task GetEHLOKeywords(); +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/SizeExtension.cs b/smtpserver/Rnwood.SmtpServer/Extensions/SizeExtension.cs new file mode 100644 index 000000000..e673decc6 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/SizeExtension.cs @@ -0,0 +1,85 @@ +// +// 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.Globalization; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions; + +/// +/// Defines the . +/// +public class SizeExtension : IExtension +{ + /// + public IExtensionProcessor CreateExtensionProcessor(IConnection connection) => + new SizeExtensionProcessor(connection); + + /// + /// Defines the . + /// + private sealed class SizeExtensionProcessor : IExtensionProcessor, IParameterProcessor + { + /// + /// Initializes a new instance of the class. + /// + /// The connection. + public SizeExtensionProcessor(IConnection connection) + { + Connection = connection; + Connection.MailVerb.FromSubVerb.ParameterProcessorMap.SetProcessor("SIZE", this); + } + + /// + /// Gets the connection this processor is for. + /// + /// + /// The connection. + /// + public IConnection Connection { get; } + + /// + public async Task GetEHLOKeywords() + { + long? maxMessageSize = + await Connection.Server.Behaviour.GetMaximumMessageSize(Connection).ConfigureAwait(false); + + if (maxMessageSize.HasValue) + { + return new[] { string.Format(CultureInfo.InvariantCulture, "SIZE={0}", maxMessageSize.Value) }; + } + + return new[] { "SIZE" }; + } + + /// + public async Task SetParameter(IConnection connection, string key, string value) + { + if (key.Equals("SIZE", StringComparison.OrdinalIgnoreCase)) + { + if (long.TryParse(value, out long messageSize) && messageSize > 0) + { + long? maxMessageSize = await Connection.Server.Behaviour.GetMaximumMessageSize(Connection) + .ConfigureAwait(false); + connection.CurrentMessage.DeclaredMessageSize = messageSize; + + if (maxMessageSize.HasValue && messageSize > maxMessageSize) + { + throw new SmtpServerException( + new SmtpResponse( + StandardSmtpResponseCode.ExceededStorageAllocation, + "Message exceeds fixes size limit")); + } + } + else + { + throw new SmtpServerException(new SmtpResponse( + StandardSmtpResponseCode.SyntaxErrorInCommandArguments, "Bad message size specified")); + } + } + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/SmtpUtfEightExtension.cs b/smtpserver/Rnwood.SmtpServer/Extensions/SmtpUtfEightExtension.cs new file mode 100644 index 000000000..beca26c9c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/SmtpUtfEightExtension.cs @@ -0,0 +1,40 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions; + +/// Implements the SMTPUTF8 extension. +public class SmtpUtfEightExtension : IExtension +{ + /// Creates the extension processor for a connection. + /// The connection. + /// The . + public IExtensionProcessor CreateExtensionProcessor(IConnection connection) => + new SmtpUtfEightExtensionProcessor(connection); + + private sealed class SmtpUtfEightExtensionProcessor : ExtensionProcessor + { + public SmtpUtfEightExtensionProcessor(IConnection connection) + : base(connection) + { + MailVerb mailVerbProcessor = connection.MailVerb; + MailFromVerb mailFromProcessor = mailVerbProcessor.FromSubVerb; + mailFromProcessor.ParameterProcessorMap.SetProcessor("SMTPUTF8", new SmtpUtfEightParameterProcessor()); + } + + public override Task GetEHLOKeywords() => Task.FromResult(new[] { "SMTPUTF8" }); + } + + private sealed class SmtpUtfEightParameterProcessor : IParameterProcessor + { + public Task SetParameter(IConnection connection, string key, string value) + { + connection.CurrentMessage.EightBitTransport = true; + return Task.CompletedTask; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/StartTlsExtension.cs b/smtpserver/Rnwood.SmtpServer/Extensions/StartTlsExtension.cs new file mode 100644 index 000000000..efc9874c6 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/StartTlsExtension.cs @@ -0,0 +1,60 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Extensions; + +/// +/// Defines the . +/// +public class StartTlsExtension : IExtension +{ + /// + public IExtensionProcessor CreateExtensionProcessor(IConnection connection) => + new StartTlsExtensionProcessor(connection); + + /// + /// Defines the . + /// + private sealed class StartTlsExtensionProcessor : IExtensionProcessor + { + /// + /// Initializes a new instance of the class. + /// + /// The connection. + public StartTlsExtensionProcessor(IConnection connection) + { + Connection = connection; + Connection.VerbMap.SetVerbProcessor("STARTTLS", new StartTlsVerb()); + } + + /// + /// Gets the connection this processor is for. + /// + /// + /// The connection. + /// + public IConnection Connection { get; } + + /// + public Task GetEHLOKeywords() + { + string[] result; + + if (!Connection.Session.SecureConnection) + { + result = new[] { "STARTTLS" }; + } + else + { + result = Array.Empty(); + } + + return Task.FromResult(result); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Extensions/StartTlsVerb.cs b/smtpserver/Rnwood.SmtpServer/Extensions/StartTlsVerb.cs new file mode 100644 index 000000000..184f4b46d --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Extensions/StartTlsVerb.cs @@ -0,0 +1,64 @@ +// +// 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.Net.Security; +using System.Reflection; +using System.Runtime.Versioning; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer.Extensions; + +/// +/// Defines the . +/// +public class StartTlsVerb : IVerb +{ + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + X509Certificate certificate = + await connection.Server.Behaviour.GetSSLCertificate(connection).ConfigureAwait(false); + + if (certificate == null) + { + await connection.WriteResponse(new SmtpResponse(StandardSmtpResponseCode.CommandNotImplemented, + "TLS configuration error - no certificate")).ConfigureAwait(false); + return; + } + + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.ServiceReady, + "Ready to start TLS")).ConfigureAwait(false); + + SslProtocols sslProtos; + + string ver = Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkName; + if (ver == null || !ver.StartsWith(".NETCoreApp,")) + { + sslProtos = SslProtocols.Tls12 | SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Ssl3 | + SslProtocols.Ssl2; + } + else + { + sslProtos = SslProtocols.None; + } + + await connection.ApplyStreamFilter(async stream => + { + SslStream sslStream = new SslStream(stream); + await sslStream.AuthenticateAsServerAsync( + certificate, + false, + sslProtos, + false).ConfigureAwait(false); + return sslStream; + }).ConfigureAwait(false); + + connection.Session.SecureConnection = true; + } +} diff --git a/smtpserver/Rnwood.SmtpServer/FileMessage.cs b/smtpserver/Rnwood.SmtpServer/FileMessage.cs new file mode 100644 index 000000000..c592da948 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/FileMessage.cs @@ -0,0 +1,122 @@ +// +// 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.IO; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class FileMessage : IMessage +{ + private readonly bool keepOnDispose; + + private bool disposedValue; // To detect redundant calls + + /// + /// Initializes a new instance of the class. + /// + /// The file. + /// The keepOnDispose. + public FileMessage(FileInfo file, bool keepOnDispose) + { + File = file; + this.keepOnDispose = keepOnDispose; + } + + /// + /// Gets the file. + /// + /// + /// The file. + /// + internal FileInfo File { get; } + + /// + /// Gets the recipients list. + /// + /// + /// The recipients list. + /// + internal List RecipientsList { get; } = new(); + + /// + /// Gets the DeclaredMessageSize. + /// + public long? DeclaredMessageSize { get; internal set; } + + /// + /// Gets a value indicating whether EightBitTransport. + /// + public bool EightBitTransport { get; internal set; } + + /// + /// Gets the From. + /// + public string From { get; internal set; } + + /// + /// Gets the ReceivedDate. + /// + public DateTime ReceivedDate { get; internal set; } + + /// + /// Gets a value indicating whether SecureConnection. + /// + public bool SecureConnection { get; internal set; } + + /// + /// Gets the Session. + /// + public ISession Session { get; internal set; } + + /// + /// Gets the To. + /// + public IReadOnlyCollection Recipients => RecipientsList.AsReadOnly(); + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Gets a stream which returns the message data. + /// + /// + /// A representing the async operation. + /// + public Task GetData() => + Task.FromResult(new FileStream(File.FullName, FileMode.Open, FileAccess.Read, + FileShare.Delete | FileShare.Read)); + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// + /// true to release both managed and unmanaged resources; false to release only + /// unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing && !keepOnDispose && File.Exists) + { + File.Delete(); + } + + disposedValue = true; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/FileMessageBuilder.cs b/smtpserver/Rnwood.SmtpServer/FileMessageBuilder.cs new file mode 100644 index 000000000..21b57e00b --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/FileMessageBuilder.cs @@ -0,0 +1,119 @@ +// +// 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.IO; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Implements a message builder which will build a . +/// +/// +public class FileMessageBuilder : IMessageBuilder +{ +#pragma warning disable CA2213 // Disposable fields should be disposed + private readonly FileMessage message; +#pragma warning restore CA2213 // Disposable fields should be disposed + + private bool disposedValue; // To detect redundant calls + + /// + /// Initializes a new instance of the class. + /// + /// The file. + /// if set to true [keep on dispose]. + public FileMessageBuilder(FileInfo file, bool keepOnDispose) => message = new FileMessage(file, keepOnDispose); + + /// + public long? DeclaredMessageSize + { + get => message.DeclaredMessageSize; + + set => message.DeclaredMessageSize = value; + } + + /// + public bool EightBitTransport + { + get => message.EightBitTransport; + + set => message.EightBitTransport = value; + } + + /// + public string From + { + get => message.From; + + set => message.From = value; + } + + /// + public DateTime ReceivedDate + { + get => message.ReceivedDate; + + set => message.ReceivedDate = value; + } + + /// + public bool SecureConnection + { + get => message.SecureConnection; + + set => message.SecureConnection = value; + } + + /// + public ISession Session + { + get => message.Session; + + set => message.Session = value; + } + + /// + public ICollection Recipients => message.RecipientsList; + + /// + public async Task GetData() => await message.GetData().ConfigureAwait(false); + + /// + public Task ToMessage() => Task.FromResult(message); + + /// + public Task WriteData() => Task.FromResult(message.File.OpenWrite()); + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// + /// true to release both managed and unmanaged resources; false to release only + /// unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + } + + disposedValue = true; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/FileSession.cs b/smtpserver/Rnwood.SmtpServer/FileSession.cs new file mode 100644 index 000000000..d52e37100 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/FileSession.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.IO; +using System.Net; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Implements an where the session log is saved to a file. +/// +/// +public class FileSession : AbstractSession +{ + private readonly FileInfo file; + + private readonly bool keepOnDispose; + + /// + /// Initializes a new instance of the class. + /// + /// The clientAddress. + /// The startDate. + /// The file. + /// The keepOnDispose. + public FileSession(IPAddress clientAddress, DateTime startDate, FileInfo file, bool keepOnDispose) + : base(clientAddress, startDate) + { + this.file = file; + this.keepOnDispose = keepOnDispose; + } + + /// + public override async Task AppendLineToSessionLog(string text) + { + using (StreamWriter writer = file.AppendText()) + { + await writer.WriteLineAsync(text).ConfigureAwait(false); + } + } + + /// + public override Task GetLog() => Task.FromResult(file.OpenText()); + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// + /// true to release both managed and unmanaged resources; false to release only + /// unmanaged resources. + /// + protected override void Dispose(bool disposing) + { + if (disposing && !keepOnDispose && file.Exists) + { + file.Delete(); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/IConnection.cs b/smtpserver/Rnwood.SmtpServer/IConnection.cs new file mode 100644 index 000000000..e1d632f4f --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/IConnection.cs @@ -0,0 +1,106 @@ +// +// 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.IO; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Extensions; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface IConnection +{ + /// + /// Gets the current message which has been started by the MAIL FROM command but not yet completed with + /// a valid response from the server after the DATA command. + /// + IMessageBuilder CurrentMessage { get; } + + /// + /// Gets a list of extensions which are available for this connection. + /// + IReadOnlyCollection ExtensionProcessors { get; } + + /// + /// Gets the MailVerb. + /// + MailVerb MailVerb { get; } + + /// + /// Gets the Server. + /// + ISmtpServer Server { get; } + + /// + /// Gets the Session. + /// + IEditableSession Session { get; } + + /// + /// Gets the VerbMap. + /// + IVerbMap VerbMap { get; } + + /// + /// Occurs when connection is closed. + /// + event AsyncEventHandler ConnectionClosedEventHandler; + + /// + /// Aborts the current message started by the MAIL FROM command. + /// + /// A representing the async operation. + Task AbortMessage(); + + /// + /// Applies a filter to the stream replacing the stream that this connection is reading/writing to with a new one. This + /// method is used to implement TLS etc. + /// + /// The filter. + /// A representing the async operation. + Task ApplyStreamFilter(Func> filter); + + /// + /// Closes the connection. + /// + /// A representing the async operation. + Task CloseConnection(); + + /// + /// Commits the current message. + /// + /// A representing the async operation. + Task CommitMessage(); + + /// + /// Creates and returns a new message and sets it as the current message. + /// + /// A representing the async operation. + Task NewMessage(); + + /// + /// Reads the next line from the client and returns it. + /// + /// A representing the async operation. + Task ReadLine(); + + /// + /// Writes an to the client. + /// + /// The response. + /// A representing the async operation. + Task WriteResponse(SmtpResponse response); + + /// + /// Reads bytes until CRLF and returns them + /// + /// + Task ReadLineBytes(); +} diff --git a/smtpserver/Rnwood.SmtpServer/IConnectionChannel.cs b/smtpserver/Rnwood.SmtpServer/IConnectionChannel.cs new file mode 100644 index 000000000..7fb977018 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/IConnectionChannel.cs @@ -0,0 +1,95 @@ +// +// 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.IO; +using System.Net; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Represents a channel connecting the client and server. +/// +/// +public interface IConnectionChannel : IDisposable +{ + /// + /// Gets the client ip address. + /// + /// + /// The client ip address. + /// + IPAddress ClientIPAddress { get; } + + /// + /// Gets a value indicating whether this instance is connected. + /// + /// + /// true if this instance is connected; otherwise, false. + /// + bool IsConnected { get; } + + /// + /// Gets or sets the receive timeout after which if data is expected but not received, the connection will be + /// terminated. + /// + /// + /// The receive timeout. + /// + TimeSpan ReceiveTimeout { get; set; } + + /// + /// Gets or sets the send timeout after which is data is being attempted to be sent but not completed, the connection + /// will be terminated. + /// + /// + /// The send timeout. + /// + TimeSpan SendTimeout { get; set; } + + /// + /// Occurs when the channel is closed. + /// + event AsyncEventHandler ClosedEventHandler; + + /// + /// Applies the a filter to the stream which is used to read data from the channel. + /// + /// The filter. + /// A representing the async operation. + Task ApplyStreamFilter(Func> filter); + + /// + /// Closes the channel and notifies users via the event. + /// + /// A representing the async operation. + Task Close(); + + /// + /// Flushes outgoing data. + /// + /// A representing the async operation. + Task Flush(); + + /// + /// Reads the next line from the channel. + /// + /// A representing the async operation. + Task ReadLine(); + + /// + /// Writes a line of text to the client. + /// + /// The text. + /// A representing the async operation. + Task WriteLine(string text); + + /// + /// Reads bytes until CRLF and returns them + /// + /// + Task ReadLineBytes(); +} diff --git a/smtpserver/Rnwood.SmtpServer/ICurrentDateTimeProvider.cs b/smtpserver/Rnwood.SmtpServer/ICurrentDateTimeProvider.cs new file mode 100644 index 000000000..c142701ae --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ICurrentDateTimeProvider.cs @@ -0,0 +1,20 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface ICurrentDateTimeProvider +{ + /// + /// Returns the current date and time. + /// + /// The . + DateTime GetCurrentDateTime(); +} diff --git a/smtpserver/Rnwood.SmtpServer/IEditableSession.cs b/smtpserver/Rnwood.SmtpServer/IEditableSession.cs new file mode 100644 index 000000000..b60b9828d --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/IEditableSession.cs @@ -0,0 +1,90 @@ +// +// 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.Net; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Extensions.Auth; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface IEditableSession : ISession +{ + /// + /// Gets or sets a value indicating whether Authenticated. + /// + new bool Authenticated { get; set; } + + /// + /// Gets or sets the credentials used during authentication. + /// + new IAuthenticationCredentials AuthenticationCredentials { get; set; } + + /// + /// Gets or sets the client IP address. + /// + new IPAddress ClientAddress { get; set; } + + /// + /// Gets or sets the client name recevied in HELO or EHLO request. + /// + new string ClientName { get; set; } + + /// + /// Gets or sets a value indicating whether the session completed without an error. + /// + new bool CompletedNormally { get; set; } + + /// + /// Gets or sets the date and time the session ended. + /// + new DateTime? EndDate { get; set; } + + /// + /// Gets or sets a value indicating whether a secure SSL/TLS channel was established. + /// + new bool SecureConnection { get; set; } + + /// + /// Gets or sets. + /// + new Exception SessionError { get; set; } + + /// + /// Gets or sets the SessionErrorType. + /// + new SessionErrorType SessionErrorType { get; set; } + + /// + /// Gets or sets the StartDate. + /// + new DateTime StartDate { get; set; } + + /// + /// Adds a message to this session. + /// + /// The message. + Task AddMessage(IMessage message); + + /// Appends a line of text to the session log. + /// The text. + /// A representing the asynchronous operation. + Task AppendLineToSessionLog(string text); + + /// + /// Increments the bad command counter. + /// + /// A representing the asynchronous operation. + Task IncrementBadCommandCounter(); + + /// + /// Resets the bad command counter. + /// + /// A representing the asynchronous operation. + Task ResetBadCommandCounter(); +} diff --git a/smtpserver/Rnwood.SmtpServer/IMessage.cs b/smtpserver/Rnwood.SmtpServer/IMessage.cs new file mode 100644 index 000000000..cf8abde07 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/IMessage.cs @@ -0,0 +1,60 @@ +// +// 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.IO; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface IMessage : IDisposable +{ + /// + /// Gets the size of the message as declared by the client using the SIZE extension to the MAIL FROM command, or null + /// if not specified by the client. + /// + long? DeclaredMessageSize { get; } + + /// + /// Gets a value indicating whether the messaage was received over a 8-bit 'clean' connection using the 8BITMIME + /// extension. + /// + bool EightBitTransport { get; } + + /// + /// Gets the sender of the message as specified by the client when sending MAIL FROM command. + /// + string From { get; } + + /// + /// Gets the date the message was received by the server. + /// + DateTime ReceivedDate { get; } + + /// + /// Gets a value indicating whether if message was received over a secure connection. + /// + bool SecureConnection { get; } + + /// + /// Gets the Session message was received on. + /// + ISession Session { get; } + + /// + /// Gets the recipient of the message as specified by the client when sending RCPT TO command. + /// + IReadOnlyCollection Recipients { get; } + + /// + /// Gets a stream which returns the message data. + /// + /// A representing the async operation. + Task GetData(); +} diff --git a/smtpserver/Rnwood.SmtpServer/IMessageBuilder.cs b/smtpserver/Rnwood.SmtpServer/IMessageBuilder.cs new file mode 100644 index 000000000..403bbdb9e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/IMessageBuilder.cs @@ -0,0 +1,75 @@ +// +// 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.IO; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface IMessageBuilder : IDisposable +{ + /// + /// Gets or sets the message size declared by the client using the SIZE extension. + /// + long? DeclaredMessageSize { get; set; } + + /// + /// Gets or sets a value indicating whether the message was received over an 8-bit clean channel. + /// + bool EightBitTransport { get; set; } + + /// + /// Gets or sets the From. + /// + string From { get; set; } + + /// + /// Gets or sets the date the message was received. + /// + DateTime ReceivedDate { get; set; } + + /// + /// Gets or sets a value indicating whether the message is being received over a secure connection. + /// + bool SecureConnection { get; set; } + + /// + /// Gets or sets the Session this message is being received in. + /// + ISession Session { get; set; } + + /// + /// Gets the recipients of the message as specified in the RCPT TO command. + /// + /// + /// The recipients. + /// + ICollection Recipients { get; } + + /// + /// Gets a read only stream containing the message data. + /// + /// + /// A representing the async operation. + /// + Task GetData(); + + /// + /// Turns the editable messge into a read only message. + /// + /// A representing the async operation. + Task ToMessage(); + + /// + /// Returns a stream which can be used to write to the message data. + /// + /// A representing the async operation. + Task WriteData(); +} diff --git a/smtpserver/Rnwood.SmtpServer/IParameterProcessor.cs b/smtpserver/Rnwood.SmtpServer/IParameterProcessor.cs new file mode 100644 index 000000000..d205d9d95 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/IParameterProcessor.cs @@ -0,0 +1,23 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface IParameterProcessor +{ + /// + /// Processes the parameter which has the and specified. + /// + /// The connection. + /// The key. + /// The value. + /// A representing the async operation. + Task SetParameter(IConnection connection, string key, string value); +} diff --git a/smtpserver/Rnwood.SmtpServer/IRandomIntegerGenerator.cs b/smtpserver/Rnwood.SmtpServer/IRandomIntegerGenerator.cs new file mode 100644 index 000000000..f2c2e393a --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/IRandomIntegerGenerator.cs @@ -0,0 +1,22 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface IRandomIntegerGenerator +{ + /// + /// Generates a random integer in a specfied range. + /// + /// The minValue. + /// The maxValue. + /// + /// The . + /// + int GenerateRandomInteger(int minValue, int maxValue); +} diff --git a/smtpserver/Rnwood.SmtpServer/IServerBehaviour.cs b/smtpserver/Rnwood.SmtpServer/IServerBehaviour.cs new file mode 100644 index 000000000..869d91575 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/IServerBehaviour.cs @@ -0,0 +1,193 @@ +// +// 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.Net; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Extensions; +using Rnwood.SmtpServer.Extensions.Auth; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface IServerBehaviour +{ + /// + /// Gets the DomainName + /// Gets domain name reported by the server to clients. + /// + string DomainName { get; } + + /// + /// Gets the IP address on which to listen for connections. + /// + IPAddress IpAddress { get; } + + /// + /// Gets the max number of sequential bad commands before the client will be disconnected. + /// + int MaximumNumberOfSequentialBadCommands { get; } + + /// + /// Gets the TCP port number on which to listen for connections. + /// + int PortNumber { get; } + + /// + /// Gets an encoding which will be used if bytes received from the client cannot be decoded as ASCII/UTF-8. + /// + Encoding FallbackEncoding { get; } + + /// + /// Gets or sets the list of Supported Auth Mechanism Identifiers. + /// + HashSet EnabledAuthMechanisms { get; set; } + + /// + /// Gets the extensions that should be enabled for the specified connection. + /// + /// The connectionChannel. + /// A resulting in a sequence of for the extensions. + Task> GetExtensions(IConnectionChannel connectionChannel); + + /// + /// Gets the maximum allowed size of the message for the specified connection. + /// + /// The connection. + /// A representing the async operation. + Task GetMaximumMessageSize(IConnection connection); + + /// + /// Gets the receive timeout that should be used for the specified connection. + /// + /// The connection channel. + /// A representing the async operation. + Task GetReceiveTimeout(IConnectionChannel connectionChannel); + + /// + /// Gets the send timeout that should be used for the specified connection. + /// + /// The connectionChannel. + /// A representing the async operation. + Task GetSendTimeout(IConnectionChannel connectionChannel); + + /// + /// Gets the SSL certificate that should be used for the specified connection. + /// + /// The connection. + /// A representing the async operation. + Task GetSSLCertificate(IConnection connection); + + /// + /// Determines whether the specified auth mechanism should be enabled for the specified connection. + /// + /// The connection. + /// The auth mechanism. + /// A representing the async operation. + Task IsAuthMechanismEnabled(IConnection connection, IAuthMechanism authMechanism); + + /// + /// Gets a value indicating whether session logging should be enabled for the given connection. + /// + /// The connection. + /// A representing the async operation. + Task IsSessionLoggingEnabled(IConnection connection); + + /// + /// Gets a value indicating whether to run in SSL mode. + /// + /// The connection. + /// + /// A representing the asynchronous operation. + /// + Task IsSSLEnabled(IConnection connection); + + /// + /// Called when a command received in the specified SMTP session. + /// + /// The connection. + /// The command. + /// A representing the asynchronous operation. + Task OnCommandReceived(IConnection connection, SmtpCommand command); + + /// + /// Called when a new message is started using the MAIL FROM command and must returns the instance of + /// which will be used to record the message. + /// + /// The connection. + /// A representing the async operation. + Task OnCreateNewMessage(IConnection connection); + + /// + /// Called when a new session is started and must return an object which is used to record details about that session. + /// + /// The connectionChannel. + /// A representing the async operation. + Task OnCreateNewSession(IConnectionChannel connectionChannel); + + /// + /// Called when a message is received but not committed. + /// + /// The connection. + /// A representing the async operation. + Task OnMessageCompleted(IConnection connection); + + /// + /// Called when a new message is received by the server. + /// + /// The connection. + /// The message. + /// A representing the async operation. + Task OnMessageReceived(IConnection connection, IMessage message); + + /// + /// Called when a new recipient is requested for a message using the MAIL FROM command. + /// + /// The connection. + /// The message. + /// The recipient. + /// A representing the async operation. + Task OnMessageRecipientAdding(IConnection connection, IMessageBuilder message, string recipient); + + /// + /// Called when a new message is started in the specified session. + /// + /// The connection. + /// From. + /// A representing the asynchronous operation. + Task OnMessageStart(IConnection connection, string from); + + /// + /// Called when a SMTP session is completed. + /// + /// The connection. + /// The session. + /// A representing the asynchronous operation. + Task OnSessionCompleted(IConnection connection, ISession session); + + /// + /// Called when a new SMTP session is started. + /// + /// The connection. + /// The session. + /// A representing the asynchronous operation. + Task OnSessionStarted(IConnection connection, ISession session); + + /// + /// Validates the authentication request to determine if the supplied details + /// are correct. + /// + /// The connection. + /// The authentication request. + /// A representing the async operation. + Task ValidateAuthenticationCredentials( + IConnection connection, + IAuthenticationCredentials authenticationRequest); +} diff --git a/smtpserver/Rnwood.SmtpServer/ISession.cs b/smtpserver/Rnwood.SmtpServer/ISession.cs new file mode 100644 index 000000000..5ff844900 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ISession.cs @@ -0,0 +1,91 @@ +// +// 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.IO; +using System.Net; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Extensions.Auth; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface ISession : IDisposable +{ + /// + /// Gets a value indicating whether this the client provided authentication. + /// + bool Authenticated { get; } + + /// + /// Gets the AuthenticationCredentials. + /// + IAuthenticationCredentials AuthenticationCredentials { get; } + + /// + /// Gets the IP address of the client that established this session. + /// + IPAddress ClientAddress { get; } + + /// + /// Gets the ClientName + /// Gets or sets the name of the client as reported in its HELO/EHLO command + /// or null. + /// + string ClientName { get; } + + /// + /// Gets a value indicating whether this completed normally (by the client issuing a + /// QUIT command) + /// as opposed to abormal termination such as a connection timeout or unhandled errors in the server. + /// + bool CompletedNormally { get; } + + /// + /// Gets the date the session ended. + /// + DateTime? EndDate { get; } + + /// + /// Gets a value indicating whether the session is over a secure connection. + /// + bool SecureConnection { get; } + + /// + /// Gets the error that caused the session to terminate if it didn't complete normally. + /// + Exception SessionError { get; } + + /// + /// Gets a classification of the type of error which was experienced. + /// + SessionErrorType SessionErrorType { get; } + + /// + /// Gets the date the session started. + /// + DateTime StartDate { get; } + + /// + /// Indicates the current number of bad commands this client has sent in a row. + /// + int NumberOfBadCommandsInARow { get; } + + /// + /// Gets the session log (all communication between the client and server) + /// if session logging is enabled. + /// + /// A which will read from the session log. + Task GetLog(); + + /// + /// Gets list of messages received in this session. + /// + /// A read only list of messages. + Task> GetMessages(); +} diff --git a/smtpserver/Rnwood.SmtpServer/ISmtpServer.cs b/smtpserver/Rnwood.SmtpServer/ISmtpServer.cs new file mode 100644 index 000000000..b4d7d20e8 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ISmtpServer.cs @@ -0,0 +1,19 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public interface ISmtpServer : IDisposable +{ + /// + /// Gets the Behaviour. + /// + IServerBehaviour Behaviour { get; } +} diff --git a/smtpserver/Rnwood.SmtpServer/Logging.cs b/smtpserver/Rnwood.SmtpServer/Logging.cs new file mode 100644 index 000000000..871a7ba7e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Logging.cs @@ -0,0 +1,22 @@ +// +// 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 Microsoft.Extensions.Logging; + +namespace Rnwood.SmtpServer; + +/// +/// Helper class implementing logging. +/// +internal static class Logging +{ + /// + /// Gets the logging factory. + /// + /// + /// The factory. + /// + public static ILoggerFactory Factory { get; } = new LoggerFactory(); +} diff --git a/smtpserver/Rnwood.SmtpServer/MemoryMessage.cs b/smtpserver/Rnwood.SmtpServer/MemoryMessage.cs new file mode 100644 index 000000000..b3e2d276e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/MemoryMessage.cs @@ -0,0 +1,113 @@ +// +// 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.IO; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class MemoryMessage : IMessage +{ + private bool disposedValue; // To detect redundant calls + + /// + /// Initializes a new instance of the class. + /// + public MemoryMessage() + { + } + + /// + /// Gets or sets the message data. + /// + /// + /// The data. + /// + internal byte[] Data { get; set; } + + /// + /// Gets the recipients list. + /// + /// + /// The recipients list. + /// + internal List RecipientsList { get; } = new(); + + /// + /// Gets the DeclaredMessageSize. + /// + public long? DeclaredMessageSize { get; internal set; } + + /// + /// Gets a value indicating whether EightBitTransport. + /// + public bool EightBitTransport { get; internal set; } + + /// + /// Gets the From. + /// + public string From { get; internal set; } + + /// + /// Gets the ReceivedDate. + /// + public DateTime ReceivedDate { get; internal set; } + + /// + /// Gets a value indicating whether if message was received over a secure connection. + /// + public bool SecureConnection { get; internal set; } + + /// + /// Gets the Session message was received on. + /// + public ISession Session { get; internal set; } + + /// + /// Gets the recipient of the message as specified by the client when sending RCPT TO command. + /// + public IReadOnlyCollection Recipients => RecipientsList.AsReadOnly(); + + /// + /// Gets a stream which returns the message data. + /// + /// + /// A representing the async operation. + /// + public Task GetData() => + Task.FromResult( + new MemoryStream( + Data ?? Array.Empty(), + false)); + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// + /// true to release both managed and unmanaged resources; false to release only + /// unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + disposedValue = true; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/MemoryMessageBuilder.cs b/smtpserver/Rnwood.SmtpServer/MemoryMessageBuilder.cs new file mode 100644 index 000000000..04b9c222c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/MemoryMessageBuilder.cs @@ -0,0 +1,125 @@ +// +// 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.IO; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class MemoryMessageBuilder : IMessageBuilder +{ + private readonly MemoryMessage message; + + private bool disposedValue; // To detect redundant calls + + /// + /// Initializes a new instance of the class. + /// + public MemoryMessageBuilder() + : this(new MemoryMessage()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + protected MemoryMessageBuilder(MemoryMessage message) => this.message = message; + + /// + public long? DeclaredMessageSize + { + get => message.DeclaredMessageSize; + + set => message.DeclaredMessageSize = value; + } + + /// + public bool EightBitTransport + { + get => message.EightBitTransport; + + set => message.EightBitTransport = value; + } + + /// + public string From + { + get => message.From; + + set => message.From = value; + } + + /// + public DateTime ReceivedDate + { + get => message.ReceivedDate; + + set => message.ReceivedDate = value; + } + + /// + public bool SecureConnection + { + get => message.SecureConnection; + + set => message.SecureConnection = value; + } + + /// + public ISession Session + { + get => message.Session; + + set => message.Session = value; + } + + /// + public ICollection Recipients => message.RecipientsList; + + /// + public async Task GetData() => await message.GetData().ConfigureAwait(false); + + /// + public virtual Task ToMessage() => Task.FromResult(message); + + /// + public Task WriteData() + { + CloseNotifyingMemoryStream stream = new CloseNotifyingMemoryStream(); + stream.Closing += (s, ea) => { message.Data = stream.ToArray(); }; + + return Task.FromResult(stream); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// + /// true to release both managed and unmanaged resources; false to release only + /// unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + disposedValue = true; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/MemorySession.cs b/smtpserver/Rnwood.SmtpServer/MemorySession.cs new file mode 100644 index 000000000..cc94b1470 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/MemorySession.cs @@ -0,0 +1,53 @@ +// +// 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.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class MemorySession : AbstractSession +{ + private readonly SmtpStreamWriter log; + + private readonly MemoryStream logStream = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The clientAddress. + /// The startDate. + public MemorySession(IPAddress clientAddress, DateTime startDate) + : base(clientAddress, startDate) => + log = new SmtpStreamWriter(logStream, false); + + /// + public override Task AppendLineToSessionLog(string text) + { + log.WriteLine(text); + return Task.CompletedTask; + } + + /// + public override Task GetLog() + { + log.Flush(); + return Task.FromResult(new StreamReader(new MemoryStream(logStream.ToArray(), false), + new UTF8Encoding(false, true), false)); + } + + /// + protected override void Dispose(bool disposing) + { + logStream.Dispose(); + log.Dispose(); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/MessageEventArgs.cs b/smtpserver/Rnwood.SmtpServer/MessageEventArgs.cs new file mode 100644 index 000000000..72df02cb0 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/MessageEventArgs.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 System; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class MessageEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The message. + public MessageEventArgs(IMessage message) => Message = message; + + /// + /// Gets the Message. + /// + public IMessage Message { get; private set; } +} diff --git a/smtpserver/Rnwood.SmtpServer/MessageStartEventArgs.cs b/smtpserver/Rnwood.SmtpServer/MessageStartEventArgs.cs new file mode 100644 index 000000000..26c6e98e5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/MessageStartEventArgs.cs @@ -0,0 +1,35 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class MessageStartEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The session. + /// The from address. + public MessageStartEventArgs(ISession session, string from) + { + Session = session; + From = from; + } + + /// + /// Gets the Session. + /// + public ISession Session { get; private set; } + + /// + /// Gets the from address. + /// + public string From { get; private set; } +} diff --git a/smtpserver/Rnwood.SmtpServer/Parameter.cs b/smtpserver/Rnwood.SmtpServer/Parameter.cs new file mode 100644 index 000000000..d775abc18 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Parameter.cs @@ -0,0 +1,100 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public sealed class Parameter : IEquatable +{ + /// + /// Initializes a new instance of the class. + /// + /// The name. + /// The value. + public Parameter(string name, string value) + { + Name = name; + Value = value; + } + + /// + /// Gets the Name. + /// + public string Name { get; } + + /// + /// Gets the Value. + /// + public string Value { get; } + + /// + /// Indicates whether the current object is equal to another object of the same type. + /// + /// The other. + /// + /// The . + /// + public bool Equals(Parameter other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return string.Equals(other.Name, Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(other.Value, Value, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether the specified , is equal to this instance. + /// + /// The obj. + /// + /// The . + /// + public override bool Equals(object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (!(obj is Parameter)) + { + return false; + } + + return Equals((Parameter)obj); + } + + /// + /// Returns a hash code for this instance. + /// + /// + /// The . + /// + public override int GetHashCode() + { + unchecked + { + return ((Name != null ? Name.ToUpperInvariant().GetHashCode() : 0) * 397) ^ + (Value != null ? Value.GetHashCode() : 0); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/ParameterParser.cs b/smtpserver/Rnwood.SmtpServer/ParameterParser.cs new file mode 100644 index 000000000..a115ec47c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ParameterParser.cs @@ -0,0 +1,39 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the which implements parsing of A=1 B=2 type string for command parameters. +/// +public class ParameterParser +{ + private readonly List parameters = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The arguments. + public ParameterParser(params string[] arguments) => Parse(arguments); + + /// + /// Gets the parameters which have been parsed from the arguments. + /// + public IReadOnlyCollection Parameters => parameters.ToArray(); + + private void Parse(string[] tokens) + { + foreach (string token in tokens) + { + string[] tokenParts = token.Split(new[] { '=' }, 2, StringSplitOptions.RemoveEmptyEntries); + string key = tokenParts[0]; + string value = tokenParts.Length > 1 ? tokenParts[1] : null; + parameters.Add(new Parameter(key, value)); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/ParameterProcessorMap.cs b/smtpserver/Rnwood.SmtpServer/ParameterProcessorMap.cs new file mode 100644 index 000000000..284ba66ba --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ParameterProcessorMap.cs @@ -0,0 +1,78 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Manages a set of processors which know how to manage the processing of parameter values +/// and handles dispatching of parameter values to them when a new command is received. +/// +public class ParameterProcessorMap +{ + private readonly Dictionary processors = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the processor which is registered for the parameter with the given + /// or null if none is found. + /// + /// The key. + /// The or null. + public IParameterProcessor GetProcessor(string key) + { + processors.TryGetValue(key, out IParameterProcessor result); + return result; + } + + /// + /// Processes a set of parameters using the registered processors. + /// + /// The connection. + /// The parameters. + /// The throwOnUnknownParameter. + /// A representing the async operation. + public Task Process(IConnection connection, ParameterParser parameters, bool throwOnUnknownParameter) + { + foreach (Parameter parameter in parameters.Parameters) + { + IParameterProcessor parameterProcessor = GetProcessor(parameter.Name); + + if (parameterProcessor != null) + { + parameterProcessor.SetParameter(connection, parameter.Name, parameter.Value); + } + else if (throwOnUnknownParameter) + { + throw new SmtpServerException( + new SmtpResponse( + StandardSmtpResponseCode.SyntaxErrorInCommandArguments, + "Parameter {0} is not recognised", + parameter.Name)); + } + } + + return Task.CompletedTask; + } + + /// + /// Processes a set of parameters using the registered processors. + /// + /// The connection. + /// The arguments. + /// The throwOnUnknownParameter. + /// A representing the async operation. + public async Task Process(IConnection connection, string[] arguments, bool throwOnUnknownParameter) => + await Process(connection, new ParameterParser(arguments), throwOnUnknownParameter).ConfigureAwait(false); + + /// + /// Sets the processor instance which will process the parameter with the given . + /// + /// The key. + /// The processor. + public void SetProcessor(string key, IParameterProcessor processor) => processors[key] = processor; +} diff --git a/smtpserver/Rnwood.SmtpServer/Properties/AssemblyInfo.cs b/smtpserver/Rnwood.SmtpServer/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..8f2cafc3b --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Properties/AssemblyInfo.cs @@ -0,0 +1,16 @@ +// +// 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.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Rnwood.SmtpServer")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCulture("")] +[assembly: ComVisible(false)] +[assembly: Guid("dce7b742-7d41-465d-9428-de586b9ee617")] +[assembly: InternalsVisibleTo("Rnwood.SmtpServer.Tests")] diff --git a/smtpserver/Rnwood.SmtpServer/RandomIntegerGenerator.cs b/smtpserver/Rnwood.SmtpServer/RandomIntegerGenerator.cs new file mode 100644 index 000000000..06232a66c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/RandomIntegerGenerator.cs @@ -0,0 +1,19 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class RandomIntegerGenerator : IRandomIntegerGenerator +{ + private static readonly Random random = new(); + + /// + public int GenerateRandomInteger(int minValue, int maxValue) => random.Next(minValue, maxValue); +} diff --git a/smtpserver/Rnwood.SmtpServer/RecipientAddingEventArgs.cs b/smtpserver/Rnwood.SmtpServer/RecipientAddingEventArgs.cs new file mode 100644 index 000000000..cc4ce6102 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/RecipientAddingEventArgs.cs @@ -0,0 +1,35 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class RecipientAddingEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The recipient being added. + public RecipientAddingEventArgs(IMessageBuilder message, string recipient) + { + Message = message; + Recipient = recipient; + } + + /// + /// Gets the Message. + /// + public IMessageBuilder Message { get; private set; } + + /// + /// Gets the Recipient. + /// + public string Recipient { get; private set; } +} diff --git a/smtpserver/Rnwood.SmtpServer/Rnwood.SmtpServer.csproj b/smtpserver/Rnwood.SmtpServer/Rnwood.SmtpServer.csproj new file mode 100644 index 000000000..2cef2a3bd --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Rnwood.SmtpServer.csproj @@ -0,0 +1,40 @@ + + + + Rnwood.SmtpServer is an SMTP server component that can be embedded in application to allow them to receive and process emails using the SMTP protocol. This component powers the popular Smtp4dev application. + Robert N. Wood + 3.0 + Robert N. Wood <rob@rnwood.co.uk> + Rnwood.SmtpServer + Library + Rnwood.SmtpServer + http://github.com/rnwood/smtpserver + https://github.com/rnwood/smtpserver/blob/master/LICENSE.md + 2.0.1 + false + false + false + true + snupkg + netstandard2.0 + default + + + + Rnwood.SmtpServer.xml + + + + + + + + + + + + + + + + diff --git a/smtpserver/Rnwood.SmtpServer/Rnwood.SmtpServer.xml b/smtpserver/Rnwood.SmtpServer/Rnwood.SmtpServer.xml new file mode 100644 index 000000000..b15c94fe7 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Rnwood.SmtpServer.xml @@ -0,0 +1,3477 @@ + + + + Rnwood.SmtpServer + + + + + Provides a base implementation for . + + + + + + Initializes a new instance of the class. + + The client address. + The start date. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + + + + + Releases unmanaged and - optionally - managed resources. + + + true to release both managed and unmanaged resources; false to release only + unmanaged resources. + + + + + Parses SMTP command arguments into an array of arguments. + Arguments are separated by spaces or are enclosed within <>s which may contain spaces and balanced <>s. + Example: + <Robert Wood<rob@rnwood.co.uk>> ARG2 ARG3 + Results in 3 arguments. + + + + + Initializes a new instance of the class. + + The text to parse. + + + + Gets the arguments parsed from the text. + + + The arguments. + + + + + Gets the Text which was parsed. + + + + + An ASCII encoding where the highest order bit is zeroed. + + + + + + Initializes a new instance of the class. + + + + + When overridden in a derived class, calculates the number of bytes produced by encoding a set of characters from + the specified character array. + + The character array containing the set of characters to encode. + The index of the first character to encode. + The number of characters to encode. + + The number of bytes produced by encoding the specified characters. + + + + + When overridden in a derived class, encodes a set of characters from the specified character array into the + specified byte array. + + The character array containing the set of characters to encode. + The index of the first character to encode. + The number of characters to encode. + The byte array to contain the resulting sequence of bytes. + The index at which to start writing the resulting sequence of bytes. + + The actual number of bytes written into bytes. + + + + + When overridden in a derived class, calculates the number of characters produced by decoding a sequence of bytes + from the specified byte array. + + The byte array containing the sequence of bytes to decode. + The index of the first byte to decode. + The number of bytes to decode. + + The number of characters produced by decoding the specified sequence of bytes. + + + + + When overridden in a derived class, decodes a sequence of bytes from the specified byte array into the specified + character array. + + The byte array containing the sequence of bytes to decode. + The index of the first byte to decode. + The number of bytes to decode. + The character array to contain the resulting set of characters. + The index at which to start writing the resulting set of characters. + + The actual number of characters written into chars. + + + + + When overridden in a derived class, calculates the maximum number of bytes produced by encoding the specified + number of characters. + + The number of characters to encode. + + The maximum number of bytes produced by encoding the specified number of characters. + + + + + When overridden in a derived class, calculates the maximum number of characters produced by decoding the specified + number of bytes. + + The number of bytes to decode. + + The maximum number of characters produced by decoding the specified number of bytes. + + + + + Gets the maximum number of characters the current object can return. + + + + + Initializes a new instance of the class. + + + An object that provides a fallback buffer for a decoder. + + + + + Represents an async event handler which accepts an parameter and a + parameter and returns a . + + The type of the second param. + The sender. + The e. + A task representing the async operation. + + + + Defines the . + + + + + Initializes a new instance of the class. + + The session + The credentials. + + + + Gets the session + + + + + Gets or sets the AuthenticationResult. + + + + + Gets the Credentials. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The smtpResponse. + + + + Initializes a new instance of the class. + + The smtpResponse. + The innerException. + + + + Initializes a new instance of the class. + + The message that describes the error. + + + + Initializes a new instance of the class. + + + + + Initializes a new instance of the class. + + The error message that explains the reason for the exception. + + The exception that is the cause of the current exception, or a null reference (Nothing in + Visual Basic) if no inner exception is specified. + + + + + Defines the which is a memory stream that fires an event when disposed. + + + + + Occurs when the stream is disposed. + + + + + Releases the unmanaged resources used by the class and optionally + releases the managed resources. + + The disposing. + + + + Defines the . + + + + + Initializes a new instance of the class. + + The command. + + + + Gets the Command. + + + + + Represents a single SMTP server from a client to the server. + + + + + Initializes a new instance of the class. + + The server. + The session. + The connection channel. + The verb map. + The extension processors. + + + + + + + + + + + + + + + + + + + + + + Gets a list of extensions which are available for this connection. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Returns a that represents this instance. + + + A that represents this instance. + + + + + Creates the a connection for the specified server and channel.. + + The server. + The connection channel. + The verb map. + An representing the async operation. + + + + Start the Tls stream. + + stream. + A representing the result of the asynchronous operation. + + + + Starts processing of this connection. + + A representing the async operation. + + + + Writes a line of text to the client. + + + The text optionally containing placeholders into which + are subtituted using . + + The arguments which are formatted into . + + The . + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The connection. + + + + Gets the Connection. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + + + + Initializes a new instance of the class. + + The message. + + + + Initializes a new instance of the class. + + The message. + The innerException. + + + + Implements using the real local date time. + + + + + + + + + Defines the DataAccessMode. + + + + + Defines the ForReading + + + + + Defines the ForWriting + + + + + A default subclass of which provides a default behaviour which is suitable for many + simple + applications. + + + + + + Initializes a new instance of the class. + Initializes a new SMTP over SSL server on port 465 using the + supplied SSL certificate. + + if set to true remote collections are allowed. + The SSL certificate to use for the server. + + + + Initializes a new instance of the class. + Initializes a new SMTP server on port 25. + + if set to true remote connections are allowed. + + + + Initializes a new instance of the class. + Initializes a new SMTP server on the specified port number. + + if set to true remote connections are allowed. + The port number. + + + + Initializes a new instance of the class. + Initializes a new SMTP over SSL server on the specified port number + using the supplied SSL certificate. + + if set to true remote connections are allowed. + The port number. + The TLS certificate to use for implicit TLS. + + + + Initializes a new instance of the class. + Initializes a new SMTP over SSL or SMTP with STARTTLS server on the specified port number + using the supplied SSL certificate. + + if set to true remote connections are allowed. + The domain name the server will send in greeting. + The port number. + The TLS certificate to use for implicit TLS. + The TLS certificate to use for STARTTLS. + + + + Initializes a new instance of the class. + Initializes a new SMTP server on the specified standard port number. + + if set to true connection from remote computers are allowed. + The standard port (or auto) to use. + + + + Gets the Behaviour. + + + + + Occurs when authentication results need to be validated. + + + + + Occurs when a message has been fully received but not yet acknowledged by the server. + + + + + Occurs when a message has been received and acknowledged by the server. + + + + + Occurs when a session is terminated. + + + + + Occurs when a new session is started, when a new client connects to the server. + + + + + Implements a default which is suitable for many basic uses. + + + + + + Initializes a new instance of the class. + + if set to true remote connections to the server are allowed. + + + + Initializes a new instance of the class. + + if set to true remote connections to the server are allowed. + The port number. + + + + Initializes a new instance of the class. + + if set to true remote connections to the server are allowed. + The port number. + The TLS certificate to use for implicit TLS. + + + + Initializes a new instance of the class. + + if set to true remote connections to the server are allowed. + The port number. + The TLS certificate to use for implicit TLS. + The TLS certificate to use for STARTTLS. + + + + Initializes a new instance of the class. + + if set to true remote connections to the server are allowed. + The domain name the server will send in greeting. + The port number. + The TLS certificate to use for implicit TLS. + The TLS certificate to use for STARTTLS. + + + + Initializes a new instance of the class. + + if set to true remote connections to the server are allowed. + The TLS certificate to use for implicit TLS. + + + + Gets or sets a List of active Auth Mechanism Identifiers. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Occurs when authentication credential provided by the client need to be validated. + + + + + Occurs when a command is received from a client. + + + + + Occurs when a message has been requested for a message. + + + + + Occurs when a message is received but not yet committed. + + + + + Occurs when a message is received and committed. + + + + + Occurs when a client session is closed. + + + + + Occurs when a new session is created, when a client connects to the server. + + + + + Occurs when a new message is started. + + + + + Defines the . + + + + + + + + Defines the which implements ANONYMOUS authentication. + + + + + + + + + + + + + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The connection. + + + + Gets the connection this processor is for. + + + The connection. + + + + + + + + + + + Defines the . + + + + + + + + Implements the AUTH extension for a connection. + + + + + + Defines the connection. + + + + + Initializes a new instance of the class. + + The connection. + + + + Gets the mechanism map which manages the list of available auth mechanisms. + + + The mechanism map. + + + + + + + + Determines whether the specified auth mechanism is enabled for the current connection. + + The mechanism. + A representing the async operation which yields true if enabled. + + + + Returns a sequence of all enabled auth mechanisms for the current connection. + + A representing the async operation. + + + + Defines the . + + + + + Defines the map. + + + + + Adds an auth mechanism to the map. + + The mechanism. + + + + Gets the auth mechanism which has been registered for the given identifier. + + The identifier. + The . + + + + Gets all registered auth mechanisms. + + The . + + + + Defines the . + + + + + Initializes a new instance of the class. + + The connection. + + + + Gets the connection this processor is for. + + + The connection. + + + + + + + + + + + Decodes a base64 encoded ASCII string and throws an exception if invalid. + + The data. + The decoded ASCII string. + If the base64 encoded string is invalid. + + + + Defines the AuthMechanismProcessorStatus. + + + + + Defines the Continue + + + + + Defines the Failed + + + + + Defines the Success + + + + + Authentication Mechanisms. + + + + + Return enumerable of all valid Auth Mechanisms. + + Enumerable collection of AuthMechanisms. + + + + Defines the . + + + + + Initializes a new instance of the class. + + The authExtensionProcessor. + + + + Gets the AuthExtensionProcessor. + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The username. + The challenge. + The challengeResponse. + + + + Gets the Challenge. + + + + + Gets the ChallengeResponse. + + + + + Gets the Username. + + + + + Validates the response sent by the client against a password specified in clear text. + + The password. + + The . + + + + + + + + Defines the implementing the CRAM-MD5 auth mechanism. + + + + + + + + + + + + + + + + + + + + Defines the . + + + + + Defines the dateTimeProvider. + + + + + Defines the random. + + + + + Defines the challenge. + + + + + Initializes a new instance of the class. + + The connection. + The random. + The dateTimeProvider. + + + + Initializes a new instance of the class. + + The connection. + The random. + The dateTimeProvider. + The challenge. + + + + + + + Represents credentials supplied by the client. + + + + + Gets a string representing the type of this credential. + + + + + Defines the which implements a single authentication mechansim for the server. + + + + + Gets the identifier for this AUTH mechanism as declared by the server in the EHELO response. + + + + + Gets a value indicating whether credentials are sent using plain text. + + + + + Creates an authentication mechanism processor for the provided connection. + + The connection. + + The . + + + + + Defines the which implements the state machine for a particular auth + mechnism for a single client connection. + + + + + Gets the Credentials supplied during this authentication. + + + + + Processes a response from the client and returns the result of the auth operation. + + The data. + + A representing the async operation. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The username. + The password. + + + + Defines the implementing the plain text LOGIN auth mechanism. + + + + + + + + + + + + + + + + + + + + Defines the . + + + + + Defines the username. + + + + + Initializes a new instance of the class. + + The connection. + + + + + + + Defines the States. + + + + + Defines the Initial + + + + + Defines the WaitingForUsername + + + + + Defines the WaitingForPassword + + + + + Defines the Completed + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The username. + The password. + + + + Defines the which implements the PLAIN auth mechanism. + + + + + + + + + + + + + + + + + + + + Defines the . + + + + + Defines the States. + + + + + Defines the Initial + + + + + Defines the AwaitingResponse + + + + + Initializes a new instance of the class. + + The connection. + + + + Gets or sets the State. + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The username. + The password. + + + + Gets the Password. + + + + + Gets the Username. + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The connection. + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The connection. + + + + Gets the Connection. + + + + + Returns the EHLO keywords which advertise this extension to the client. + + A representing the async operation. + + + + Defines the . + + + + + Creates the extension processor for a connection. + + The connection. + + The . + + + + + Defines the . + + + + + Returns a sequence of EHLO keywords which are output to advertise the support for this extension to the client. + + A representing the async operation. + + + + Defines the . + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The connection. + + + + Gets the connection this processor is for. + + + The connection. + + + + + + + + + + Implements the SMTPUTF8 extension. + + + Creates the extension processor for a connection. + The connection. + The . + + + + Defines the . + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The connection. + + + + Gets the connection this processor is for. + + + The connection. + + + + + + + + Defines the . + + + + + + + + Defines the AuthenticationResult. + + + + + Defines the Success + + + + + Defines the Failure + + + + + Defines the TemporaryFailure + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The file. + The keepOnDispose. + + + + Gets the file. + + + The file. + + + + + Gets the recipients list. + + + The recipients list. + + + + + Gets the DeclaredMessageSize. + + + + + Gets a value indicating whether EightBitTransport. + + + + + Gets the From. + + + + + Gets the ReceivedDate. + + + + + Gets a value indicating whether SecureConnection. + + + + + Gets the Session. + + + + + Gets the To. + + + + + Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + + + + + Gets a stream which returns the message data. + + + A representing the async operation. + + + + + Releases unmanaged and - optionally - managed resources. + + + true to release both managed and unmanaged resources; false to release only + unmanaged resources. + + + + + Implements a message builder which will build a . + + + + + + Initializes a new instance of the class. + + The file. + if set to true [keep on dispose]. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + + + + + Releases unmanaged and - optionally - managed resources. + + + true to release both managed and unmanaged resources; false to release only + unmanaged resources. + + + + + Implements an where the session log is saved to a file. + + + + + + Initializes a new instance of the class. + + The clientAddress. + The startDate. + The file. + The keepOnDispose. + + + + + + + + + + Releases unmanaged and - optionally - managed resources. + + + true to release both managed and unmanaged resources; false to release only + unmanaged resources. + + + + + Defines the . + + + + + Gets the current message which has been started by the MAIL FROM command but not yet completed with + a valid response from the server after the DATA command. + + + + + Gets a list of extensions which are available for this connection. + + + + + Gets the MailVerb. + + + + + Gets the Server. + + + + + Gets the Session. + + + + + Gets the VerbMap. + + + + + Occurs when connection is closed. + + + + + Aborts the current message started by the MAIL FROM command. + + A representing the async operation. + + + + Applies a filter to the stream replacing the stream that this connection is reading/writing to with a new one. This + method is used to implement TLS etc. + + The filter. + A representing the async operation. + + + + Closes the connection. + + A representing the async operation. + + + + Commits the current message. + + A representing the async operation. + + + + Creates and returns a new message and sets it as the current message. + + A representing the async operation. + + + + Reads the next line from the client and returns it. + + A representing the async operation. + + + + Writes an to the client. + + The response. + A representing the async operation. + + + + Reads bytes until CRLF and returns them + + + + + + Represents a channel connecting the client and server. + + + + + + Gets the client ip address. + + + The client ip address. + + + + + Gets a value indicating whether this instance is connected. + + + true if this instance is connected; otherwise, false. + + + + + Gets or sets the receive timeout after which if data is expected but not received, the connection will be + terminated. + + + The receive timeout. + + + + + Gets or sets the send timeout after which is data is being attempted to be sent but not completed, the connection + will be terminated. + + + The send timeout. + + + + + Occurs when the channel is closed. + + + + + Applies the a filter to the stream which is used to read data from the channel. + + The filter. + A representing the async operation. + + + + Closes the channel and notifies users via the event. + + A representing the async operation. + + + + Flushes outgoing data. + + A representing the async operation. + + + + Reads the next line from the channel. + + A representing the async operation. + + + + Writes a line of text to the client. + + The text. + A representing the async operation. + + + + Reads bytes until CRLF and returns them + + + + + + Defines the . + + + + + Returns the current date and time. + + The . + + + + Defines the . + + + + + Gets or sets a value indicating whether Authenticated. + + + + + Gets or sets the credentials used during authentication. + + + + + Gets or sets the client IP address. + + + + + Gets or sets the client name recevied in HELO or EHLO request. + + + + + Gets or sets a value indicating whether the session completed without an error. + + + + + Gets or sets the date and time the session ended. + + + + + Gets or sets a value indicating whether a secure SSL/TLS channel was established. + + + + + Gets or sets. + + + + + Gets or sets the SessionErrorType. + + + + + Gets or sets the StartDate. + + + + + Adds a message to this session. + + The message. + + + Appends a line of text to the session log. + The text. + A representing the asynchronous operation. + + + + Increments the bad command counter. + + A representing the asynchronous operation. + + + + Resets the bad command counter. + + A representing the asynchronous operation. + + + + Defines the . + + + + + Gets the size of the message as declared by the client using the SIZE extension to the MAIL FROM command, or null + if not specified by the client. + + + + + Gets a value indicating whether the messaage was received over a 8-bit 'clean' connection using the 8BITMIME + extension. + + + + + Gets the sender of the message as specified by the client when sending MAIL FROM command. + + + + + Gets the date the message was received by the server. + + + + + Gets a value indicating whether if message was received over a secure connection. + + + + + Gets the Session message was received on. + + + + + Gets the recipient of the message as specified by the client when sending RCPT TO command. + + + + + Gets a stream which returns the message data. + + A representing the async operation. + + + + Defines the . + + + + + Gets or sets the message size declared by the client using the SIZE extension. + + + + + Gets or sets a value indicating whether the message was received over an 8-bit clean channel. + + + + + Gets or sets the From. + + + + + Gets or sets the date the message was received. + + + + + Gets or sets a value indicating whether the message is being received over a secure connection. + + + + + Gets or sets the Session this message is being received in. + + + + + Gets the recipients of the message as specified in the RCPT TO command. + + + The recipients. + + + + + Gets a read only stream containing the message data. + + + A representing the async operation. + + + + + Turns the editable messge into a read only message. + + A representing the async operation. + + + + Returns a stream which can be used to write to the message data. + + A representing the async operation. + + + + Defines the . + + + + + Processes the parameter which has the and specified. + + The connection. + The key. + The value. + A representing the async operation. + + + + Defines the . + + + + + Generates a random integer in a specfied range. + + The minValue. + The maxValue. + + The . + + + + + Defines the . + + + + + Gets the DomainName + Gets domain name reported by the server to clients. + + + + + Gets the IP address on which to listen for connections. + + + + + Gets the max number of sequential bad commands before the client will be disconnected. + + + + + Gets the TCP port number on which to listen for connections. + + + + + Gets an encoding which will be used if bytes received from the client cannot be decoded as ASCII/UTF-8. + + + + + Gets or sets the list of Supported Auth Mechanism Identifiers. + + + + + Gets the extensions that should be enabled for the specified connection. + + The connectionChannel. + A resulting in a sequence of for the extensions. + + + + Gets the maximum allowed size of the message for the specified connection. + + The connection. + A representing the async operation. + + + + Gets the receive timeout that should be used for the specified connection. + + The connection channel. + A representing the async operation. + + + + Gets the send timeout that should be used for the specified connection. + + The connectionChannel. + A representing the async operation. + + + + Gets the SSL certificate that should be used for the specified connection. + + The connection. + A representing the async operation. + + + + Determines whether the specified auth mechanism should be enabled for the specified connection. + + The connection. + The auth mechanism. + A representing the async operation. + + + + Gets a value indicating whether session logging should be enabled for the given connection. + + The connection. + A representing the async operation. + + + + Gets a value indicating whether to run in SSL mode. + + The connection. + + A representing the asynchronous operation. + + + + + Called when a command received in the specified SMTP session. + + The connection. + The command. + A representing the asynchronous operation. + + + + Called when a new message is started using the MAIL FROM command and must returns the instance of + which will be used to record the message. + + The connection. + A representing the async operation. + + + + Called when a new session is started and must return an object which is used to record details about that session. + + The connectionChannel. + A representing the async operation. + + + + Called when a message is received but not committed. + + The connection. + A representing the async operation. + + + + Called when a new message is received by the server. + + The connection. + The message. + A representing the async operation. + + + + Called when a new recipient is requested for a message using the MAIL FROM command. + + The connection. + The message. + The recipient. + A representing the async operation. + + + + Called when a new message is started in the specified session. + + The connection. + From. + A representing the asynchronous operation. + + + + Called when a SMTP session is completed. + + The connection. + The session. + A representing the asynchronous operation. + + + + Called when a new SMTP session is started. + + The connection. + The session. + A representing the asynchronous operation. + + + + Validates the authentication request to determine if the supplied details + are correct. + + The connection. + The authentication request. + A representing the async operation. + + + + Defines the . + + + + + Gets a value indicating whether this the client provided authentication. + + + + + Gets the AuthenticationCredentials. + + + + + Gets the IP address of the client that established this session. + + + + + Gets the ClientName + Gets or sets the name of the client as reported in its HELO/EHLO command + or null. + + + + + Gets a value indicating whether this completed normally (by the client issuing a + QUIT command) + as opposed to abormal termination such as a connection timeout or unhandled errors in the server. + + + + + Gets the date the session ended. + + + + + Gets a value indicating whether the session is over a secure connection. + + + + + Gets the error that caused the session to terminate if it didn't complete normally. + + + + + Gets a classification of the type of error which was experienced. + + + + + Gets the date the session started. + + + + + Indicates the current number of bad commands this client has sent in a row. + + + + + Gets the session log (all communication between the client and server) + if session logging is enabled. + + A which will read from the session log. + + + + Gets list of messages received in this session. + + A read only list of messages. + + + + Defines the . + + + + + Gets the Behaviour. + + + + + Helper class implementing logging. + + + + + Gets the logging factory. + + + The factory. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + + + + Gets or sets the message data. + + + The data. + + + + + Gets the recipients list. + + + The recipients list. + + + + + Gets the DeclaredMessageSize. + + + + + Gets a value indicating whether EightBitTransport. + + + + + Gets the From. + + + + + Gets the ReceivedDate. + + + + + Gets a value indicating whether if message was received over a secure connection. + + + + + Gets the Session message was received on. + + + + + Gets the recipient of the message as specified by the client when sending RCPT TO command. + + + + + Gets a stream which returns the message data. + + + A representing the async operation. + + + + + Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + + + + + Releases unmanaged and - optionally - managed resources. + + + true to release both managed and unmanaged resources; false to release only + unmanaged resources. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + + + + Initializes a new instance of the class. + + The message. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + + + + + Releases unmanaged and - optionally - managed resources. + + + true to release both managed and unmanaged resources; false to release only + unmanaged resources. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The clientAddress. + The startDate. + + + + + + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The message. + + + + Gets the Message. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The session. + The from address. + + + + Gets the Session. + + + + + Gets the from address. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The name. + The value. + + + + Gets the Name. + + + + + Gets the Value. + + + + + Indicates whether the current object is equal to another object of the same type. + + The other. + + The . + + + + + Determines whether the specified , is equal to this instance. + + The obj. + + The . + + + + + Returns a hash code for this instance. + + + The . + + + + + Defines the which implements parsing of A=1 B=2 type string for command parameters. + + + + + Initializes a new instance of the class. + + The arguments. + + + + Gets the parameters which have been parsed from the arguments. + + + + + Manages a set of processors which know how to manage the processing of parameter values + and handles dispatching of parameter values to them when a new command is received. + + + + + Gets the processor which is registered for the parameter with the given + or null if none is found. + + The key. + The or null. + + + + Processes a set of parameters using the registered processors. + + The connection. + The parameters. + The throwOnUnknownParameter. + A representing the async operation. + + + + Processes a set of parameters using the registered processors. + + The connection. + The arguments. + The throwOnUnknownParameter. + A representing the async operation. + + + + Sets the processor instance which will process the parameter with the given . + + The key. + The processor. + + + + Defines the . + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The message. + The recipient being added. + + + + Gets the Message. + + + + + Gets the Recipient. + + + + + Defines the ServerStopBehaviour. + + + + + Defines the WaitForExistingConnections + + + + + Defines the KillExistingConnections + + + + + A high level classification of common session termination errors. + + + + + Indicates that there was no error. + + + + + Indicates a network/IO error such as connection timeout or aborted connection. + + + + + Indicates an unhandled exception in the server or an extension which caused the connection to be terminated. + + + + + Indicates the connection was terminated because the server was shut down. + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The session. + + + + Gets the Session. + + + + + Defines the which implements parsing of an SMTP command received from client to server. + + + + + Initializes a new instance of the class. + + The text. + + + + Gets the arguments supplied after the VERB in the command as a single string. + + + + + Gets a value indicating whether IsEmpty. + + + + + Gets a value indicating whether this command is valid - i.e. matching the pattern allowed. + + + + + Gets the Text. + + + + + Gets the Verb. + + + + + Indicates whether the current object is equal to another object of the same type. + + The other. + + The . + + + + + Determines whether the specified , is equal to this instance. + + The obj. + + The . + + + + Converts to string. + A that represents this instance. + + + + Returns a hash code for this instance. + + + The . + + + + + Represents a SMTP response from server to client which is represented by a numeric code an optional descriptive + text. + + + + + + Initializes a new instance of the class using any code represented as a number. + + The code. + + The message format string. Including placeholders where will + be substituted in. + + The arguments used to fill in placeholders in . + + + + Initializes a new instance of the class using an enum of standard responses. + + The code. + The message format string. + The arguments. + + + + Gets the Code. + + + + + Gets a value indicating whether this response represents an error. + Error responses have a in the range 500-599. + + + + + Gets a value indicating whether this response represent success. + Successful responses have a in the range 200-299. + + + + + Gets the Message. + + + + + Indicates whether the current response is equal to another. Both message and code must be equal. + + An object to compare with this object. + + true if the current object is equal to the other parameter; otherwise, false. + + + + + Determines whether the specified , is equal to this instance. + Objects are equal if they are both instances of and have the same + and . + + The to compare with this instance. + + true if the specified is equal to this instance; otherwise, false. + + + + + Returns a hash code for this instance. + + + A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. + + + + + Returns a that represents the response. + + A that represents the response. + + + + Defines the . + + + + + Defines the activeConnections. + + + + + Defines the logger. + + + + + Defines the nextConnectionEvent. + + + + + Defines the coreTask. + + + + + Defines the disposedValue. + + + + + Defines the isRunning. + + + + + Defines the listener. + + + + + Initializes a new instance of the class. + + The behaviour. + + + + Gets the ActiveConnections. + + Note: this is not thread-safe for enumeration. + + + + Gets a value indicating whether IsRunning + Gets or sets a value indicating whether the server is currently running. + + + + + Gets the PortNumber. + + + + + Gets the Behaviour. + + + + + Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + + + + + Defines the IsRunningChanged + + + + + Kills all client connections. + + + + + Runs the server asynchronously. This method returns once the server has been started. + To stop the server call the method. + + + + + Stops the running server. Any existing connections are terminated. + + + + + Stops the running server. + This method blocks until all connections have terminated, either by normal completion or timeout, + or if has been specified then once all of the threads + have been killed. + + True if existing connections should be terminated. + + + + Waits for the next client to connect and blocks until then. + + + + + Releases unmanaged and - optionally - managed resources. + + The disposing. + + + + Creates the verb map which represent the commands implemented by the server. + + The with registered verbs for the commands. + + + + Defines the . + + + + + Initializes a new instance of the class. + + The smtpResponse. + + + + Initializes a new instance of the class. + + The smtpResponse. + The innerException. + + + + Initializes a new instance of the class. + + The error message that explains the reason for the exception. + + The exception that is the cause of the current exception, or a null reference (Nothing in + Visual Basic) if no inner exception is specified. + + + + + Initializes a new instance of the class. + + + + + Initializes a new instance of the class. + + The message that describes the error. + + + + Gets the SmtpResponse. + + + + A stream writer which uses the correct \r\n line ending required for SMTP protocol. + + + Initializes a new instance of the class. + The stream to write to. + The character encoding to use to fallback to should a line not be decodable as UTF8. + True if stream should be left open when the reader is disposed. + + + Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + + + The cancellation token. + A Task representing the asynchronous operation. + + + + Reads the a line from the stream which is terminated with a \n. The string will be decoded using UTF8 and + falling back to the provided encoding if decoding fails. + + The cancellation token. + A Task representing the asynchronous operation. + + + Releases unmanaged and - optionally - managed resources. + + true to release both managed and unmanaged resources; false to release only unmanaged resources. + + + + A stream writer which uses the correct \r\n line ending required for SMTP protocol. + + + Initializes a new instance of the class. + The stream to write to. + True if stream should be closed when the writer is disposed. + + + Builds a multi line string where each line has the CRLF terminator required for SMTP. + + + Appends a line to the string and terminates it with the correct CRLF required for SMTP. + The text. + + + Returns the complete string including all lines which have been appended separated with the correct CRLF. + A that represents this instance. + + + + Enumeration of the different standard TCP ports that the server can listen on. + + + + + Select a free port number automatically + + + + + Use the standard IANA SMTP port - 25 + + + + + Use the standard IANA SMTP-over-SSL port - 465 + + + + + Defines the StandardSmtpResponseCode. + + + + + Defines the SyntaxErrorCommandUnrecognised + + + + + Defines the SyntaxErrorInCommandArguments + + + + + Defines the CommandNotImplemented + + + + + Defines the BadSequenceOfCommands + + + + + Defines the CommandParameterNotImplemented + + + + + Defines the ExceededStorageAllocation + + + + + Defines the AuthenticationFailure + + + + + Defines the AuthenticationRequired + + + + + Defines the RecipientRejected + + + + + Defines the TransactionFailed + + + + + Defines the SystemStatusOrHelpReply + + + + + Defines the HelpMessage + + + + + Defines the ServiceReady + + + + + Defines the ClosingTransmissionChannel + + + + + Defines the OK + + + + + Defines the UserNotLocalWillForwardTo + + + + + Defines the StartMailInputEndWithDot + + + + + Defines the AuthenticationContinue + + + + + Defines the AuthenticationOK + + + + + Defines the . + + + + + Initializes a new instance of the class. + + The tcpClient. + The encoding to fallback to if bytes received cannot be decoded as UTF-8. + + + + Defines the Closed + + + + + Gets the ClientIPAddress. + + + + + Gets a value indicating whether IsConnected. + + + + + Gets or sets the ReceiveTimeout. + + + + + Gets or sets the SendTimeout. + + + + + Applies the a filter to the stream which is used to read data from the channel. + + The filter. + + A representing the async operation. + + + + + Closes the channel and notifies users via the event. + + + A representing the async operation. + + + + + Flushes outgoing data. + + + A representing the async operation. + + + + + Reads the next line from the channel. + + + A representing the async operation. + + Reader returned null string. + Read failed. + + + + + + + + + + Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + + + + + Releases unmanaged and - optionally - managed resources. + + + true to release both managed and unmanaged resources; false to release only + unmanaged resources. + + + + + Defines the . + + + + + + + + Processes a line of data from the client removing the escaping of the special end of message character. + + The line. + The line of data without escaping of the . character. + + + + Defines the . + + + + + + + + Defines the . + + + + + + + + Defines the . + + + + + Processes a command which math. + + The connection. + The command. + A representing the async operation. + + + + Defines the . + + + + + Gets the verb processor which is registered for the specified verb. + + The verb. + The verb or null. + + + + Sets the verb processor which is registered for a verb. + + The verb. + The verbProcessor. + + + + Defines the . + + + + + + + + Defines the . + + + + + + + + Defines the . + + + + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + + + + Initializes a new instance of the class. + + The subVerbMap. + + + + Gets the SubVerbMap. + + + + + Dispatches a command to the registered sub command matching the next verb in the command + or writes an error to the client is no match was found. + + The connection. + The command. + + A representing the async operation. + + + + + Defines the . + + + + + Defines the currentDateTimeProvider. + + + + + Initializes a new instance of the class. + + + + + Initializes a new instance of the class. + + The currentDateTimeProvider. + + + + Gets the ParameterProcessorMap. + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + + + + Gets the FromSubVerb. + + + + + Gets the SubVerbMap. + + + + + + + + Defines the . + + + + + + + + Defines the . + + + + + + + + Defines the . + + + + + Initializes a new instance of the class. + + + + + Gets the for subcommands. + + + + + + + diff --git a/smtpserver/Rnwood.SmtpServer/ServerStopBehaviour.cs b/smtpserver/Rnwood.SmtpServer/ServerStopBehaviour.cs new file mode 100644 index 000000000..3cf692bf3 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/ServerStopBehaviour.cs @@ -0,0 +1,22 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer; + +/// +/// Defines the ServerStopBehaviour. +/// +public enum ServerStopBehaviour +{ + /// + /// Defines the WaitForExistingConnections + /// + WaitForExistingConnections, + + /// + /// Defines the KillExistingConnections + /// + KillExistingConnections +} diff --git a/smtpserver/Rnwood.SmtpServer/SessionErrorType.cs b/smtpserver/Rnwood.SmtpServer/SessionErrorType.cs new file mode 100644 index 000000000..d6be6bafd --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SessionErrorType.cs @@ -0,0 +1,32 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer; + +/// +/// A high level classification of common session termination errors. +/// +public enum SessionErrorType +{ + /// + /// Indicates that there was no error. + /// + None = 0, + + /// + /// Indicates a network/IO error such as connection timeout or aborted connection. + /// + NetworkError, + + /// + /// Indicates an unhandled exception in the server or an extension which caused the connection to be terminated. + /// + UnexpectedException, + + /// + /// Indicates the connection was terminated because the server was shut down. + /// + ServerShutdown +} diff --git a/smtpserver/Rnwood.SmtpServer/SessionEventArgs.cs b/smtpserver/Rnwood.SmtpServer/SessionEventArgs.cs new file mode 100644 index 000000000..e7fe5db02 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SessionEventArgs.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 System; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class SessionEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The session. + public SessionEventArgs(ISession session) => Session = session; + + /// + /// Gets the Session. + /// + public ISession Session { get; private set; } +} diff --git a/smtpserver/Rnwood.SmtpServer/SmtpCommand.cs b/smtpserver/Rnwood.SmtpServer/SmtpCommand.cs new file mode 100644 index 000000000..6af1a0beb --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SmtpCommand.cs @@ -0,0 +1,127 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the which implements parsing of an SMTP command received from client to server. +/// +public sealed class SmtpCommand : IEquatable +{ + private static readonly Regex COMMANDREGEX = new("(?'verb'[^ :]+)[ :]*(?'arguments'.*)"); + + /// + /// Initializes a new instance of the class. + /// + /// The text. + public SmtpCommand(string text) + { + Text = text; + + IsValid = false; + IsEmpty = true; + + if (!string.IsNullOrEmpty(text)) + { + Match match = COMMANDREGEX.Match(text); + + if (match.Success) + { + Verb = match.Groups["verb"].Value; + ArgumentsText = match.Groups["arguments"].Value ?? string.Empty; + IsValid = true; + } + } + } + + /// + /// Gets the arguments supplied after the VERB in the command as a single string. + /// + public string ArgumentsText { get; private set; } + + /// + /// Gets a value indicating whether IsEmpty. + /// + public bool IsEmpty { get; private set; } + + /// + /// Gets a value indicating whether this command is valid - i.e. matching the pattern allowed. + /// + public bool IsValid { get; private set; } + + /// + /// Gets the Text. + /// + public string Text { get; } + + /// + /// Gets the Verb. + /// + public string Verb { get; private set; } + + /// + /// Indicates whether the current object is equal to another object of the same type. + /// + /// The other. + /// + /// The . + /// + public bool Equals(SmtpCommand other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Equals(other.Text, Text); + } + + /// + /// Determines whether the specified , is equal to this instance. + /// + /// The obj. + /// + /// The . + /// + public override bool Equals(object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (!(obj is SmtpCommand)) + { + return false; + } + + return Equals((SmtpCommand)obj); + } + + /// Converts to string. + /// A that represents this instance. + public override string ToString() => Text; + + /// + /// Returns a hash code for this instance. + /// + /// + /// The . + /// + public override int GetHashCode() => Text != null ? Text.GetHashCode() : 0; +} diff --git a/smtpserver/Rnwood.SmtpServer/SmtpResponse.cs b/smtpserver/Rnwood.SmtpServer/SmtpResponse.cs new file mode 100644 index 000000000..108bf235f --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SmtpResponse.cs @@ -0,0 +1,156 @@ +// +// 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.Globalization; + +namespace Rnwood.SmtpServer; + +/// +/// Represents a SMTP response from server to client which is represented by a numeric code an optional descriptive +/// text. +/// +/// +public sealed class SmtpResponse : IEquatable +{ + /// + /// Initializes a new instance of the class using any code represented as a number. + /// + /// The code. + /// + /// The message format string. Including placeholders where will + /// be substituted in. + /// + /// The arguments used to fill in placeholders in . + public SmtpResponse(int code, string messageFormatString, params object[] args) + { + Code = code; + Message = string.Format(CultureInfo.InvariantCulture, messageFormatString, args); + } + + /// + /// Initializes a new instance of the class using an enum of standard responses. + /// + /// The code. + /// The message format string. + /// The arguments. + public SmtpResponse(StandardSmtpResponseCode code, string messageFormatString, params object[] args) + : this((int)code, messageFormatString, args) + { + } + + /// + /// Gets the Code. + /// + public int Code { get; } + + /// + /// Gets a value indicating whether this response represents an error. + /// Error responses have a in the range 500-599. + /// + public bool IsError => Code >= 500 && Code <= 599; + + /// + /// Gets a value indicating whether this response represent success. + /// Successful responses have a in the range 200-299. + /// + public bool IsSuccess => Code >= 200 && Code <= 299; + + /// + /// Gets the Message. + /// + public string Message { get; } + + /// + /// Indicates whether the current response is equal to another. Both message and code must be equal. + /// + /// An object to compare with this object. + /// + /// true if the current object is equal to the other parameter; otherwise, false. + /// + public bool Equals(SmtpResponse other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return other.Code == Code && Equals(other.Message, Message); + } + + /// + /// Determines whether the specified , is equal to this instance. + /// Objects are equal if they are both instances of and have the same + /// and . + /// + /// The to compare with this instance. + /// + /// true if the specified is equal to this instance; otherwise, false. + /// + public override bool Equals(object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (!(obj is SmtpResponse)) + { + return false; + } + + return Equals((SmtpResponse)obj); + } + + /// + /// Returns a hash code for this instance. + /// + /// + /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. + /// + public override int GetHashCode() + { + unchecked + { + return (Code * 397) ^ (Message != null ? Message.GetHashCode() : 0); + } + } + + /// + /// Returns a that represents the response. + /// + /// A that represents the response. + public override string ToString() + { + SmtpStringBuilder result = new SmtpStringBuilder(); + string[] lines = Message.Split(new[] { "\r\n" }, StringSplitOptions.None); + + for (int l = 0; l < lines.Length; l++) + { + string line = lines[l]; + + if (l == lines.Length - 1) + { + result.AppendLine(Code + " " + line); + } + else + { + result.AppendLine(Code + "-" + line); + } + } + + return result.ToString(); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/SmtpServer.cs b/smtpserver/Rnwood.SmtpServer/SmtpServer.cs new file mode 100644 index 000000000..804c6f7b1 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SmtpServer.cs @@ -0,0 +1,301 @@ +// +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +#pragma warning disable CA1724 // Type names should not match namespaces +/// +/// Defines the . +/// +public class SmtpServer : ISmtpServer +#pragma warning restore CA1724 // Type names should not match namespaces +{ + /// + /// Defines the activeConnections. + /// + private readonly IList activeConnections = ArrayList.Synchronized(new List()); + + /// + /// Defines the logger. + /// + private readonly ILogger logger = Logging.Factory.CreateLogger(); + + /// + /// Defines the nextConnectionEvent. + /// + private readonly AutoResetEvent nextConnectionEvent = new(false); + + /// + /// Defines the coreTask. + /// + private Task coreTask; + + /// + /// Defines the disposedValue. + /// + private bool disposedValue; // To detect redundant calls + + /// + /// Defines the isRunning. + /// + private bool isRunning; + + /// + /// Defines the listener. + /// + private TcpListener listener; + + /// + /// Initializes a new instance of the class. + /// + /// The behaviour. + public SmtpServer(IServerBehaviour behaviour) => Behaviour = behaviour; + + /// + /// Gets the ActiveConnections. + /// + /// Note: this is not thread-safe for enumeration. + public IEnumerable ActiveConnections => activeConnections.Cast(); + + /// + /// Gets a value indicating whether IsRunning + /// Gets or sets a value indicating whether the server is currently running. + /// + public bool IsRunning + { + get => isRunning; + + private set + { + isRunning = value; + IsRunningChanged?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Gets the PortNumber. + /// + public int PortNumber => ((IPEndPoint)listener.LocalEndpoint).Port; + + /// + /// Gets the Behaviour. + /// + public IServerBehaviour Behaviour { get; } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Defines the IsRunningChanged + /// + public event EventHandler IsRunningChanged; + + /// + /// Kills all client connections. + /// + public void KillConnections() + { + logger.LogDebug("Killing client connections"); + + List killTasks = new List(); + lock (activeConnections.SyncRoot) + { + foreach (Connection connection in activeConnections.Cast().ToArray()) + { + logger.LogDebug("Killing connection {0}", connection); + killTasks.Add(connection.CloseConnection()); + } + } + + Task.WaitAll(killTasks.ToArray()); + } + + /// + /// Runs the server asynchronously. This method returns once the server has been started. + /// To stop the server call the method. + /// + public void Start() + { + if (IsRunning) + { + throw new InvalidOperationException("Already running"); + } + + logger.LogDebug("Starting server on {0}:{1}", Behaviour.IpAddress, Behaviour.PortNumber); + + listener = new TcpListener(Behaviour.IpAddress, Behaviour.PortNumber); + listener.Start(); + + IsRunning = true; + + logger.LogDebug("Listener active. Starting core task"); + + coreTask = Task.Run(() => Core().Wait()); + } + + /// + /// Stops the running server. Any existing connections are terminated. + /// + public void Stop() => Stop(true); + + /// + /// Stops the running server. + /// This method blocks until all connections have terminated, either by normal completion or timeout, + /// or if has been specified then once all of the threads + /// have been killed. + /// + /// True if existing connections should be terminated. + public void Stop(bool killConnections) + { + if (!IsRunning) + { + return; + } + + logger.LogDebug("Stopping server"); + + IsRunning = false; + listener.Stop(); + + logger.LogDebug("Listener stopped. Waiting for core task to exit"); + coreTask.Wait(); + + if (killConnections) + { + KillConnections(); + + logger.LogDebug("Server is stopped"); + } + else + { + logger.LogDebug("Server is stopped but existing connections may still be active"); + } + } + + /// + /// Waits for the next client to connect and blocks until then. + /// + public void WaitForNextConnection() => nextConnectionEvent.WaitOne(); + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// The disposing. + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + Stop(); + nextConnectionEvent.Close(); + } + + disposedValue = true; + } + } + + /// + /// Creates the verb map which represent the commands implemented by the server. + /// + /// The with registered verbs for the commands. + protected virtual IVerbMap CreateVerbMap() + { + VerbMap verbMap = new VerbMap(); + verbMap.SetVerbProcessor("HELO", new HeloVerb()); + verbMap.SetVerbProcessor("EHLO", new EhloVerb()); + verbMap.SetVerbProcessor("QUIT", new QuitVerb()); + verbMap.SetVerbProcessor("MAIL", new MailVerb()); + verbMap.SetVerbProcessor("RCPT", new RcptVerb()); + verbMap.SetVerbProcessor("DATA", new DataVerb()); + verbMap.SetVerbProcessor("RSET", new RsetVerb()); + verbMap.SetVerbProcessor("NOOP", new NoopVerb()); + + return verbMap; + } + + private async Task AcceptNextClient() + { + TcpClient tcpClient = null; + try + { + tcpClient = await listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (SocketException) + { + if (IsRunning) + { + throw; + } + + logger.LogDebug("Got SocketException on listener, shutting down"); + } + catch (InvalidOperationException) + { + if (IsRunning) + { + throw; + } + + logger.LogDebug("Got InvalidOperationException on listener, shutting down"); + } + + if (IsRunning) + { + logger.LogDebug("New connection from {0}", tcpClient.Client.RemoteEndPoint); + + TcpClientConnectionChannel connectionChannel = + new TcpClientConnectionChannel(tcpClient, Behaviour.FallbackEncoding); + connectionChannel.ReceiveTimeout = + await Behaviour.GetReceiveTimeout(connectionChannel).ConfigureAwait(false); + connectionChannel.SendTimeout = await Behaviour.GetSendTimeout(connectionChannel).ConfigureAwait(false); + + Connection connection = + await Connection.Create(this, connectionChannel, CreateVerbMap()).ConfigureAwait(false); + activeConnections.Add(connection); + connection.ConnectionClosedEventHandler += (s, ea) => + { + logger.LogDebug("Connection {0} handling completed removing from active connections", connection); + activeConnections.Remove(connection); + return Task.CompletedTask; + }; +#pragma warning disable 4014 + connection.ProcessAsync(); +#pragma warning restore 4014 + } + } + + private async Task Core() + { + logger.LogDebug("Core task running"); + + while (IsRunning) + { + logger.LogDebug("Waiting for new client"); + + await AcceptNextClient().ConfigureAwait(false); + + nextConnectionEvent.Set(); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/SmtpServerException.cs b/smtpserver/Rnwood.SmtpServer/SmtpServerException.cs new file mode 100644 index 000000000..d49bc0f43 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SmtpServerException.cs @@ -0,0 +1,65 @@ +// +// 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; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class SmtpServerException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The smtpResponse. + public SmtpServerException(SmtpResponse smtpResponse) + : base(smtpResponse.Message) => + SmtpResponse = smtpResponse; + + /// + /// Initializes a new instance of the class. + /// + /// The smtpResponse. + /// The innerException. + public SmtpServerException(SmtpResponse smtpResponse, Exception innerException) + : base(smtpResponse.Message, innerException) => + SmtpResponse = smtpResponse; + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// + /// The exception that is the cause of the current exception, or a null reference (Nothing in + /// Visual Basic) if no inner exception is specified. + /// + public SmtpServerException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class. + /// + public SmtpServerException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public SmtpServerException(string message) + : base(message) + { + } + + /// + /// Gets the SmtpResponse. + /// + public SmtpResponse SmtpResponse { get; private set; } +} diff --git a/smtpserver/Rnwood.SmtpServer/SmtpStreamReader.cs b/smtpserver/Rnwood.SmtpServer/SmtpStreamReader.cs new file mode 100644 index 000000000..fab40753c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SmtpStreamReader.cs @@ -0,0 +1,124 @@ +// +// 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.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// A stream writer which uses the correct \r\n line ending required for SMTP protocol. +public class SmtpStreamReader : IDisposable +{ + private readonly byte[] buffer = new byte[64 * 1024]; + private readonly Encoding fallbackEncoding; + private readonly bool leaveOpen; + private readonly List lineBytes = new(32 * 1024); + private readonly Stream stream; + private readonly UTF8Encoding utf8Encoding = new(false, true); + private int bufferLen; + private int bufferPos; + + private bool disposedValue; + + /// Initializes a new instance of the class. + /// The stream to write to. + /// The character encoding to use to fallback to should a line not be decodable as UTF8. + /// True if stream should be left open when the reader is disposed. + public SmtpStreamReader(Stream stream, Encoding fallbackEncoding, bool leaveOpen) + { + this.stream = stream; + this.leaveOpen = leaveOpen; + this.fallbackEncoding = Encoding.GetEncoding(fallbackEncoding.CodePage, new EncoderExceptionFallback(), + new DecoderExceptionFallback()); + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// The cancellation token. + /// A Task representing the asynchronous operation. + public async Task ReadLineBytesAsync(CancellationToken cancellationToken) + { + lineBytes.Clear(); + + while (true) + { + while (bufferPos < bufferLen) + { + byte bufferByte = buffer[bufferPos++]; + + if (bufferByte == '\n') + { + return lineBytes.ToArray(); + } + + if (bufferByte != '\r') + { + lineBytes.Add(bufferByte); + } + } + + bufferPos = 0; + bufferLen = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken) + .ConfigureAwait(false); + + if (bufferLen < 1) + { + return null; + } + } + } + + /// + /// Reads the a line from the stream which is terminated with a \n. The string will be decoded using UTF8 and + /// falling back to the provided encoding if decoding fails. + /// + /// The cancellation token. + /// A Task representing the asynchronous operation. + public async Task ReadLineAsync(CancellationToken cancellationToken) => + Decode(await ReadLineBytesAsync(cancellationToken).ConfigureAwait(false)); + + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing && !leaveOpen) + { + stream.Close(); + } + + disposedValue = true; + } + } + + private string Decode(byte[] lineBytes) + { + if (lineBytes == null) + { + return null; + } + + try + { + return utf8Encoding.GetString(lineBytes); + } + catch (DecoderFallbackException) + { + return fallbackEncoding.GetString(lineBytes); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/SmtpStreamWriter.cs b/smtpserver/Rnwood.SmtpServer/SmtpStreamWriter.cs new file mode 100644 index 000000000..823d6393e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SmtpStreamWriter.cs @@ -0,0 +1,20 @@ +// +// 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.IO; +using System.Text; + +namespace Rnwood.SmtpServer; + +/// A stream writer which uses the correct \r\n line ending required for SMTP protocol. +public class SmtpStreamWriter : StreamWriter +{ + /// Initializes a new instance of the class. + /// The stream to write to. + /// True if stream should be closed when the writer is disposed. + public SmtpStreamWriter(Stream stream, bool leaveOpen) + : base(stream, new UTF8Encoding(false, true), 1024 * 24, leaveOpen) => + NewLine = "\r\n"; +} diff --git a/smtpserver/Rnwood.SmtpServer/SmtpStringBuilder.cs b/smtpserver/Rnwood.SmtpServer/SmtpStringBuilder.cs new file mode 100644 index 000000000..a17d2cbaf --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/SmtpStringBuilder.cs @@ -0,0 +1,26 @@ +// +// 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.Text; + +namespace Rnwood.SmtpServer; + +/// Builds a multi line string where each line has the CRLF terminator required for SMTP. +public class SmtpStringBuilder +{ + private readonly StringBuilder innerStringBuilder = new(); + + /// Appends a line to the string and terminates it with the correct CRLF required for SMTP. + /// The text. + public void AppendLine(string text) + { + innerStringBuilder.Append(text); + innerStringBuilder.Append("\r\n"); + } + + /// Returns the complete string including all lines which have been appended separated with the correct CRLF. + /// A that represents this instance. + public override string ToString() => innerStringBuilder.ToString(); +} diff --git a/smtpserver/Rnwood.SmtpServer/StandardSmtpPort.cs b/smtpserver/Rnwood.SmtpServer/StandardSmtpPort.cs new file mode 100644 index 000000000..3515cc06a --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/StandardSmtpPort.cs @@ -0,0 +1,27 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer; + +/// +/// Enumeration of the different standard TCP ports that the server can listen on. +/// +public enum StandardSmtpPort +{ + /// + /// Select a free port number automatically + /// + AssignAutomatically = 0, + + /// + /// Use the standard IANA SMTP port - 25 + /// + SMTP = 25, + + /// + /// Use the standard IANA SMTP-over-SSL port - 465 + /// + SMTPOverSSL = 465 +} diff --git a/smtpserver/Rnwood.SmtpServer/StandardSmtpResponseCode.cs b/smtpserver/Rnwood.SmtpServer/StandardSmtpResponseCode.cs new file mode 100644 index 000000000..018383e55 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/StandardSmtpResponseCode.cs @@ -0,0 +1,107 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer; + +/// +/// Defines the StandardSmtpResponseCode. +/// +public enum StandardSmtpResponseCode +{ + /// + /// Defines the SyntaxErrorCommandUnrecognised + /// + SyntaxErrorCommandUnrecognised = 500, + + /// + /// Defines the SyntaxErrorInCommandArguments + /// + SyntaxErrorInCommandArguments = 501, + + /// + /// Defines the CommandNotImplemented + /// + CommandNotImplemented = 502, + + /// + /// Defines the BadSequenceOfCommands + /// + BadSequenceOfCommands = 503, + + /// + /// Defines the CommandParameterNotImplemented + /// + CommandParameterNotImplemented = 504, + + /// + /// Defines the ExceededStorageAllocation + /// + ExceededStorageAllocation = 552, + + /// + /// Defines the AuthenticationFailure + /// + AuthenticationFailure = 535, + + /// + /// Defines the AuthenticationRequired + /// + AuthenticationRequired = 530, + + /// + /// Defines the RecipientRejected + /// + RecipientRejected = 550, + + /// + /// Defines the TransactionFailed + /// + TransactionFailed = 554, + + /// + /// Defines the SystemStatusOrHelpReply + /// + SystemStatusOrHelpReply = 211, + + /// + /// Defines the HelpMessage + /// + HelpMessage = 214, + + /// + /// Defines the ServiceReady + /// + ServiceReady = 220, + + /// + /// Defines the ClosingTransmissionChannel + /// + ClosingTransmissionChannel = 221, + + /// + /// Defines the OK + /// + OK = 250, + + /// + /// Defines the UserNotLocalWillForwardTo + /// + UserNotLocalWillForwardTo = 251, + + /// + /// Defines the StartMailInputEndWithDot + /// + StartMailInputEndWithDot = 354, + + /// + /// Defines the AuthenticationContinue + /// + AuthenticationContinue = 334, + + /// + /// Defines the AuthenticationOK + /// + AuthenticationOK = 235 +} diff --git a/smtpserver/Rnwood.SmtpServer/TcpClientConnectionChannel.cs b/smtpserver/Rnwood.SmtpServer/TcpClientConnectionChannel.cs new file mode 100644 index 000000000..73b62c8a5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/TcpClientConnectionChannel.cs @@ -0,0 +1,240 @@ +// +// 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.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class TcpClientConnectionChannel : IConnectionChannel +{ + private readonly Encoding fallbackEncoding; + private readonly TcpClient tcpClient; + + private bool disposedValue; // To detect redundant calls + + private SmtpStreamReader reader; + + private Stream stream; + + private SmtpStreamWriter writer; + + /// + /// Initializes a new instance of the class. + /// + /// The tcpClient. + /// The encoding to fallback to if bytes received cannot be decoded as UTF-8. + public TcpClientConnectionChannel(TcpClient tcpClient, Encoding fallbackEncoding) + { + this.tcpClient = tcpClient; + stream = tcpClient.GetStream(); + IsConnected = true; + this.fallbackEncoding = fallbackEncoding; + SetupReaderAndWriter(); + } + + /// + /// Defines the Closed + /// + public event AsyncEventHandler ClosedEventHandler; + + /// + /// Gets the ClientIPAddress. + /// + public IPAddress ClientIPAddress => ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address; + + /// + /// Gets a value indicating whether IsConnected. + /// + public bool IsConnected { get; private set; } + + /// + /// Gets or sets the ReceiveTimeout. + /// + public TimeSpan ReceiveTimeout + { + get => TimeSpan.FromMilliseconds(tcpClient.ReceiveTimeout); + set => tcpClient.ReceiveTimeout = (int)Math.Min(int.MaxValue, value.TotalMilliseconds); + } + + /// + /// Gets or sets the SendTimeout. + /// + public TimeSpan SendTimeout + { + get => TimeSpan.FromMilliseconds(tcpClient.SendTimeout); + set => tcpClient.SendTimeout = (int)Math.Min(int.MaxValue, value.TotalMilliseconds); + } + + /// + /// Applies the a filter to the stream which is used to read data from the channel. + /// + /// The filter. + /// + /// A representing the async operation. + /// + public async Task ApplyStreamFilter(Func> filter) + { + stream = await filter(stream).ConfigureAwait(false); + SetupReaderAndWriter(); + } + + /// + /// Closes the channel and notifies users via the event. + /// + /// + /// A representing the async operation. + /// + public Task Close() + { + if (IsConnected) + { + IsConnected = false; + tcpClient.Dispose(); + + foreach (Delegate handler in ClosedEventHandler?.GetInvocationList() ?? Enumerable.Empty()) + { + handler.DynamicInvoke(this, EventArgs.Empty); + } + } + + return Task.CompletedTask; + } + + /// + /// Flushes outgoing data. + /// + /// + /// A representing the async operation. + /// + public async Task Flush() => await writer.FlushAsync().ConfigureAwait(false); + + /// + /// Reads the next line from the channel. + /// + /// + /// A representing the async operation. + /// + /// Reader returned null string. + /// Read failed. + public async Task ReadLine() + { + try + { + using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(30))) + { + string text = await reader.ReadLineAsync(cts.Token).ConfigureAwait(false); + + if (text == null) + { + throw new IOException("Reader returned null string"); + } + + return text; + } + } + catch (IOException e) + { + await Close().ConfigureAwait(false); + throw new ConnectionUnexpectedlyClosedException("Read failed", e); + } + } + + /// + public async Task WriteLine(string text) + { + try + { + await writer.WriteLineAsync(text).ConfigureAwait(false); + } + catch (IOException e) + { + await Close().ConfigureAwait(false); + throw new ConnectionUnexpectedlyClosedException("Write failed", e); + } + } + + /// + public async Task ReadLineBytes() + { + try + { + using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(30))) + { + byte[] data = await reader.ReadLineBytesAsync(cts.Token).ConfigureAwait(false); + + if (data == null) + { + throw new IOException("Reader returned null bytes"); + } + + return data; + } + } + catch (IOException e) + { + await Close().ConfigureAwait(false); + throw new ConnectionUnexpectedlyClosedException("Read failed", e); + } + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// + /// true to release both managed and unmanaged resources; false to release only + /// unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + writer.Dispose(); + reader.Dispose(); + stream.Dispose(); + tcpClient.Dispose(); + } + + disposedValue = true; + } + } + + private void SetupReaderAndWriter() + { + if (reader != null) + { + reader.Dispose(); + } + + reader = new SmtpStreamReader(stream, fallbackEncoding, true); + + if (writer != null) + { + writer.Dispose(); + } + + writer = new SmtpStreamWriter(stream, true) { AutoFlush = true }; + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/DataVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/DataVerb.cs new file mode 100644 index 000000000..4773398e6 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/DataVerb.cs @@ -0,0 +1,124 @@ +// +// 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.IO; +using System.Linq; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class DataVerb : IVerb +{ + private readonly byte[] CRLF_BYTES = "\r\n"u8.ToArray(); + + /// + public virtual async Task Process(IConnection connection, SmtpCommand command) + { + if (connection.CurrentMessage == null) + { + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.BadSequenceOfCommands, + "Bad sequence of commands")).ConfigureAwait(false); + return; + } + + connection.CurrentMessage.SecureConnection = connection.Session.SecureConnection; + + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.StartMailInputEndWithDot, + "End message with period")).ConfigureAwait(false); + + long messageSize = 0; + + using (Stream messageStream = await connection.CurrentMessage.WriteData().ConfigureAwait(false)) + { + bool firstLine = true; + + do + { + byte[] data = await connection.ReadLineBytes().ConfigureAwait(false); + + + if (!"."u8.ToArray().SequenceEqual(data)) + { + data = ProcessLine(data); + + if (!firstLine) + { + messageSize += CRLF_BYTES.Length; + messageStream.Write(CRLF_BYTES, 0, CRLF_BYTES.Length); + } + + messageSize += data.Length; + messageStream.Write(data, 0, data.Length); + } + else + { + break; + } + + firstLine = false; + } while (true); + + await messageStream.FlushAsync().ConfigureAwait(false); + } + long? maxMessageSize = + await connection.Server.Behaviour.GetMaximumMessageSize(connection).ConfigureAwait(false); + + + + + if (maxMessageSize.HasValue && messageSize > maxMessageSize.Value) + { + await connection.WriteResponse( + new SmtpResponse( + StandardSmtpResponseCode.ExceededStorageAllocation, + "Message exceeds fixed size limit")).ConfigureAwait(false); + await connection.AbortMessage().ConfigureAwait(false); + return; + + } + + + try + { + await connection.Server.Behaviour.OnMessageCompleted(connection).ConfigureAwait(false); + await connection.WriteResponse(new SmtpResponse(StandardSmtpResponseCode.OK, "Mail accepted")) + .ConfigureAwait(false); + await connection.CommitMessage().ConfigureAwait(false); + } + catch (SmtpServerException ex) + { + await connection.AbortMessage().ConfigureAwait(false); + await connection.WriteResponse(ex.SmtpResponse); + } + catch + { + await connection.AbortMessage().ConfigureAwait(false); + throw; + } + + } + + /// + /// Processes a line of data from the client removing the escaping of the special end of message character. + /// + /// The line. + /// The line of data without escaping of the . character. + protected virtual byte[] ProcessLine(byte[] data) + { + // Remove escaping of end of message character + if (data.Length > 0 && data[0] == '.') + { + return data.Skip(1).ToArray(); + } + + return data; + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/EhloVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/EhloVerb.cs new file mode 100644 index 000000000..95ce8624a --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/EhloVerb.cs @@ -0,0 +1,36 @@ +// +// 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.Threading.Tasks; +using Rnwood.SmtpServer.Extensions; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class EhloVerb : IVerb +{ + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + connection.Session.ClientName = command.ArgumentsText ?? string.Empty; + + SmtpStringBuilder text = new SmtpStringBuilder(); + text.AppendLine("Nice to meet you."); + + foreach (IExtensionProcessor extensionProcessor in connection.ExtensionProcessors) + { + foreach (string ehloKeyword in await extensionProcessor.GetEHLOKeywords().ConfigureAwait(false)) + { + text.AppendLine(ehloKeyword); + } + } + + await connection.WriteResponse(new SmtpResponse(StandardSmtpResponseCode.OK, text.ToString().TrimEnd())) + .ConfigureAwait(false); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/HeloVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/HeloVerb.cs new file mode 100644 index 000000000..365633ed6 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/HeloVerb.cs @@ -0,0 +1,31 @@ +// +// 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.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class HeloVerb : IVerb +{ + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + if (!string.IsNullOrEmpty(connection.Session.ClientName)) + { + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.BadSequenceOfCommands, + "You already said HELO")).ConfigureAwait(false); + return; + } + + connection.Session.ClientName = command.ArgumentsText ?? string.Empty; + await connection.WriteResponse(new SmtpResponse(StandardSmtpResponseCode.OK, "Nice to meet you")) + .ConfigureAwait(false); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/IVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/IVerb.cs new file mode 100644 index 000000000..dc2ca5933 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/IVerb.cs @@ -0,0 +1,22 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Verbs; + +/// +/// Defines the . +/// +public interface IVerb +{ + /// + /// Processes a command which math. + /// + /// The connection. + /// The command. + /// A representing the async operation. + Task Process(IConnection connection, SmtpCommand command); +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/IVerbMap.cs b/smtpserver/Rnwood.SmtpServer/Verbs/IVerbMap.cs new file mode 100644 index 000000000..abf34fbe6 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/IVerbMap.cs @@ -0,0 +1,26 @@ +// +// 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. +// + +namespace Rnwood.SmtpServer.Verbs; + +/// +/// Defines the . +/// +public interface IVerbMap +{ + /// + /// Gets the verb processor which is registered for the specified verb. + /// + /// The verb. + /// The verb or null. + IVerb GetVerbProcessor(string verb); + + /// + /// Sets the verb processor which is registered for a verb. + /// + /// The verb. + /// The verbProcessor. + void SetVerbProcessor(string verb, IVerb verbProcessor); +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/MailFromVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/MailFromVerb.cs new file mode 100644 index 000000000..87a54ae4c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/MailFromVerb.cs @@ -0,0 +1,98 @@ +// +// 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 Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class MailFromVerb : IVerb +{ + /// + /// Defines the currentDateTimeProvider. + /// + private readonly ICurrentDateTimeProvider currentDateTimeProvider; + + /// + /// Initializes a new instance of the class. + /// + public MailFromVerb() + : this(new CurrentDateTimeProvider()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The currentDateTimeProvider. + public MailFromVerb(ICurrentDateTimeProvider currentDateTimeProvider) + { + ParameterProcessorMap = new ParameterProcessorMap(); + this.currentDateTimeProvider = currentDateTimeProvider; + } + + /// + /// Gets the ParameterProcessorMap. + /// + public ParameterProcessorMap ParameterProcessorMap { get; } + + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + if (connection.CurrentMessage != null) + { + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.BadSequenceOfCommands, + "You already told me who the message was from")).ConfigureAwait(false); + return; + } + + if (command.ArgumentsText.Length == 0) + { + await connection.WriteResponse( + new SmtpResponse( + StandardSmtpResponseCode.SyntaxErrorInCommandArguments, + "Must specify from address or <>")).ConfigureAwait(false); + return; + } + + ArgumentsParser argumentsParser = new ArgumentsParser(command.ArgumentsText); + IReadOnlyCollection arguments = argumentsParser.Arguments; + + string from = arguments.First(); + if (from.StartsWith("<", StringComparison.OrdinalIgnoreCase)) + { + from = from.Remove(0, 1); + } + + if (from.EndsWith(">", StringComparison.OrdinalIgnoreCase)) + { + from = from.Remove(from.Length - 1, 1); + } + + await connection.Server.Behaviour.OnMessageStart(connection, from).ConfigureAwait(false); + await connection.NewMessage().ConfigureAwait(false); + connection.CurrentMessage.ReceivedDate = currentDateTimeProvider.GetCurrentDateTime(); + connection.CurrentMessage.From = from; + + try + { + await ParameterProcessorMap.Process(connection, arguments.Skip(1).ToArray(), true).ConfigureAwait(false); + await connection.WriteResponse(new SmtpResponse(StandardSmtpResponseCode.OK, "New message started")) + .ConfigureAwait(false); + } + catch + { + await connection.AbortMessage().ConfigureAwait(false); + throw; + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/MailVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/MailVerb.cs new file mode 100644 index 000000000..121e400e8 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/MailVerb.cs @@ -0,0 +1,54 @@ +// +// 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.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class MailVerb : IVerb +{ + /// + /// Initializes a new instance of the class. + /// + public MailVerb() + { + SubVerbMap = new VerbMap(); + SubVerbMap.SetVerbProcessor("FROM", new MailFromVerb()); + } + + /// + /// Gets the FromSubVerb. + /// + public MailFromVerb FromSubVerb => (MailFromVerb)SubVerbMap.GetVerbProcessor("FROM"); + + /// + /// Gets the SubVerbMap. + /// + public VerbMap SubVerbMap { get; } + + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + SmtpCommand subrequest = new SmtpCommand(command.ArgumentsText); + IVerb verbProcessor = SubVerbMap.GetVerbProcessor(subrequest.Verb); + + if (verbProcessor != null) + { + await verbProcessor.Process(connection, subrequest).ConfigureAwait(false); + } + else + { + await connection.WriteResponse( + new SmtpResponse( + StandardSmtpResponseCode.CommandParameterNotImplemented, + "Subcommand {0} not implemented", + subrequest.Verb)).ConfigureAwait(false); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/NoopVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/NoopVerb.cs new file mode 100644 index 000000000..64e872de9 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/NoopVerb.cs @@ -0,0 +1,19 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Verbs; + +/// +/// Defines the . +/// +public class NoopVerb : IVerb +{ + /// + public async Task Process(IConnection connection, SmtpCommand command) => + await connection.WriteResponse(new SmtpResponse(StandardSmtpResponseCode.OK, "Successfully did nothing")) + .ConfigureAwait(false); +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/QuitVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/QuitVerb.cs new file mode 100644 index 000000000..9fd71b3fc --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/QuitVerb.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 System.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class QuitVerb : IVerb +{ + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.ClosingTransmissionChannel, + "Goodbye")).ConfigureAwait(false); + await connection.CloseConnection().ConfigureAwait(false); + connection.Session.CompletedNormally = true; + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/RcptToVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/RcptToVerb.cs new file mode 100644 index 000000000..e0b11611e --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/RcptToVerb.cs @@ -0,0 +1,47 @@ +// +// 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.Linq; +using System.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class RcptToVerb : IVerb +{ + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + if (connection.CurrentMessage == null) + { + await connection.WriteResponse(new SmtpResponse( + StandardSmtpResponseCode.BadSequenceOfCommands, + "No current message")).ConfigureAwait(false); + return; + } + + if (command.ArgumentsText == "<>" || !command.ArgumentsText.StartsWith("<", StringComparison.Ordinal) || + !command.ArgumentsText.EndsWith(">", StringComparison.Ordinal) || + command.ArgumentsText.Count(c => c == '<') != command.ArgumentsText.Count(c => c == '>')) + { + await connection.WriteResponse( + new SmtpResponse( + StandardSmtpResponseCode.SyntaxErrorInCommandArguments, + "Must specify to address
")).ConfigureAwait(false); + return; + } + + string address = command.ArgumentsText.Remove(0, 1).Remove(command.ArgumentsText.Length - 2); + await connection.Server.Behaviour.OnMessageRecipientAdding(connection, connection.CurrentMessage, address) + .ConfigureAwait(false); + connection.CurrentMessage.Recipients.Add(address); + await connection.WriteResponse(new SmtpResponse(StandardSmtpResponseCode.OK, "Recipient accepted")) + .ConfigureAwait(false); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/RcptVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/RcptVerb.cs new file mode 100644 index 000000000..5ced06d17 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/RcptVerb.cs @@ -0,0 +1,49 @@ +// +// 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.Threading.Tasks; +using Rnwood.SmtpServer.Verbs; + +namespace Rnwood.SmtpServer; + +/// +/// Defines the . +/// +public class RcptVerb : IVerb +{ + /// + /// Initializes a new instance of the class. + /// + public RcptVerb() + { + SubVerbMap = new VerbMap(); + SubVerbMap.SetVerbProcessor("TO", new RcptToVerb()); + } + + /// + /// Gets the for subcommands. + /// + public VerbMap SubVerbMap { get; } + + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + SmtpCommand subrequest = new SmtpCommand(command.ArgumentsText); + IVerb verbProcessor = SubVerbMap.GetVerbProcessor(subrequest.Verb); + + if (verbProcessor != null) + { + await verbProcessor.Process(connection, subrequest).ConfigureAwait(false); + } + else + { + await connection.WriteResponse( + new SmtpResponse( + StandardSmtpResponseCode.CommandParameterNotImplemented, + "Subcommand {0} not implemented", + subrequest.Verb)).ConfigureAwait(false); + } + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/RsetVerb.cs b/smtpserver/Rnwood.SmtpServer/Verbs/RsetVerb.cs new file mode 100644 index 000000000..c97d26ee5 --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/RsetVerb.cs @@ -0,0 +1,22 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Verbs; + +/// +/// Defines the . +/// +public class RsetVerb : IVerb +{ + /// + public async Task Process(IConnection connection, SmtpCommand command) + { + await connection.AbortMessage().ConfigureAwait(false); + await connection.WriteResponse(new SmtpResponse(StandardSmtpResponseCode.OK, "Rset completed")) + .ConfigureAwait(false); + } +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/VerbMap.cs b/smtpserver/Rnwood.SmtpServer/Verbs/VerbMap.cs new file mode 100644 index 000000000..60f8df35f --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/VerbMap.cs @@ -0,0 +1,27 @@ +// +// 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; + +namespace Rnwood.SmtpServer.Verbs; + +/// +/// Defines the . +/// +public class VerbMap : IVerbMap +{ + private readonly Dictionary processorVerbs = new(StringComparer.OrdinalIgnoreCase); + + /// + public virtual IVerb GetVerbProcessor(string verb) + { + processorVerbs.TryGetValue(verb, out IVerb result); + return result; + } + + /// + public virtual void SetVerbProcessor(string verb, IVerb verbProcessor) => processorVerbs[verb] = verbProcessor; +} diff --git a/smtpserver/Rnwood.SmtpServer/Verbs/VerbWithSubCommands.cs b/smtpserver/Rnwood.SmtpServer/Verbs/VerbWithSubCommands.cs new file mode 100644 index 000000000..15d08356c --- /dev/null +++ b/smtpserver/Rnwood.SmtpServer/Verbs/VerbWithSubCommands.cs @@ -0,0 +1,61 @@ +// +// 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.Threading.Tasks; + +namespace Rnwood.SmtpServer.Verbs; + +/// +/// Defines the . +/// +public abstract class VerbWithSubCommands : IVerb +{ + /// + /// Initializes a new instance of the class. + /// + protected VerbWithSubCommands() + : this(new VerbMap()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The subVerbMap. + protected VerbWithSubCommands(IVerbMap subVerbMap) => SubVerbMap = subVerbMap; + + /// + /// Gets the SubVerbMap. + /// + public IVerbMap SubVerbMap { get; } + + /// + /// Dispatches a command to the registered sub command matching the next verb in the command + /// or writes an error to the client is no match was found. + /// + /// The connection. + /// The command. + /// + /// A representing the async operation. + /// + public virtual async Task Process(IConnection connection, SmtpCommand command) + { + SmtpCommand subrequest = new SmtpCommand(command.ArgumentsText); + IVerb verbProcessor = SubVerbMap.GetVerbProcessor(subrequest.Verb); + + if (verbProcessor != null) + { + await verbProcessor.Process(connection, subrequest).ConfigureAwait(false); + } + else + { + await connection.WriteResponse( + new SmtpResponse( + StandardSmtpResponseCode.CommandParameterNotImplemented, + "Subcommand {0} not implemented", + subrequest.Verb)).ConfigureAwait(false); + } + } +} diff --git a/smtpserver/SolutionInfo.cs b/smtpserver/SolutionInfo.cs new file mode 100644 index 000000000..2de33e827 --- /dev/null +++ b/smtpserver/SolutionInfo.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +[assembly: AssemblyCompany("Robert N Wood")] +[assembly: AssemblyProduct("smtp4dev")] +[assembly: AssemblyCopyright("Copyright © Robert N Wood 2009-2017")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyVersion("3.0.100")] \ No newline at end of file diff --git a/smtpserver/appveyor.yml b/smtpserver/appveyor.yml new file mode 100644 index 000000000..d74cfc336 --- /dev/null +++ b/smtpserver/appveyor.yml @@ -0,0 +1,102 @@ +version: 3.1.0-ci@{build} +image: Visual Studio 2019 +environment: + matrix: + - TESTFRAMEWORK: net6.0 + APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 + PUBLISH: 1 + - TESTFRAMEWORK: net6.0 + APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu2004 + PUBLISH: 0 + - TESTFRAMEWORK: net462 + APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 + PUBLISH: 0 + APPVEYORAPIKEY: + secure: f6ZDvXLvu8fryoFvnGfIUdpvlUC6Rft/qZKbWGh5CFw= +skip_tags: false +assembly_info: + patch: true + file: '**\SolutionInfo.*' + assembly_version: '{version}' + assembly_file_version: '{version}' + assembly_informational_version: '{version}' +nuget: + account_feed: false + project_feed: false +init: + - ps: | + $ErrorActionPreference="Stop" + $version = $env:APPVEYOR_BUILD_VERSION + if ($env:APPVEYOR_REPO_TAG_NAME) { + write-host "Using label ${env:APPVEYOR_REPO_TAG_NAME} as version number" + try { + Update-AppveyorBuild -version $env:APPVEYOR_REPO_TAG_NAME + } catch { + Add-AppveyorMessage -Message "Skipping github release label." + + Exit-AppveyorBuild + } + $version = $env:APPVEYOR_REPO_TAG_NAME + Set-AppveyorBuildVariable 'PRERELEASE' (($version.Contains("-")).ToString()) + } else { + $paddedbuild = ([int]$env:APPVEYOR_BUILD_NUMBER).ToString("0000") + write-host "Version is $version - replacing @${env:APPVEYOR_BUILD_NUMBER} with $paddedbuild" + Update-AppveyorBuild -version ($version.Replace("@${env:APPVEYOR_BUILD_NUMBER}", "$paddedbuild")) + Set-AppveyorBuildVariable 'PRERELEASE' "true" + } + +install: +- ps: | + dotnet restore +build_script: + - ps: | + dotnet build Rnwood.SmtpServer -c Debug + if ($env:PUBLISH -eq "1") { + dotnet pack Rnwood.SmtpServer --include-source -c Release -p:Version=$env:APPVEYOR_BUILD_VERSION -p:"PackageVersion=$env:APPVEYOR_BUILD_VERSION" + } else { + write-host "Skipping publish" + } +artifacts: +- path: Rnwood.SmtpServer\bin\Release\*.nupkg +- path: Rnwood.SmtpServer\bin\Release\*.snupkg +test_script: +- ps: | + dotnet test -f $env:TESTFRAMEWORK -r Debug Rnwood.SmtpServer.Tests\Rnwood.SmtpServer.Tests.csproj +after_deploy: + - ps: | + if ($env:APPVEYOR_REPO_TAG -eq "false") { + $headers = @{ + "Authorization" = "Bearer $($env:APPVEYORAPIKEY)" + "Content-type" = "application/json" + } + $history = Invoke-RestMethod -Uri "https://ci.appveyor.com/api/projects/$($env:APPVEYOR_ACCOUNT_NAME)/$($env:APPVEYOR_PROJECT_SLUG)/history?recordsNumber=25&branch=$($env:APPVEYOR_REPO_BRANCH)" -Headers $headers -Method Get + $buildtoremove = $history.builds | where-object { $_.tag -eq $env:APPVEYOR_BUILD_VERSION} + if ($buildtoremove) { + Invoke-RestMethod -uri "https://ci.appveyor.com/api/builds/$($env:APPVEYOR_ACCOUNT_NAME)/$($env:APPVEYOR_PROJECT_SLUG)/$($buildtoremove.version)" -Headers $headers -Method Delete + Invoke-RestMethod -uri "https://ci.appveyor.com/api/account/$($env:APPVEYOR_ACCOUNT_NAME)/builds/$($buildtoremove.buildid)" -Headers $headers -Method Delete + } + } +deploy: +- provider: GitHub + artifact: /.*\..*/ + auth_token: + secure: kwKatdFixHGt6H2kF/Tcvg1Uds+I077wvItDbYz0uxrb9+9r8HIb1w50vgpZbpIq + prerelease: $(PRERELEASE) + tag: $(APPVEYOR_REPO_TAG_NAME) + on: + appveyor_repo_tag: true +- provider: GitHub + artifact: /.*\..*/ + auth_token: + secure: kwKatdFixHGt6H2kF/Tcvg1Uds+I077wvItDbYz0uxrb9+9r8HIb1w50vgpZbpIq + prerelease: $(PRERELEASE) + on: + branch: master + appveyor_repo_tag: false +- provider: NuGet + api_key: + secure: eKfQ9Opi9SCUWrmxYOGm9xGMKx2+KXnAjAvqCWnYbSDN37CWzGehvH74kY4iH/B1 + skip_symbols: false + artifact: /.*\.s?nupkg/ + on: + branch: master diff --git a/smtpserver/renovate.json b/smtpserver/renovate.json new file mode 100644 index 000000000..5db72dd6a --- /dev/null +++ b/smtpserver/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/smtpserver/stylecop.json b/smtpserver/stylecop.json new file mode 100644 index 000000000..b50c82cd8 --- /dev/null +++ b/smtpserver/stylecop.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "indentation": { + "useTabs": true + }, + "documentationRules": { + "companyName": "Rnwood.SmtpServer project contributors", + "copyrightText": "Copyright (c) {companyName}. All rights reserved.\nLicensed under the {licenseName} license. See {licenseFile} file in the project root for full license information.", + "variables": { + "licenseName": "BSD", + "licenseFile": "LICENSE.md" + } + } + } +}