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: "<