diff --git a/src/libraries/System.Net.Mail/tests/Functional/LoopbackServerTestBase.cs b/src/libraries/System.Net.Mail/tests/Functional/LoopbackServerTestBase.cs new file mode 100644 index 00000000000000..0d00087e61427f --- /dev/null +++ b/src/libraries/System.Net.Mail/tests/Functional/LoopbackServerTestBase.cs @@ -0,0 +1,179 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.NetworkInformation; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Net.Mail.Tests; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Sdk; +using Xunit.Abstractions; + +namespace System.Net.Mail.Tests +{ + public enum SendMethod + { + Send, + SendAsync, + SendMailAsync + } + + public interface ISendMethodProvider + { + static abstract SendMethod SendMethod { get; } + } + + public struct SyncSendMethod : ISendMethodProvider + { + public static SendMethod SendMethod => SendMethod.Send; + } + + public struct AsyncSendMethod : ISendMethodProvider + { + public static SendMethod SendMethod => SendMethod.SendAsync; + } + + public struct SendMailAsyncMethod : ISendMethodProvider + { + public static SendMethod SendMethod => SendMethod.SendMailAsync; + } + + public abstract class LoopbackServerTestBase : IDisposable + where T : ISendMethodProvider + { + protected LoopbackSmtpServer Server { get; private set; } + protected ITestOutputHelper Output { get; private set; } + + private SmtpClient _smtp; + + protected SmtpClient Smtp + { + get + { + return _smtp ??= Server.CreateClient(); + } + } + + public LoopbackServerTestBase(ITestOutputHelper output) + { + Output = output; + Server = new LoopbackSmtpServer(Output); + } + + private Task SendMailInternal(MailMessage msg, CancellationToken cancellationToken, bool? asyncExpectDirectException) + { + switch (T.SendMethod) + { + case SendMethod.Send: + try + { + Smtp.Send(msg); + return Task.FromResult(null); + } + catch (Exception ex) + { + return Task.FromResult(ex); + } + + case SendMethod.SendAsync: + TaskCompletionSource tcs = new TaskCompletionSource(); + SendCompletedEventHandler handler = null!; + handler = (s, e) => + { + Smtp.SendCompleted -= handler; + + if (e.Error != null) + { + tcs.SetResult(e.Error); + } + else if (e.Cancelled) + { + tcs.SetResult(new OperationCanceledException("The operation was canceled.")); + } + else + { + tcs.SetResult(null); + } + }; + Smtp.SendCompleted += handler; + try + { + Smtp.SendAsync(msg, tcs); + + if (asyncExpectDirectException == true) + { + Assert.Fail($"No exception thrown"); + } + + return tcs.Task; + } + catch (Exception ex) when (ex is not XunitException) + { + Smtp.SendCompleted -= handler; + + if (asyncExpectDirectException == false) + { + Assert.Fail($"Expected exception via callback, got direct: {ex}"); + } + + return Task.FromResult(ex); + } + + case SendMethod.SendMailAsync: + try + { + Task task = Smtp.SendMailAsync(msg, cancellationToken); + + if (asyncExpectDirectException == true) + { + Assert.Fail($"No exception thrown"); + } + + return task.ContinueWith(t => t.Exception?.InnerException); + } + catch (Exception ex) when (ex is not XunitException) + { + if (asyncExpectDirectException == false) + { + Assert.Fail($"Expected stored exception, got direct: {ex}"); + } + + return Task.FromResult(ex); + } + + default: + throw new ArgumentOutOfRangeException(); + } + } + + protected async Task SendMail(MailMessage msg, CancellationToken cancellationToken = default) + { + Exception? ex = await SendMailInternal(msg, cancellationToken, null); + Assert.Null(ex); + } + + protected async Task SendMail(MailMessage msg, CancellationToken cancellationToken = default, bool unwrapException = true, bool asyncDirectException = false) where TException : Exception + { + Exception? ex = await SendMailInternal(msg, cancellationToken, asyncDirectException); + + if (unwrapException && T.SendMethod != SendMethod.Send && typeof(TException) != typeof(SmtpException)) + { + ex = Assert.IsType(ex).InnerException; + } + + return Assert.IsType(ex); + } + + protected static string GetClientDomain() => IPGlobalProperties.GetIPGlobalProperties().HostName.Trim().ToLower(); + + public virtual void Dispose() + { + _smtp?.Dispose(); + Server?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/libraries/System.Net.Mail/tests/Functional/LoopbackSmtpServer.cs b/src/libraries/System.Net.Mail/tests/Functional/LoopbackSmtpServer.cs index 6b5d4ab504488e..29d9a978890119 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/LoopbackSmtpServer.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/LoopbackSmtpServer.cs @@ -7,13 +7,16 @@ using System.Diagnostics; using System.Net; using System.Net.Mail; +using System.Net.Mime; using System.Net.Security; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; +using System.IO; +using Xunit.Abstractions; -namespace Systen.Net.Mail.Tests +namespace System.Net.Mail.Tests { public class LoopbackSmtpServer : IDisposable { @@ -24,8 +27,10 @@ public class LoopbackSmtpServer : IDisposable public bool SupportSmtpUTF8 = false; public bool AdvertiseNtlmAuthSupport = false; public bool AdvertiseGssapiAuthSupport = false; + public SslServerAuthenticationOptions? SslOptions { get; set; } public NetworkCredential ExpectedGssapiCredential { get; set; } + private ITestOutputHelper? _output; private bool _disposed = false; private readonly Socket _listenSocket; private readonly ConcurrentBag _socketsToDispose; @@ -36,24 +41,27 @@ public class LoopbackSmtpServer : IDisposable public Action OnConnected; public Action OnHelloReceived; - public Action OnCommandReceived; + public Func OnCommandReceived; public Action OnUnknownCommand; public Action OnQuitReceived; public string ClientDomain { get; private set; } public string MailFrom { get; private set; } - public string MailTo { get; private set; } + public List MailTo { get; private set; } = new List(); public string UsernamePassword { get; private set; } public string Username { get; private set; } public string Password { get; private set; } public string AuthMethodUsed { get; private set; } public ParsedMailMessage Message { get; private set; } + public bool IsEncrypted { get; private set; } + public string TlsHostName { get; private set; } public int ConnectionCount { get; private set; } public int MessagesReceived { get; private set; } - public LoopbackSmtpServer() + public LoopbackSmtpServer(ITestOutputHelper? output = null) { + _output = output; _socketsToDispose = new ConcurrentBag(); _listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socketsToDispose.Add(_listenSocket); @@ -78,6 +86,19 @@ public LoopbackSmtpServer() private async Task HandleConnectionAsync(Socket socket) { var buffer = new byte[1024].AsMemory(); + Stream stream = new NetworkStream(socket); + + string lastTag = string.Empty; + void LogMessage(string tag, string message) + { + StringReader reader = new(message); + while (reader.ReadLine() is string line) + { + tag = tag == lastTag ? " " : tag; + _output?.WriteLine($"{tag}> {line}"); + lastTag = tag; + } + } async ValueTask ReceiveMessageAsync(bool isBody = false) { @@ -87,48 +108,73 @@ async ValueTask ReceiveMessageAsync(bool isBody = false) int received = 0; do { - int read = await socket.ReceiveAsync(buffer.Slice(received), SocketFlags.None); + int read = await stream.ReadAsync(buffer.Slice(received)); + if (read == 0) return null; received += read; } while (received < suffix || !buffer.Slice(received - suffix, suffix).Span.SequenceEqual(terminator.Span)); MessagesReceived++; - return Encoding.UTF8.GetString(buffer.Span.Slice(0, received - suffix)); + string message = Encoding.UTF8.GetString(buffer.Span.Slice(0, received - suffix)); + LogMessage("Client", Encoding.UTF8.GetString(buffer.Span.Slice(0, received))); + return message; } + async ValueTask SendMessageAsync(string text) { var bytes = buffer.Slice(0, Encoding.UTF8.GetBytes(text, buffer.Span) + 2); bytes.Span[^2] = (byte)'\r'; bytes.Span[^1] = (byte)'\n'; - await socket.SendAsync(bytes, SocketFlags.None); + + LogMessage("Server", text + "\r\n"); + await stream.WriteAsync(bytes); + await stream.FlushAsync(); } try { OnConnected?.Invoke(socket); await SendMessageAsync("220 localhost"); + bool isFirstMessage = true; - string message = await ReceiveMessageAsync(); - Debug.Assert(message.ToLower().StartsWith("helo ") || message.ToLower().StartsWith("ehlo ")); - ClientDomain = message.Substring(5).ToLower(); - OnCommandReceived?.Invoke(message.Substring(0, 4), ClientDomain); - OnHelloReceived?.Invoke(ClientDomain); + while (await ReceiveMessageAsync() is string message && message != null) + { + Debug.Assert(!isFirstMessage || (message.ToLower().StartsWith("helo ") || message.ToLower().StartsWith("ehlo ")), "Expected the first message to be HELO/EHLO"); + isFirstMessage = false; - await SendMessageAsync("250-localhost, mock server here"); - if (SupportSmtpUTF8) await SendMessageAsync("250-SMTPUTF8"); - await SendMessageAsync( - "250 AUTH PLAIN LOGIN" + - (AdvertiseNtlmAuthSupport ? " NTLM" : "") + - (AdvertiseGssapiAuthSupport ? " GSSAPI" : "")); + if (message.ToLower().StartsWith("helo ") || message.ToLower().StartsWith("ehlo ")) + { + ClientDomain = message.Substring(5).ToLower(); + + if (OnCommandReceived?.Invoke(message.Substring(0, 4), ClientDomain) is string reply) + { + await SendMessageAsync(reply); + continue; + } + + OnHelloReceived?.Invoke(ClientDomain); + + await SendMessageAsync("250-localhost, mock server here"); + if (SupportSmtpUTF8) await SendMessageAsync("250-SMTPUTF8"); + if (SslOptions != null && stream is not SslStream) await SendMessageAsync("250-STARTTLS"); + await SendMessageAsync( + "250 AUTH PLAIN LOGIN" + + (AdvertiseNtlmAuthSupport ? " NTLM" : "") + + (AdvertiseGssapiAuthSupport ? " GSSAPI" : "")); + + continue; + } - while ((message = await ReceiveMessageAsync()) != null) - { int colonIndex = message.IndexOf(':'); string command = colonIndex == -1 ? message : message.Substring(0, colonIndex); string argument = command.Length == message.Length ? string.Empty : message.Substring(colonIndex + 1).Trim(); - OnCommandReceived?.Invoke(command, argument); + if (OnCommandReceived?.Invoke(command, argument) is string response) + { + await SendMessageAsync(response); + continue; + } if (command.StartsWith("AUTH", StringComparison.OrdinalIgnoreCase)) { @@ -201,13 +247,32 @@ await SendMessageAsync( switch (command.ToUpper()) { + case "STARTTLS": + if (SslOptions == null || stream is SslStream) + { + await SendMessageAsync("454 TLS not available"); + break; + } + await SendMessageAsync("220 Ready to start TLS"); + + // Upgrade connection to TLS + var sslStream = new SslStream(stream); + await sslStream.AuthenticateAsServerAsync(SslOptions); + IsEncrypted = true; + TlsHostName = sslStream.TargetHostName; + + stream = sslStream; + break; + case "MAIL FROM": MailFrom = argument; + MailTo.Clear(); + Message = null; await SendMessageAsync("250 Ok"); break; case "RCPT TO": - MailTo = argument; + MailTo.Add(argument); await SendMessageAsync("250 Ok"); break; @@ -233,14 +298,7 @@ await SendMessageAsync( catch { } finally { - try - { - socket.Shutdown(SocketShutdown.Both); - } - finally - { - socket?.Close(); - } + stream.Dispose(); } } @@ -261,52 +319,180 @@ public void Dispose() } } - public class ParsedMailMessage { public readonly IReadOnlyDictionary Headers; public readonly string Body; + public readonly string RawBody; + public readonly List Attachments; private string GetHeader(string name) => Headers.TryGetValue(name, out string value) ? value : "NOT-PRESENT"; public string From => GetHeader("From"); public string To => GetHeader("To"); + public string Cc => GetHeader("Cc"); public string Subject => GetHeader("Subject"); - private ParsedMailMessage(Dictionary headers, string body) + private ContentType _contentType; + public ContentType ContentType => _contentType ??= new ContentType(GetHeader("Content-Type")); + + private ParsedMailMessage(Dictionary headers, string body, string rawBody, List attachments) { Headers = headers; Body = body; + RawBody = rawBody; + Attachments = attachments; } - public static ParsedMailMessage Parse(string data) + private static (Dictionary headers, string content) ParseContent(ReadOnlySpan data) { Dictionary headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + List attachments = new List(); - ReadOnlySpan dataSpan = data; string body = null; - while (!dataSpan.IsEmpty) + // Parse headers with support for folded lines + string currentHeaderName = null; + StringBuilder currentHeaderValue = null; + + while (!data.IsEmpty) { - int endOfLine = dataSpan.IndexOf('\n'); + int endOfLine = data.IndexOf('\n'); Debug.Assert(endOfLine != -1, "Expected valid \r\n terminated lines"); - var line = dataSpan.Slice(0, endOfLine).TrimEnd('\r'); + var line = data.Slice(0, endOfLine).TrimEnd('\r'); if (line.IsEmpty) { - body = dataSpan.Slice(endOfLine + 1).TrimEnd(stackalloc char[] { '\r', '\n' }).ToString(); + // End of headers section - add the last header if there is one + if (currentHeaderName != null && currentHeaderValue != null) + { + headers.Add(currentHeaderName, currentHeaderValue.ToString().Trim()); + } + + body = data.Slice(endOfLine + 1).TrimEnd(stackalloc char[] { '\r', '\n' }).ToString(); break; } - else + else if ((line[0] == ' ' || line[0] == '\t') && currentHeaderName != null) + { + // This is a folded line, append it to the current header value + currentHeaderValue.Append(' ').Append(line.ToString().TrimStart()); + } + else // new header { + // If we have a header being built, add it now + if (currentHeaderName != null && currentHeaderValue != null) + { + headers.Add(currentHeaderName, currentHeaderValue.ToString().Trim()); + } + + // Start a new header int colon = line.IndexOf(':'); Debug.Assert(colon != -1, "Expected a valid header"); - headers.Add(line.Slice(0, colon).Trim().ToString(), line.Slice(colon + 1).Trim().ToString()); - dataSpan = dataSpan.Slice(endOfLine + 1); + currentHeaderName = line.Slice(0, colon).Trim().ToString(); + currentHeaderValue = new StringBuilder(line.Slice(colon + 1).ToString()); + } + + data = data.Slice(endOfLine + 1); + } + + return (headers, body); + } + + public static ParsedMailMessage Parse(string data) + { + List attachments = new List(); + string rawBody; + (Dictionary headers, string body) = ParseContent(data); + rawBody = body; + + // Check if this is a multipart message + string contentType = headers.TryGetValue("Content-Type", out string ct) ? ct : string.Empty; + if (contentType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase)) + { + // Extract the boundary + string boundary = ExtractBoundary(contentType); + if (!string.IsNullOrEmpty(boundary)) + { + // Parse multipart body + (attachments, body) = ParseMultipartBody(body, boundary); } } - return new ParsedMailMessage(headers, body); + return new ParsedMailMessage(headers, body, rawBody, attachments); } + + private static string ExtractBoundary(string contentType) + { + int boundaryIndex = contentType.IndexOf("boundary=", StringComparison.OrdinalIgnoreCase); + if (boundaryIndex < 0) + return null; + + string boundaryPart = contentType.Substring(boundaryIndex + 9); + if (boundaryPart.StartsWith("\"")) + { + int endQuote = boundaryPart.IndexOf("\"", 1); + if (endQuote > 0) + return boundaryPart.Substring(1, endQuote - 1); + } + else + { + int endBoundary = boundaryPart.IndexOfAny(new[] { ';', ' ' }); + return endBoundary > 0 ? boundaryPart.Substring(0, endBoundary) : boundaryPart; + } + + return null; + } + + private static (List attachments, string textBody) ParseMultipartBody(string body, string boundary) + { + string textBody = null; + List attachments = new List(); + + string[] parts = body.Split(new[] { "--" + boundary }, StringSplitOptions.None); + + Debug.Assert(string.IsNullOrWhiteSpace(parts[0]), "Expected empty first part"); + Debug.Assert(parts[^1] == "--", "Expected empty last part"); + for (int i = 1; i < parts.Length - 1; i++) + { + string part = parts[i]; + + Debug.Assert(part.StartsWith("\r\n")); + + (Dictionary headers, string content) = ParseContent(part[2..]); + + ContentType contentType = new ContentType(headers["Content-Type"]); + + // Check if this part is an attachment + if (headers.TryGetValue("Content-Disposition", out string disposition) && + disposition.StartsWith("attachment", StringComparison.OrdinalIgnoreCase)) + { + attachments.Add(new ParsedAttachment + { + ContentType = contentType, + RawContent = content, + Headers = headers + }); + } + + // Check if this is a text part + else if (contentType.MediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) && + textBody == null) + { + textBody = content; + } + } + + return (attachments, textBody ?? ""); + } + } + + public class ParsedAttachment + { + public ContentType ContentType { get; set; } + public string RawContent { get; set; } + public IDictionary Headers { get; set; } + + private string GetHeader(string name) => Headers.TryGetValue(name, out string value) ? value : "NOT-PRESENT"; + public string ContentTransferEncoding => GetHeader("Content-Transfer-Encoding"); } } } diff --git a/src/libraries/System.Net.Mail/tests/Functional/MailMessageTest.cs b/src/libraries/System.Net.Mail/tests/Functional/MailMessageTest.cs index dc871a4d6846b1..c73daa15422856 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/MailMessageTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/MailMessageTest.cs @@ -192,7 +192,26 @@ blah blah string sent = DecodeSentMailMessage(messageWithSubjectAndBody).Raw; sent = Regex.Replace(sent, "Date:.*?\r\n", "Date: DATE\r\n"); - sent = Regex.Replace(sent, @"_.{8}-.{4}-.{4}-.{4}-.{12}", "_GUID"); + + // Find outer boundary (in the main Content-Type) + var outerBoundaryMatch = Regex.Match(sent, @"Content-Type: multipart/mixed;\s+boundary=(--boundary_\d+_[a-f0-9-]+)"); + // Find inner boundary (in the nested Content-Type) + var innerBoundaryMatch = Regex.Match(sent, @"Content-Type: multipart/alternative;\s+boundary=(--boundary_\d+_[a-f0-9-]+)"); + + if (outerBoundaryMatch.Success && innerBoundaryMatch.Success) + { + string outerBoundary = outerBoundaryMatch.Groups[1].Value; + string innerBoundary = innerBoundaryMatch.Groups[1].Value; + + // Replace all occurrences of these boundaries + sent = sent.Replace(outerBoundary, "--boundary_1_GUID"); + sent = sent.Replace(innerBoundary, "--boundary_0_GUID"); + } + else + { + // unify boundary GUIDs + sent = Regex.Replace(sent, @"--boundary_\d+_[a-f0-9-]+", "--boundary_?_GUID"); + } // name and charset can appear in different order Assert.Contains("; name=AttachmentName", sent); diff --git a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientAttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientAttachmentTest.cs new file mode 100644 index 00000000000000..a18ff95dc82c0a --- /dev/null +++ b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientAttachmentTest.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Net.Mime; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Mail.Tests +{ + public abstract class SmtpClientAttachmentTest : LoopbackServerTestBase where T : ISendMethodProvider + { + public SmtpClientAttachmentTest(ITestOutputHelper output) : base(output) + { + } + + private class ThrowingStream : Stream + { + + public override bool CanRead => throw new NotImplementedException(); + public override bool CanSeek => throw new NotImplementedException(); + public override bool CanWrite => throw new NotImplementedException(); + public override long Length => throw new NotImplementedException(); + public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public override void Flush() => throw new NotImplementedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new InvalidOperationException("Something wrong happened"); + public override long Seek(long offset, SeekOrigin origin) => throw new NotImplementedException(); + public override void SetLength(long length) => throw new NotImplementedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException(); + } + + [Fact] + public async Task AtachmentStreamThrows_Exception() + { + string attachmentFilename = "test.txt"; + byte[] attachmentContent = Encoding.UTF8.GetBytes("File Contents\r\n"); + + string body = "This is a test mail."; + + using var msg = new MailMessage() + { + From = new MailAddress("foo@example.com"), + To = { new MailAddress("baz@example.com") }, + Attachments = { + new Attachment(new ThrowingStream(), attachmentFilename, MediaTypeNames.Text.Plain) + }, + Subject = "Test Subject", + Body = body + }; + + await SendMail(msg); + } + + [Fact] + public async Task TextFileAttachment() + { + string attachmentFilename = "test.txt"; + byte[] attachmentContent = Encoding.UTF8.GetBytes("File Contents\r\n"); + + string body = "This is a test mail."; + + using var msg = new MailMessage() + { + From = new MailAddress("foo@example.com"), + To = { new MailAddress("baz@example.com") }, + Attachments = { + new Attachment(new MemoryStream(attachmentContent), attachmentFilename, MediaTypeNames.Text.Plain) + }, + Subject = "Test Subject", + Body = body + }; + + await SendMail(msg); + + Assert.Equal(body, Server.Message.Body); + Assert.Collection(Server.Message.Attachments, + attachment => + { + Assert.Equal(attachmentFilename, attachment.ContentType.Name); + Assert.Equal(MediaTypeNames.Text.Plain, attachment.ContentType.MediaType); + Assert.Equal("base64", attachment.ContentTransferEncoding); + Assert.Equal(attachmentContent, Convert.FromBase64String(attachment.RawContent)); + }); + } + } + + public class SmtpClientAttachmentTest_Send : SmtpClientAttachmentTest + { + public SmtpClientAttachmentTest_Send(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientAttachmentTest_SendAsync : SmtpClientAttachmentTest + { + public SmtpClientAttachmentTest_SendAsync(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientAttachmentTest_SendMailAsync : SmtpClientAttachmentTest + { + public SmtpClientAttachmentTest_SendMailAsync(ITestOutputHelper output) : base(output) { } + } +} diff --git a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientAuthTest.cs b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientAuthTest.cs new file mode 100644 index 00000000000000..ca0805958b8dc9 --- /dev/null +++ b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientAuthTest.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Mail.Tests; +using System.Net.Test.Common; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Mail.Tests +{ + public abstract class SmtpClientAuthTest : LoopbackServerTestBase + where TSendMethod : ISendMethodProvider + { + public static bool IsNtlmInstalled => Capability.IsNtlmInstalled(); + + public SmtpClientAuthTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + [PlatformSpecific(TestPlatforms.Windows)] // NTLM support required, see https://github.com/dotnet/runtime/issues/25827 + [SkipOnCoreClr("System.Net.Tests are flaky and/or long running: https://github.com/dotnet/runtime/issues/131", ~RuntimeConfiguration.Release)] + [ActiveIssue("https://github.com/dotnet/runtime/issues/131", TestRuntimes.Mono)] // System.Net.Tests are flaky and/or long running + public async Task TestCredentialsCopyInAsyncContext() + { + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + CredentialCache cache = new CredentialCache(); + cache.Add("localhost", Server.Port, "NTLM", CredentialCache.DefaultNetworkCredentials); + + Smtp.Credentials = cache; + + // The mock server doesn't actually understand NTLM, but still advertises support for it + Server.AdvertiseNtlmAuthSupport = true; + await SendMail(msg); + + Assert.Equal("NTLM", Server.AuthMethodUsed, StringComparer.OrdinalIgnoreCase); + } + + [ConditionalFact(nameof(IsNtlmInstalled))] + [ActiveIssue("https://github.com/dotnet/runtime/issues/65678", TestPlatforms.OSX | TestPlatforms.iOS | TestPlatforms.MacCatalyst)] + public async Task TestGssapiAuthentication() + { + Server.AdvertiseGssapiAuthSupport = true; + Server.ExpectedGssapiCredential = new NetworkCredential("foo", "bar"); + Smtp.Credentials = Server.ExpectedGssapiCredential; + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + await SendMail(msg); + + Assert.Equal("GSSAPI", Server.AuthMethodUsed, StringComparer.OrdinalIgnoreCase); + } + } + + public class SmtpClientAuthTest_Send : SmtpClientAuthTest + { + public SmtpClientAuthTest_Send(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientAuthTest_SendAsync : SmtpClientAuthTest + { + public SmtpClientAuthTest_SendAsync(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientAuthTest_SendMailAsync : SmtpClientAuthTest + { + public SmtpClientAuthTest_SendMailAsync(ITestOutputHelper output) : base(output) { } + } +} \ No newline at end of file diff --git a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs new file mode 100644 index 00000000000000..4ae8ccf4d692c8 --- /dev/null +++ b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Mail.Tests; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Mail.Tests +{ + public abstract class SmtpClientConnectionTest : LoopbackServerTestBase + where TSendMethod : ISendMethodProvider + { + public SmtpClientConnectionTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task SocketClosed() + { + Server.OnConnected = socket => socket.Close(); + await SendMail(new MailMessage("mono@novell.com", "everyone@novell.com", "introduction", "hello")); + } + + [Fact] + public async Task EHelloNotRecognized_RestartWithHello() + { + bool helloReceived = false; + Server.OnCommandReceived = (command, arg) => + { + helloReceived |= string.Equals(command, "HELO", StringComparison.OrdinalIgnoreCase); + if (string.Equals(command, "EHLO", StringComparison.OrdinalIgnoreCase)) + { + return "502 Not implemented"; + } + + return null; + }; + + await SendMail(new MailMessage("mono@novell.com", "everyone@novell.com", "introduction", "hello")); + Assert.True(helloReceived, "HELO command was not received."); + } + } + + public class SmtpClientConnectionTest_Send : SmtpClientConnectionTest + { + public SmtpClientConnectionTest_Send(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientConnectionTest_SendAsync : SmtpClientConnectionTest + { + public SmtpClientConnectionTest_SendAsync(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientConnectionTest_SendMailAsync : SmtpClientConnectionTest + { + public SmtpClientConnectionTest_SendMailAsync(ITestOutputHelper output) : base(output) { } + } +} diff --git a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientSendMailTest.cs b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientSendMailTest.cs new file mode 100644 index 00000000000000..a65a4d63d05dcb --- /dev/null +++ b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientSendMailTest.cs @@ -0,0 +1,328 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Mail.Tests; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Mail.Tests +{ + public abstract class SmtpClientSendMailTest : LoopbackServerTestBase where T : ISendMethodProvider + { + public SmtpClientSendMailTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Message_Null() + { + await SendMail(null, asyncDirectException: true); + } + + [Fact] + public async Task Network_Host_Whitespace() + { + Smtp.Host = " \r\n "; + await SendMail(new MailMessage("mono@novell.com", "everyone@novell.com", "introduction", "hello"), asyncDirectException: true); + } + + [Fact] + public async Task ServerDoesntExist_Throws() + { + Smtp.Host = Guid.NewGuid().ToString("N"); + await SendMail(new MailMessage("mono@novell.com", "everyone@novell.com", "introduction", "hello")); + } + + [Theory] + [InlineData("howdydoo")] + [InlineData("")] + [InlineData(null)] + [SkipOnCoreClr("System.Net.Tests are flaky and/or long running: https://github.com/dotnet/runtime/issues/131", ~RuntimeConfiguration.Release)] + [ActiveIssue("https://github.com/dotnet/runtime/issues/131", TestRuntimes.Mono)] // System.Net.Tests are flaky and/or long running + public async Task MailDelivery(string body) + { + Smtp.Credentials = new NetworkCredential("foo", "bar"); + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", body); + + await SendMail(msg); + + Assert.Equal("", Server.MailFrom); + Assert.Equal("", Assert.Single(Server.MailTo)); + Assert.Equal("hello", Server.Message.Subject); + Assert.Equal(body ?? "", Server.Message.Body); + Assert.Equal(GetClientDomain(), Server.ClientDomain); + Assert.Equal("foo", Server.Username); + Assert.Equal("bar", Server.Password); + Assert.Equal("LOGIN", Server.AuthMethodUsed, StringComparer.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] // Received subjectText. + [InlineData(true, false)] + [InlineData(true, true)] // Received subjectBase64. If subjectText is received, the test fails, and the results are inconsistent with those of synchronous methods. + public async Task SendMail_DeliveryFormat_SubjectEncoded(bool useSevenBit, bool useSmtpUTF8) + { + // If the server support `SMTPUTF8` and use `SmtpDeliveryFormat.International`, the server should received this subject. + const string subjectText = "Test \u6d4b\u8bd5 Contain \u5305\u542b UTF8"; + + // If the server does not support `SMTPUTF8` or use `SmtpDeliveryFormat.SevenBit`, the server should received this subject. + const string subjectBase64 = "=?utf-8?B?VGVzdCDmtYvor5UgQ29udGFpbiDljIXlkKsgVVRGOA==?="; + + // Setting up Server Support for `SMTPUTF8`. + Server.SupportSmtpUTF8 = useSmtpUTF8; + + if (useSevenBit) + { + // Subject will be encoded by Base64. + Smtp.DeliveryFormat = SmtpDeliveryFormat.SevenBit; + } + else + { + // If the server supports `SMTPUTF8`, subject will not be encoded. Otherwise, subject will be encoded by Base64. + Smtp.DeliveryFormat = SmtpDeliveryFormat.International; + } + + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", subjectText, "hello \u9ad8\u575a\u679c"); + msg.HeadersEncoding = msg.BodyEncoding = msg.SubjectEncoding = System.Text.Encoding.UTF8; + + await SendMail(msg); + + if (useSevenBit || !useSmtpUTF8) + { + Assert.Equal(subjectBase64, Server.Message.Subject); + } + else + { + Assert.Equal(subjectText, Server.Message.Subject); + } + } + + [Fact] + public async Task SendQUITOnDispose() + { + bool quitMessageReceived = false; + using ManualResetEventSlim quitReceived = new ManualResetEventSlim(); + Server.OnQuitReceived += _ => + { + quitMessageReceived = true; + quitReceived.Set(); + }; + + Smtp.Credentials = new NetworkCredential("Foo", "Bar"); + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + await SendMail(msg); + Assert.False(quitMessageReceived, "QUIT received"); + Smtp.Dispose(); + + // There is a latency between send/receive. + quitReceived.Wait(TimeSpan.FromSeconds(30)); + Assert.True(quitMessageReceived, "QUIT message not received"); + } + + [Fact] + public async Task TestMultipleMailDelivery() + { + Smtp.Timeout = 10000; + Smtp.Credentials = new NetworkCredential("foo", "bar"); + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + for (var i = 0; i < 5; i++) + { + await SendMail(msg); + + Assert.Equal("", Server.MailFrom); + Assert.Equal("", Assert.Single(Server.MailTo)); + Assert.Equal("hello", Server.Message.Subject); + Assert.Equal("howdydoo", Server.Message.Body); + Assert.Equal(GetClientDomain(), Server.ClientDomain); + Assert.Equal("foo", Server.Username); + Assert.Equal("bar", Server.Password); + Assert.Equal("LOGIN", Server.AuthMethodUsed, StringComparer.OrdinalIgnoreCase); + } + } + + [Theory] + [MemberData(nameof(SendMail_MultiLineDomainLiterals_Data))] + public async Task MultiLineDomainLiterals_Disabled_Throws(string from, string to) + { + Smtp.Credentials = new NetworkCredential("Foo", "Bar"); + + using var msg = new MailMessage(@from, @to, "subject", "body"); + + await SendMail(msg); + } + + public static IEnumerable SendMail_MultiLineDomainLiterals_Data() + { + foreach (string address in new[] { "foo@[\r\n bar]", "foo@[bar\r\n ]", "foo@[bar\r\n baz]" }) + { + yield return new object[] { address, "foo@example.com" }; + yield return new object[] { "foo@example.com", address }; + } + } + + [Fact] + public async Task MultipleRecipients_Success() + { + using var msg = new MailMessage() + { + From = new MailAddress("foo@example.com"), + To = { + new MailAddress("bar@example.com"), + new MailAddress("baz@example.com") + }, + CC = { + new MailAddress("cc1@example.com"), + new MailAddress("cc2@example.com"), + }, + Subject = "subject", + Body = "body" + }; + await SendMail(msg); + + Assert.Equal("", Server.MailFrom); + Assert.Equal(["", "", "", ""], Server.MailTo); + Assert.Equal("subject", Server.Message.Subject); + Assert.Equal("body", Server.Message.Body); + Assert.Equal("bar@example.com, baz@example.com", Server.Message.To); + Assert.Equal("cc1@example.com, cc2@example.com", Server.Message.Cc); + } + + [Fact] + public async Task MultipleRecipients_Failure_One() + { + Server.OnCommandReceived = (command, argument) => + { + if (string.Equals("RCPT TO", command, StringComparison.OrdinalIgnoreCase) && argument.Contains("bar")) + { + return "550 unknown recipient"; + } + + return null; + }; + + using var msg = new MailMessage() + { + From = new MailAddress("foo@example.com"), + To = { + new MailAddress("bar@example.com"), + new MailAddress("baz@example.com") + }, + CC = { + new MailAddress("cc1@example.com"), + new MailAddress("cc2@example.com"), + }, + Subject = "subject", + Body = "body" + }; + + var ex = await SendMail(msg, unwrapException: false); + Assert.Equal("", ex.FailedRecipient); + + // still expect the message to be sent since other recipients were available + Assert.Equal("body", Server.Message.Body); + Assert.Equal("bar@example.com, baz@example.com", Server.Message.To); + Assert.Equal("cc1@example.com, cc2@example.com", Server.Message.Cc); + } + + [Fact] + public async Task MultipleRecipients_Failure_Many() + { + Server.OnCommandReceived = (command, argument) => + { + if (string.Equals("RCPT TO", command, StringComparison.OrdinalIgnoreCase) && !argument.Contains("bar")) + { + return "550 unknown recipient"; + } + + return null; + }; + + using var msg = new MailMessage() + { + From = new MailAddress("foo@example.com"), + To = { + new MailAddress("bar@example.com"), + new MailAddress("baz@example.com") + }, + CC = { + new MailAddress("cc1@example.com"), + new MailAddress("cc2@example.com"), + }, + Subject = "subject", + Body = "body" + }; + + var ex = await SendMail(msg, unwrapException: false); + Assert.Collection(ex.InnerExceptions, + e => { Assert.Equal("", e.FailedRecipient); }, + e => { Assert.Equal("", e.FailedRecipient); }, + e => { Assert.Equal("", e.FailedRecipient); } + ); + + // still expect the message to be sent since other recipients were available + Assert.Equal("body", Server.Message.Body); + Assert.Equal("bar@example.com, baz@example.com", Server.Message.To); + Assert.Equal("cc1@example.com, cc2@example.com", Server.Message.Cc); + } + + [Fact] + public async Task MultipleRecipients_Failure_All() + { + Server.OnCommandReceived = (command, argument) => + { + if (string.Equals("RCPT TO", command, StringComparison.OrdinalIgnoreCase)) + { + return "550 unknown recipient"; + } + + return null; + }; + + using var msg = new MailMessage() + { + From = new MailAddress("foo@example.com"), + To = { + new MailAddress("bar@example.com"), + new MailAddress("baz@example.com") + }, + CC = { + new MailAddress("cc1@example.com"), + new MailAddress("cc2@example.com"), + }, + Subject = "subject", + Body = "body" + }; + + var ex = await SendMail(msg, unwrapException: false); + Assert.Collection(ex.InnerExceptions, + e => { Assert.Equal("", e.FailedRecipient); }, + e => { Assert.Equal("", e.FailedRecipient); }, + e => { Assert.Equal("", e.FailedRecipient); }, + e => { Assert.Equal("", e.FailedRecipient); } + ); + + // No recipients succeeded, nothing to send + Assert.Null(Server.Message); + } + } + + public class SmtpClientSendMailTest_Send : SmtpClientSendMailTest + { + public SmtpClientSendMailTest_Send(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientSendMailTest_SendAsync : SmtpClientSendMailTest + { + public SmtpClientSendMailTest_SendAsync(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientSendMailTest_SendMailAsync : SmtpClientSendMailTest + { + public SmtpClientSendMailTest_SendMailAsync(ITestOutputHelper output) : base(output) { } + } +} \ No newline at end of file diff --git a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientSpecifiedPickupDirectoryTest.cs b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientSpecifiedPickupDirectoryTest.cs new file mode 100644 index 00000000000000..595cb2eecdf0ef --- /dev/null +++ b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientSpecifiedPickupDirectoryTest.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.NetworkInformation; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Net.Mail.Tests; +using System.IO; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Mail.Tests +{ + public abstract class SmtpClientSpecifiedPickupDirectoryTest : LoopbackServerTestBase where T : ISendMethodProvider + { + class FileCleanupProvider : FileCleanupTestBase + { + // expose protected member + public new string TestDirectory => base.TestDirectory; + } + + FileCleanupProvider _fileCleanupProvider = new FileCleanupProvider(); + + public SmtpClientSpecifiedPickupDirectoryTest(ITestOutputHelper output) : base(output) + { + + } + + private string TempFolder + { + get + { + return _fileCleanupProvider.TestDirectory; + } + } + + public override void Dispose() + { + _fileCleanupProvider.Dispose(); + } + + [Fact] + public async Task Send() + { + Smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory; + Smtp.PickupDirectoryLocation = TempFolder; + await SendMail(new MailMessage("mono@novell.com", "everyone@novell.com", "introduction", "hello")); + + string[] files = Directory.GetFiles(TempFolder, "*"); + Assert.Equal(1, files.Length); + Assert.Equal(".eml", Path.GetExtension(files[0])); + } + + [Fact] + public async Task Send_SpecifiedPickupDirectory_MessageBodyDoesNotEncodeForTransport() + { + // This test verifies that a line fold which results in a dot appearing as the first character of + // a new line does not get dot-stuffed when the delivery method is pickup. To do so, it relies on + // folding happening at a precise location. If folding implementation details change, this test will + // likely fail and need to be updated accordingly. + + string padding = new string('a', 65); + + Smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory; + Smtp.PickupDirectoryLocation = TempFolder; + + await SendMail(new MailMessage("mono@novell.com", "everyone@novell.com", "introduction", padding + ".")); + + string[] files = Directory.GetFiles(TempFolder, "*"); + Assert.Equal(1, files.Length); + Assert.Equal(".eml", Path.GetExtension(files[0])); + + string message = File.ReadAllText(files[0]); + Assert.EndsWith($"{padding}=\r\n.\r\n", message); + } + + [Theory] + [InlineData("some_path_not_exist")] + [InlineData("")] + [InlineData(null)] + [InlineData("\0abc")] + public async Task Send_SpecifiedPickupDirectoryInvalid(string location) + { + Smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory; + Smtp.PickupDirectoryLocation = location; + await SendMail(new MailMessage("mono@novell.com", "everyone@novell.com", "introduction", "hello"), asyncDirectException: true); + } + } + + public class SmtpClientSpecifiedPickupDirectoryTest_Send : SmtpClientSpecifiedPickupDirectoryTest + { + public SmtpClientSpecifiedPickupDirectoryTest_Send(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientSpecifiedPickupDirectoryTest_SendAsync : SmtpClientSpecifiedPickupDirectoryTest + { + public SmtpClientSpecifiedPickupDirectoryTest_SendAsync(ITestOutputHelper output) : base(output) { } + } + + public class SmtpClientSpecifiedPickupDirectoryTest_SendMailAsync : SmtpClientSpecifiedPickupDirectoryTest + { + public SmtpClientSpecifiedPickupDirectoryTest_SendMailAsync(ITestOutputHelper output) : base(output) { } + } +} \ No newline at end of file diff --git a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTest.cs b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTest.cs index c8c5734b04cf44..332b14b3baed78 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTest.cs @@ -18,7 +18,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.DotNet.RemoteExecutor; -using Systen.Net.Mail.Tests; using System.Net.Test.Common; using Xunit; @@ -29,8 +28,6 @@ public class SmtpClientTest : FileCleanupTestBase { private SmtpClient _smtp; - public static bool IsNtlmInstalled => Capability.IsNtlmInstalled(); - private SmtpClient Smtp { get @@ -195,71 +192,12 @@ public void Port_Value_Invalid(int value) Assert.Throws(() => Smtp.Port = value); } - [Fact] - public void Send_Message_Null() - { - Assert.Throws(() => Smtp.Send(null)); - } - [Fact] public void Send_Network_Host_Null() { Assert.Throws(() => Smtp.Send("mono@novell.com", "everyone@novell.com", "introduction", "hello")); } - [Fact] - public void Send_Network_Host_Whitespace() - { - Smtp.Host = " \r\n "; - Assert.Throws(() => Smtp.Send("mono@novell.com", "everyone@novell.com", "introduction", "hello")); - } - - [Fact] - public void Send_SpecifiedPickupDirectory() - { - Smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory; - Smtp.PickupDirectoryLocation = TempFolder; - Smtp.Send("mono@novell.com", "everyone@novell.com", "introduction", "hello"); - - string[] files = Directory.GetFiles(TempFolder, "*"); - Assert.Equal(1, files.Length); - Assert.Equal(".eml", Path.GetExtension(files[0])); - } - - [Fact] - public void Send_SpecifiedPickupDirectory_MessageBodyDoesNotEncodeForTransport() - { - // This test verifies that a line fold which results in a dot appearing as the first character of - // a new line does not get dot-stuffed when the delivery method is pickup. To do so, it relies on - // folding happening at a precise location. If folding implementation details change, this test will - // likely fail and need to be updated accordingly. - - string padding = new string('a', 65); - - Smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory; - Smtp.PickupDirectoryLocation = TempFolder; - Smtp.Send("mono@novell.com", "everyone@novell.com", "introduction", padding + "."); - - string[] files = Directory.GetFiles(TempFolder, "*"); - Assert.Equal(1, files.Length); - Assert.Equal(".eml", Path.GetExtension(files[0])); - - string message = File.ReadAllText(files[0]); - Assert.EndsWith($"{padding}=\r\n.\r\n", message); - } - - [Theory] - [InlineData("some_path_not_exist")] - [InlineData("")] - [InlineData(null)] - [InlineData("\0abc")] - public void Send_SpecifiedPickupDirectoryInvalid(string location) - { - Smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory; - Smtp.PickupDirectoryLocation = location; - Assert.Throws(() => Smtp.Send("mono@novell.com", "everyone@novell.com", "introduction", "hello")); - } - [Theory] [InlineData(0)] [InlineData(50)] @@ -307,7 +245,7 @@ public void TestMailDelivery() client.Send(msg); Assert.Equal("", server.MailFrom); - Assert.Equal("", server.MailTo); + Assert.Equal("", Assert.Single(server.MailTo)); Assert.Equal("hello", server.Message.Subject); Assert.Equal("howdydoo", server.Message.Body); Assert.Equal(GetClientDomain(), server.ClientDomain); @@ -341,106 +279,6 @@ public void TestZeroTimeout() } } - [Theory] - [InlineData("howdydoo")] - [InlineData("")] - [InlineData(null)] - [SkipOnCoreClr("System.Net.Tests are flaky and/or long running: https://github.com/dotnet/runtime/issues/131", ~RuntimeConfiguration.Release)] - [ActiveIssue("https://github.com/dotnet/runtime/issues/131", TestRuntimes.Mono)] // System.Net.Tests are flaky and/or long running - public async Task TestMailDeliveryAsync(string body) - { - using var server = new LoopbackSmtpServer(); - using SmtpClient client = server.CreateClient(); - MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", body); - - await client.SendMailAsync(msg).WaitAsync(TimeSpan.FromSeconds(30)); - - Assert.Equal("", server.MailFrom); - Assert.Equal("", server.MailTo); - Assert.Equal("hello", server.Message.Subject); - Assert.Equal(body ?? "", server.Message.Body); - Assert.Equal(GetClientDomain(), server.ClientDomain); - } - - [Fact] - [PlatformSpecific(TestPlatforms.Windows)] // NTLM support required, see https://github.com/dotnet/runtime/issues/25827 - [SkipOnCoreClr("System.Net.Tests are flaky and/or long running: https://github.com/dotnet/runtime/issues/131", ~RuntimeConfiguration.Release)] - [ActiveIssue("https://github.com/dotnet/runtime/issues/131", TestRuntimes.Mono)] // System.Net.Tests are flaky and/or long running - public async Task TestCredentialsCopyInAsyncContext() - { - using var server = new LoopbackSmtpServer(); - using SmtpClient client = server.CreateClient(); - MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); - - CredentialCache cache = new CredentialCache(); - cache.Add("localhost", server.Port, "NTLM", CredentialCache.DefaultNetworkCredentials); - - client.Credentials = cache; - - // The mock server doesn't actually understand NTLM, but still advertises support for it - server.AdvertiseNtlmAuthSupport = true; - await Assert.ThrowsAsync(async () => await client.SendMailAsync(msg)); - - Assert.Equal("NTLM", server.AuthMethodUsed, StringComparer.OrdinalIgnoreCase); - } - - - [Theory] - [InlineData(false, false, false)] - [InlineData(false, false, true)] // Received subjectText. - [InlineData(false, true, false)] - [InlineData(false, true, true)] - [InlineData(true, false, false)] - [InlineData(true, false, true)] // Received subjectText. - [InlineData(true, true, false)] - [InlineData(true, true, true)] // Received subjectBase64. If subjectText is received, the test fails, and the results are inconsistent with those of synchronous methods. - public void SendMail_DeliveryFormat_SubjectEncoded(bool useAsyncSend, bool useSevenBit, bool useSmtpUTF8) - { - // If the server support `SMTPUTF8` and use `SmtpDeliveryFormat.International`, the server should received this subject. - const string subjectText = "Test \u6d4b\u8bd5 Contain \u5305\u542b UTF8"; - - // If the server does not support `SMTPUTF8` or use `SmtpDeliveryFormat.SevenBit`, the server should received this subject. - const string subjectBase64 = "=?utf-8?B?VGVzdCDmtYvor5UgQ29udGFpbiDljIXlkKsgVVRGOA==?="; - - using var server = new LoopbackSmtpServer(); - using SmtpClient client = server.CreateClient(); - - // Setting up Server Support for `SMTPUTF8`. - server.SupportSmtpUTF8 = useSmtpUTF8; - - if (useSevenBit) - { - // Subject will be encoded by Base64. - client.DeliveryFormat = SmtpDeliveryFormat.SevenBit; - } - else - { - // If the server supports `SMTPUTF8`, subject will not be encoded. Otherwise, subject will be encoded by Base64. - client.DeliveryFormat = SmtpDeliveryFormat.International; - } - - MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", subjectText, "hello \u9ad8\u575a\u679c"); - msg.HeadersEncoding = msg.BodyEncoding = msg.SubjectEncoding = System.Text.Encoding.UTF8; - - if (useAsyncSend) - { - client.SendMailAsync(msg).Wait(); - } - else - { - client.Send(msg); - } - - if (useSevenBit || !useSmtpUTF8) - { - Assert.Equal(subjectBase64, server.Message.Subject); - } - else - { - Assert.Equal(subjectText, server.Message.Subject); - } - } - [Fact] public void SendMailAsync_CanBeCanceled_CancellationToken_SetAlready() { @@ -486,132 +324,12 @@ public async Task SendMailAsync_CanBeCanceled_CancellationToken() await Task.Run(() => client.SendMailAsync(message)).WaitAsync(TestHelper.PassingTestTimeout); Assert.Equal("", server.MailFrom); - Assert.Equal("", server.MailTo); + Assert.Equal("", Assert.Single(server.MailTo)); Assert.Equal("Foo", server.Message.Subject); Assert.Equal("Bar", server.Message.Body); Assert.Equal(GetClientDomain(), server.ClientDomain); } private static string GetClientDomain() => IPGlobalProperties.GetIPGlobalProperties().HostName.Trim().ToLower(); - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task SendMail_SendQUITOnDispose(bool asyncSend) - { - bool quitMessageReceived = false; - using ManualResetEventSlim quitReceived = new ManualResetEventSlim(); - using var server = new LoopbackSmtpServer(); - server.OnQuitReceived += _ => - { - quitMessageReceived = true; - quitReceived.Set(); - }; - - using (SmtpClient client = server.CreateClient()) - { - client.Credentials = new NetworkCredential("Foo", "Bar"); - MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); - if (asyncSend) - { - await client.SendMailAsync(msg).WaitAsync(TimeSpan.FromSeconds(30)); - } - else - { - client.Send(msg); - } - Assert.False(quitMessageReceived, "QUIT received"); - } - - // There is a latency between send/receive. - quitReceived.Wait(TimeSpan.FromSeconds(30)); - Assert.True(quitMessageReceived, "QUIT message not received"); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task TestMultipleMailDelivery(bool asyncSend) - { - using var server = new LoopbackSmtpServer(); - using SmtpClient client = server.CreateClient(); - client.Timeout = 10000; - client.Credentials = new NetworkCredential("foo", "bar"); - MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); - - for (var i = 0; i < 5; i++) - { - if (asyncSend) - { - using var cts = new CancellationTokenSource(10000); - await client.SendMailAsync(msg, cts.Token); - } - else - { - client.Send(msg); - } - - Assert.Equal("", server.MailFrom); - Assert.Equal("", server.MailTo); - Assert.Equal("hello", server.Message.Subject); - Assert.Equal("howdydoo", server.Message.Body); - Assert.Equal(GetClientDomain(), server.ClientDomain); - Assert.Equal("foo", server.Username); - Assert.Equal("bar", server.Password); - Assert.Equal("LOGIN", server.AuthMethodUsed, StringComparer.OrdinalIgnoreCase); - } - } - - [ConditionalFact(nameof(IsNtlmInstalled))] - [ActiveIssue("https://github.com/dotnet/runtime/issues/65678", TestPlatforms.OSX | TestPlatforms.iOS | TestPlatforms.MacCatalyst)] - public void TestGssapiAuthentication() - { - using var server = new LoopbackSmtpServer(); - server.AdvertiseGssapiAuthSupport = true; - server.ExpectedGssapiCredential = new NetworkCredential("foo", "bar"); - using SmtpClient client = server.CreateClient(); - client.Credentials = server.ExpectedGssapiCredential; - MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); - - client.Send(msg); - - Assert.Equal("GSSAPI", server.AuthMethodUsed, StringComparer.OrdinalIgnoreCase); - } - - [Theory] - [MemberData(nameof(SendMail_MultiLineDomainLiterals_Data))] - public async Task SendMail_MultiLineDomainLiterals_Disabled_Throws(string from, string to, bool asyncSend) - { - using var server = new LoopbackSmtpServer(); - - using SmtpClient client = server.CreateClient(); - client.Credentials = new NetworkCredential("Foo", "Bar"); - - using var msg = new MailMessage(@from, @to, "subject", "body"); - - await Assert.ThrowsAsync(async () => - { - if (asyncSend) - { - await client.SendMailAsync(msg).WaitAsync(TimeSpan.FromSeconds(30)); - } - else - { - client.Send(msg); - } - }); - } - - public static IEnumerable SendMail_MultiLineDomainLiterals_Data() - { - foreach (bool async in new[] { true, false }) - { - foreach (string address in new[] { "foo@[\r\n bar]", "foo@[bar\r\n ]", "foo@[bar\r\n baz]" }) - { - yield return new object[] { address, "foo@example.com", async }; - yield return new object[] { "foo@example.com", address, async }; - } - } - } } } diff --git a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTlsTest.cs b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTlsTest.cs new file mode 100644 index 00000000000000..1d835acb4f4305 --- /dev/null +++ b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTlsTest.cs @@ -0,0 +1,195 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.NetworkInformation; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Net.Mail.Tests; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Mail.Tests +{ + // Common test setup to share across test cases. + public class CertificateSetup : IDisposable + { + public readonly X509Certificate2 serverCert; + public readonly X509Certificate2Collection serverChain; + public readonly SslStreamCertificateContext serverCertContext; + + public CertificateSetup() + { + (serverCert, serverChain) = System.Net.Test.Common.Configuration.Certificates.GenerateCertificates("localhost", nameof(SmtpClientTlsTest<>)); + serverCertContext = SslStreamCertificateContext.Create(serverCert, serverChain); + } + + public void Dispose() + { + serverCert.Dispose(); + foreach (var c in serverChain) + { + c.Dispose(); + } + } + } + + public abstract class SmtpClientTlsTest : LoopbackServerTestBase + where TSendMethod : ISendMethodProvider + { + private CertificateSetup _certificateSetup; + private Func? _serverCertValidationCallback; + + public SmtpClientTlsTest(ITestOutputHelper output, CertificateSetup certificateSetup) : base(output) + { + _certificateSetup = certificateSetup; + Server.SslOptions = new SslServerAuthenticationOptions + { + ServerCertificateContext = _certificateSetup.serverCertContext, + ClientCertificateRequired = false, + }; + +#pragma warning disable SYSLIB0014 // ServicePointManager is obsolete + ServicePointManager.ServerCertificateValidationCallback = ServerCertValidationCallback; +#pragma warning restore SYSLIB0014 // ServicePointManager is obsolete + } + + [Fact] + public async Task EnableSsl_ServerSupports_UsesTls() + { + _serverCertValidationCallback = (cert, chain, errors) => + { + return true; + }; + + Smtp.Credentials = new NetworkCredential("foo", "bar"); + Smtp.EnableSsl = true; + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + await SendMail(msg); + Assert.True(Server.IsEncrypted, "TLS was not negotiated."); + Assert.Equal(Smtp.Host, Server.TlsHostName); + } + + [Theory] + [InlineData("500 T'was just a jest.")] + [InlineData("300 I don't know what I am doing.")] + [InlineData("I don't know what I am doing.")] + public async Task EnableSsl_ServerError(string reply) + { + Smtp.EnableSsl = true; + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + Server.OnCommandReceived = (command, parameter) => + { + if (string.Equals(command, "STARTTLS", StringComparison.OrdinalIgnoreCase)) + return reply; + + return null; + }; + + await SendMail(msg); + } + + + [Fact] + public async Task EnableSsl_NoServerSupport_NoTls() + { + Server.SslOptions = null; + + Smtp.Credentials = new NetworkCredential("foo", "bar"); + Smtp.EnableSsl = true; + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + await SendMail(msg); + } + + [Fact] + public async Task DisableSslServerSupport_NoTls() + { + + Smtp.Credentials = new NetworkCredential("foo", "bar"); + Smtp.EnableSsl = false; + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + await SendMail(msg); + Assert.False(Server.IsEncrypted, "TLS was negotiated when it should not have been."); + } + + [Fact] + public async Task AuthenticationException_Propagates() + { + _serverCertValidationCallback = (cert, chain, errors) => + { + return false; // force auth errors + }; + + Smtp.Credentials = new NetworkCredential("foo", "bar"); + Smtp.EnableSsl = true; + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + await SendMail(msg); + } + + [Fact] + public async Task ClientCertificateRequired_Sent() + { + Server.SslOptions.ClientCertificateRequired = true; + X509Certificate2 clientCert = _certificateSetup.serverCert; // use the server cert as a client cert for testing + X509Certificate2? receivedClientCert = null; + Server.SslOptions.RemoteCertificateValidationCallback = (sender, cert, chain, errors) => + { + receivedClientCert = cert as X509Certificate2; + return true; + }; + + _serverCertValidationCallback = (cert, chain, errors) => + { + return true; + }; + + Smtp.Credentials = new NetworkCredential("foo", "bar"); + Smtp.EnableSsl = true; + Smtp.ClientCertificates.Add(clientCert); + + MailMessage msg = new MailMessage("foo@example.com", "bar@example.com", "hello", "howdydoo"); + + await SendMail(msg); + Assert.True(Server.IsEncrypted, "TLS was not negotiated."); + Assert.Equal(clientCert, receivedClientCert); + } + + private bool ServerCertValidationCallback(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) + { + if (_serverCertValidationCallback != null) + { + return _serverCertValidationCallback((X509Certificate2)certificate!, chain!, sslPolicyErrors); + } + + // Default validation: check if the certificate is valid. + return sslPolicyErrors == SslPolicyErrors.None; + } + } + + // since the tests change global state (ServicePointManager.ServerCertificateValidationCallback), we need to run them in isolation + + [Collection(nameof(DisableParallelization))] + public class SmtpClientTlsTest_Send : SmtpClientTlsTest, IClassFixture + { + public SmtpClientTlsTest_Send(ITestOutputHelper output, CertificateSetup certificateSetup) : base(output, certificateSetup) { } + } + + [Collection(nameof(DisableParallelization))] + public class SmtpClientTlsTest_SendAsync : SmtpClientTlsTest, IClassFixture + { + public SmtpClientTlsTest_SendAsync(ITestOutputHelper output, CertificateSetup certificateSetup) : base(output, certificateSetup) { } + } + + [Collection(nameof(DisableParallelization))] + public class SmtpClientTlsTest_SendMailAsync : SmtpClientTlsTest, IClassFixture + { + public SmtpClientTlsTest_SendMailAsync(ITestOutputHelper output, CertificateSetup certificateSetup) : base(output, certificateSetup) { } + } +} \ No newline at end of file diff --git a/src/libraries/System.Net.Mail/tests/Functional/System.Net.Mail.Functional.Tests.csproj b/src/libraries/System.Net.Mail/tests/Functional/System.Net.Mail.Functional.Tests.csproj index 28ea42dedc3795..f75c2e37169ee7 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/System.Net.Mail.Functional.Tests.csproj +++ b/src/libraries/System.Net.Mail/tests/Functional/System.Net.Mail.Functional.Tests.csproj @@ -21,6 +21,12 @@ + + + + + + + + + + + +