diff --git a/scripts/update-version.ps1 b/scripts/update-version.ps1 index 5e7f35d5a..fd9329e2b 100644 --- a/scripts/update-version.ps1 +++ b/scripts/update-version.ps1 @@ -52,7 +52,7 @@ Update-TextVersion $PSScriptRoot/../doc/MailTemplate.Result.txt $OldVersion $New Update-TextVersion $PSScriptRoot/../doc/MailTemplate.Announce.txt $OldVersion $NewVersion Update-TextVersion $PSScriptRoot/build-preview.ps1 $OldVersion $NewVersion Update-TextVersion $PSScriptRoot/build-release.ps1 $OldVersion $NewVersion -Update-XmlVersion $PSScriptRoot/../src/log4net/log4net.csproj $NewVersion '/Project/PropertyGroup/Version' +Update-XmlVersion $PSScriptRoot/../src/Directory.Build.props $NewVersion '/Project/PropertyGroup/VersionPrefix' Update-XmlVersion $PSScriptRoot/../src/Directory.Build.props $OldVersion '/Project/PropertyGroup/Log4NetPackageVersion' Update-XmlVersion $PSScriptRoot/../examples/Directory.Build.props $OldVersion '/Project/PropertyGroup/Version' Update-TextVersion $PSScriptRoot/../src/site/antora/modules/ROOT/partials/supported-versions.adoc $OldVersion $NewVersion diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4cdce3398..36f673f7f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -14,7 +14,9 @@ en;en-US + 3.3.3 3.3.2 + 4.17.0 4.5.0 8.0.0 diff --git a/src/changelog/3.3.3/300-add-mailkit-based-smtpappender.xml b/src/changelog/3.3.3/300-add-mailkit-based-smtpappender.xml new file mode 100644 index 000000000..6d883b720 --- /dev/null +++ b/src/changelog/3.3.3/300-add-mailkit-based-smtpappender.xml @@ -0,0 +1,11 @@ + + + + + Add a MailKit based `SmtpAppender` in the new `log4net.Ext.Mail` assembly, + because Microsoft no longer recommends `System.Net.Mail.SmtpClient` for new development + (requested by @DietzeC, implemented by @FreeAndNil in https://github.com/apache/logging-log4net/pull/302[#302]) + \ No newline at end of file diff --git a/src/log4net.Ext.Mail.Tests/.editorconfig b/src/log4net.Ext.Mail.Tests/.editorconfig new file mode 100644 index 000000000..fb1bc15f4 --- /dev/null +++ b/src/log4net.Ext.Mail.Tests/.editorconfig @@ -0,0 +1,2 @@ +# CA1861: Avoid constant arrays as arguments +dotnet_diagnostic.CA1861.severity = none \ No newline at end of file diff --git a/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs b/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs new file mode 100644 index 000000000..7b268952b --- /dev/null +++ b/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs @@ -0,0 +1,173 @@ +#region Apache License +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to you under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +#endregion + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; + +using log4net.Ext.Mail.Appender; +using log4net.Ext.Mail.Appender.Internal; + +using MailKit.Security; + +using MimeKit; + +namespace log4net.Ext.Mail.Tests.Appender; + +/// +/// An that records what the appender asked it to do +/// instead of talking to an SMTP server. +/// +internal sealed class FakeSmtpTransport : ISmtpTransport +{ + /// + /// The names of the transport methods that were called, in order. + /// + internal List Calls { get; } = []; + + internal string? ConnectedHost { get; private set; } + + internal int ConnectedPort { get; private set; } + + internal SecureSocketOptions SecureSocketOptions { get; private set; } + + internal ICredentials? Credentials { get; private set; } + + internal SaslMechanism? SaslMechanism { get; private set; } + + /// + /// A snapshot of every message passed to , taken before the + /// appender disposes the . + /// + internal List SentMails { get; } = []; + + internal bool DisconnectedWithQuit { get; private set; } + + internal bool IsDisposed { get; private set; } + + /// + /// When set, throws this instead of recording the message. + /// + internal Exception? SendException { get; set; } + + public bool IsConnected { get; private set; } + + public bool IsAuthenticated { get; private set; } + + public void Connect(string host, int port, SecureSocketOptions secureSocketOptions) + { + Calls.Add(nameof(Connect)); + ConnectedHost = host; + ConnectedPort = port; + SecureSocketOptions = secureSocketOptions; + IsConnected = true; + } + + public void Authenticate(ICredentials credentials) + { + Calls.Add(nameof(Authenticate)); + Credentials = credentials; + IsAuthenticated = true; + } + + public void Authenticate(SaslMechanism mechanism) + { + Calls.Add(nameof(Authenticate)); + SaslMechanism = mechanism; + IsAuthenticated = true; + } + + public void Send(MimeMessage message) + { + Calls.Add(nameof(Send)); + if (SendException is Exception exception) + { + throw exception; + } + SentMails.Add(new SentMail(message)); + } + + public void Disconnect(bool quit) + { + Calls.Add(nameof(Disconnect)); + DisconnectedWithQuit = quit; + IsConnected = false; + } + + public void Dispose() + { + Calls.Add(nameof(Dispose)); + IsDisposed = true; + } +} + +/// +/// The parts of a the tests assert on, captured eagerly +/// because the appender disposes the message once it has been sent. +/// +internal sealed class SentMail +{ + internal SentMail(MimeMessage message) + { + From = Addresses(message.From); + To = Addresses(message.To); + Cc = Addresses(message.Cc); + Bcc = Addresses(message.Bcc); + ReplyTo = Addresses(message.ReplyTo); + Subject = message.Subject; + Priority = message.Priority; + + Header? subjectHeader = message.Headers.FirstOrDefault(h => h.Id == HeaderId.Subject); + RawSubjectHeader = subjectHeader is null + ? string.Empty + : Encoding.ASCII.GetString(subjectHeader.RawValue); + + TextPart? textPart = message.Body as TextPart; + Body = textPart?.Text ?? string.Empty; + BodyCharset = textPart?.ContentType.Charset; + } + + internal string[] From { get; } + + internal string[] To { get; } + + internal string[] Cc { get; } + + internal string[] Bcc { get; } + + internal string[] ReplyTo { get; } + + internal string? Subject { get; } + + /// + /// The on-the-wire subject header, so that tests can check the applied charset. + /// + internal string RawSubjectHeader { get; } + + internal MessagePriority Priority { get; } + + internal string Body { get; } + + internal string? BodyCharset { get; } + + private static string[] Addresses(InternetAddressList list) + => list.Mailboxes.Select(m => m.Address).ToArray(); +} diff --git a/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs new file mode 100644 index 000000000..5a7117c06 --- /dev/null +++ b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs @@ -0,0 +1,547 @@ +#region Apache License +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to you under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +#endregion + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Mail; +using System.Text; +using log4net.Core; +using log4net.Ext.Mail.Appender; +using log4net.Ext.Mail.Appender.Internal; +using log4net.Layout; +using MailKit.Security; +using MimeKit; +using NUnit.Framework; + +namespace log4net.Ext.Mail.Tests.Appender; + +/// +/// Unit tests for the MailKit based . No mail leaves the +/// process: every test drives a . +/// +[TestFixture] +public class SmtpAppenderTest +{ + /// + /// An that collects what the appender reported. + /// + private sealed class SilentErrorHandler : IErrorHandler + { + private readonly StringBuilder _buffer = new(); + + public string Message => _buffer.ToString(); + + public void Error(string message) => _buffer.Append(message + '\n'); + + public void Error(string message, Exception e) => _buffer.Append(message + '\n' + e.Message + '\n'); + + public void Error(string message, Exception? e, ErrorCode errorCode) + => _buffer.Append(message + '\n' + e?.Message + '\n'); + } + + private FakeSmtpTransport _transport = null!; + private SilentErrorHandler _errorHandler = null!; + + [SetUp] + public void SetUp() + { + _transport = new FakeSmtpTransport(); + _errorHandler = new SilentErrorHandler(); + } + + [TearDown] + public void TearDown() => _transport.Dispose(); + + /// + /// Creates an appender wired to with the minimum required options, + /// configured so that every appended event is sent immediately. + /// + private SmtpAppender CreateAppender(string? header = null, string? footer = null) + { + PatternLayout layout = new() { ConversionPattern = "%m%n", Header = header, Footer = footer }; + layout.ActivateOptions(); + + return new(() => _transport) + { + Layout = layout, + ErrorHandler = _errorHandler, + SmtpHost = "mail.example.com", + From = "from@example.com", + To = "to@example.com", + Subject = "subject", + // BufferSize of 1 makes BufferingAppenderSkeleton send each event straight away. + BufferSize = 1, + }; + } + + private static LoggingEvent CreateEvent(string message) + => new(new LoggingEventData + { + LoggerName = "TestLogger", + Level = Level.Error, + Message = message, + TimeStampUtc = DateTime.UtcNow, + }); + + /// + /// Activates the appender and appends a single event, which triggers one send. + /// + private static void Append(SmtpAppender appender, string message = "log message") + { + appender.ActivateOptions(); + appender.DoAppend(CreateEvent(message)); + } + + [Test] + public void SendsOneMailPerEventWhenNotBuffering() + { + SmtpAppender appender = CreateAppender(); + + Append(appender); + + Assert.That(_errorHandler.Message, Is.Empty); + Assert.That(_transport.SentMails, Has.Count.EqualTo(1)); + } + + [Test] + public void ConnectsToConfiguredHostAndPort() + { + SmtpAppender appender = CreateAppender(); + appender.SmtpHost = "smtp.internal"; + appender.Port = 2525; + + Append(appender); + + Assert.That(_transport.ConnectedHost, Is.EqualTo("smtp.internal")); + Assert.That(_transport.ConnectedPort, Is.EqualTo(2525)); + } + + [Test] + public void DefaultPortIs25() + { + SmtpAppender appender = CreateAppender(); + + Append(appender); + + Assert.That(_transport.ConnectedPort, Is.EqualTo(25)); + } + + [Test] + public void ConnectAuthenticateSendDisconnectHappenInOrder() + { + SmtpAppender appender = CreateAppender(); + appender.Authentication = SmtpAppender.SmtpAuthentication.Basic; + appender.Username = "user"; + appender.Password = "secret"; + + Append(appender); + + Assert.That(_transport.Calls, Is.EqualTo(new[] { "Connect", "Authenticate", "Send", "Disconnect", "Dispose" })); + } + + [Test] + public void DoesNotAuthenticateWhenAuthenticationIsNone() + { + SmtpAppender appender = CreateAppender(); + + Append(appender); + + Assert.That(_transport.IsAuthenticated, Is.False); + Assert.That(_transport.Calls, Does.Not.Contain("Authenticate")); + } + + [Test] + public void BasicAuthenticationPassesUsernameAndPassword() + { + SmtpAppender appender = CreateAppender(); + appender.Authentication = SmtpAppender.SmtpAuthentication.Basic; + appender.Username = "user"; + appender.Password = "secret"; + + Append(appender); + + Assert.That(_transport.SaslMechanism, Is.Null); + NetworkCredential credential = (NetworkCredential)_transport.Credentials!; + Assert.That(credential.UserName, Is.EqualTo("user")); + Assert.That(credential.Password, Is.EqualTo("secret")); + } + + [Test] + public void NtlmAuthenticationUsesTheNtlmSaslMechanism() + { + SmtpAppender appender = CreateAppender(); + appender.Authentication = SmtpAppender.SmtpAuthentication.Ntlm; + appender.Username = "user"; + appender.Password = "secret"; + + Append(appender); + + Assert.That(_transport.Credentials, Is.Null); + Assert.That(_transport.SaslMechanism, Is.InstanceOf()); + Assert.That(_transport.SaslMechanism!.Credentials.GetCredential(null, null).UserName, Is.EqualTo("user")); + } + + [Test] + public void EnableSslOffConnectsWithoutTransportSecurity() + { + SmtpAppender appender = CreateAppender(); + + Append(appender); + + Assert.That(_transport.SecureSocketOptions, Is.EqualTo(SecureSocketOptions.None)); + } + + [Test] + public void EnableSslOnNegotiatesTransportSecurity() + { + SmtpAppender appender = CreateAppender(); + appender.EnableSsl = true; + + Append(appender); + + Assert.That(_transport.SecureSocketOptions, Is.EqualTo(SecureSocketOptions.Auto)); + } + + [Test] + public void BodyContainsTheRenderedEvent() + { + SmtpAppender appender = CreateAppender(); + + Append(appender, "something broke"); + + Assert.That(_transport.SentMails[0].Body, Does.Contain("something broke")); + } + + [Test] + public void BodyIsWrappedInLayoutHeaderAndFooter() + { + SmtpAppender appender = CreateAppender(header: "<
>", footer: "<