From 37762d0b53ed19b7cdc9830282aee2d4eea6e679 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Tue, 28 Jul 2026 16:22:44 +0200 Subject: [PATCH 1/8] #300 create new assemblies for SmtpAppender --- scripts/update-version.ps1 | 2 +- src/Directory.Build.props | 1 + .../log4net.Ext.Mail.Tests.csproj | 33 ++++++++ src/log4net.Ext.Mail/log4net.Ext.Mail.csproj | 75 +++++++++++++++++++ src/log4net.sln | 16 +++- src/log4net/log4net.csproj | 1 - 6 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj create mode 100644 src/log4net.Ext.Mail/log4net.Ext.Mail.csproj 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..ceabeb65f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -14,6 +14,7 @@ en;en-US + 3.3.3 3.3.2 4.5.0 8.0.0 diff --git a/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj b/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj new file mode 100644 index 000000000..c7e3e071d --- /dev/null +++ b/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj @@ -0,0 +1,33 @@ + + + true + net8.0 + NETSDK1138;CS1701 + Library + bin\$(Configuration) + Debug;Release + AnyCPU + true + false + TRACE;DEBUG;$(DefineConstants) + + CS8032 + quackers + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj b/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj new file mode 100644 index 000000000..c6d81d48c --- /dev/null +++ b/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj @@ -0,0 +1,75 @@ + + + log4net.Ext.Mail + Apache log4net.Ext.Mail + $(Product) + log4net.Ext.Mail provides Appenders for sending mails + AnyCPU + netstandard2.0 + Debug;Release + Library + true + true + true + ..\..\build\$(Configuration) + ..\..\build\artifacts + ..\..\build\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + The Apache Software Foundation + false + true + Apache-2.0 + https://logging.apache.org/log4net/ + package-icon.png + logging log tracing mail smtp + Apache Logging Project + true + true + Apache log4net mail extensions + $(AssemblyName) + The Apache Software Foundation + Copyright %A9 2026 - $([System.DateTime]::Now.Year) The Apache Software Foundation + $(Copyright) + Apache and Apache log4net are trademarks of The Apache Software Foundation + $(AssemblyName) + + + README.md + false + false + + + Debug + TRACE;DEBUG;$(DefineConstants) + $(GeneratePackages) + + + Retail + TRACE;STRONG;$(DefineConstants) + true + $(MSBuildProjectDirectory)\=$(MSBuildProjectDirectory.Replace($(MSBuildThisFileDirectory),"D:\Git\apache\logging-log4net"))\ + + + + + + + + package-icon.png + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/log4net.sln b/src/log4net.sln index 3ecbc281a..f0c530615 100644 --- a/src/log4net.sln +++ b/src/log4net.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34622.214 +# Visual Studio Version 18 +VisualStudioVersion = 18.7.11903.348 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "log4net", "log4net\log4net.csproj", "{181FE707-E161-4722-9F38-6AAAB6FAA106}" EndProject @@ -56,6 +56,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".scripts", ".scripts", "{C0 ..\scripts\update-version.ps1 = ..\scripts\update-version.ps1 EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "log4net.Ext.Mail", "log4net.Ext.Mail\log4net.Ext.Mail.csproj", "{73AA7739-6E5E-6256-E2A2-6A3B79864E0B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "log4net.Ext.Mail.Tests", "log4net.Ext.Mail.Tests\log4net.Ext.Mail.Tests.csproj", "{56A9114E-30BC-0ED0-843E-97613E9E6221}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -94,6 +98,14 @@ Global {605058A8-CE6D-4190-91DA-7B3E9F84489E}.Debug|Any CPU.Build.0 = Debug|Any CPU {605058A8-CE6D-4190-91DA-7B3E9F84489E}.Release|Any CPU.ActiveCfg = Release|Any CPU {605058A8-CE6D-4190-91DA-7B3E9F84489E}.Release|Any CPU.Build.0 = Release|Any CPU + {73AA7739-6E5E-6256-E2A2-6A3B79864E0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73AA7739-6E5E-6256-E2A2-6A3B79864E0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73AA7739-6E5E-6256-E2A2-6A3B79864E0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73AA7739-6E5E-6256-E2A2-6A3B79864E0B}.Release|Any CPU.Build.0 = Release|Any CPU + {56A9114E-30BC-0ED0-843E-97613E9E6221}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56A9114E-30BC-0ED0-843E-97613E9E6221}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56A9114E-30BC-0ED0-843E-97613E9E6221}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56A9114E-30BC-0ED0-843E-97613E9E6221}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/log4net/log4net.csproj b/src/log4net/log4net.csproj index c15686078..3f78b5ff3 100644 --- a/src/log4net/log4net.csproj +++ b/src/log4net/log4net.csproj @@ -1,6 +1,5 @@ - 3.3.3 log4net Apache log4net $(Product) From f7c1649b129647e58fdca1284684a800b46f6ccd Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Tue, 28 Jul 2026 17:39:35 +0200 Subject: [PATCH 2/8] add MailKit based SmtpAppender to log4net.Ext.Mail log4net.Ext.Mail.Appender.SmtpAppender exposes the same options as log4net.Appender.SmtpAppender, but sends via MailKit instead of the obsolete System.Net.Mail.SmtpClient. Sending goes through the new ISmtpTransport abstraction (default: MailKitSmtpTransport) so the appender can be tested without an SMTP server. --- src/Directory.Build.props | 1 + .../Appender/ISmtpTransport.cs | 87 ++++ .../Appender/MailKitSmtpTransport.cs | 59 +++ src/log4net.Ext.Mail/Appender/SmtpAppender.cs | 482 ++++++++++++++++++ src/log4net.Ext.Mail/log4net.Ext.Mail.csproj | 18 +- src/log4net/Util/Log4NetAssert.cs | 29 +- 6 files changed, 671 insertions(+), 5 deletions(-) create mode 100644 src/log4net.Ext.Mail/Appender/ISmtpTransport.cs create mode 100644 src/log4net.Ext.Mail/Appender/MailKitSmtpTransport.cs create mode 100644 src/log4net.Ext.Mail/Appender/SmtpAppender.cs diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ceabeb65f..36f673f7f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -16,6 +16,7 @@ 3.3.3 3.3.2 + 4.17.0 4.5.0 8.0.0 diff --git a/src/log4net.Ext.Mail/Appender/ISmtpTransport.cs b/src/log4net.Ext.Mail/Appender/ISmtpTransport.cs new file mode 100644 index 000000000..6f93f4679 --- /dev/null +++ b/src/log4net.Ext.Mail/Appender/ISmtpTransport.cs @@ -0,0 +1,87 @@ +#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.Net; +using MailKit.Security; +using MimeKit; + +namespace log4net.Ext.Mail.Appender; + +/// +/// The slice of the MailKit SMTP client API used by . +/// +/// +/// +/// This interface exists so that can be unit tested without +/// talking to a real SMTP server. is the production +/// implementation and simply forwards to . +/// +/// +/// The members mirror their MailKit counterparts, so an implementation that wraps +/// needs no translation logic. +/// +/// +public interface ISmtpTransport : IDisposable +{ + /// + /// Gets a value indicating whether the transport is connected to a server. + /// + bool IsConnected { get; } + + /// + /// Gets a value indicating whether the transport has been authenticated. + /// + bool IsAuthenticated { get; } + + /// + /// Connects to the SMTP server at and . + /// + /// The name or address of the SMTP server. + /// The port the SMTP server is listening on. + /// The transport security to use. + void Connect(string host, int port, SecureSocketOptions secureSocketOptions); + + /// + /// Authenticates using the supplied and whichever + /// SASL mechanism the server and client agree on. + /// + /// The credentials to authenticate with. + void Authenticate(ICredentials credentials); + + /// + /// Authenticates using an explicit SASL . + /// + /// The SASL mechanism to authenticate with. + void Authenticate(SaslMechanism mechanism); + + /// + /// Sends the specified . + /// + /// The message to send. + void Send(MimeMessage message); + + /// + /// Disconnects from the SMTP server. + /// + /// + /// to send the QUIT command before disconnecting. + /// + void Disconnect(bool quit); +} diff --git a/src/log4net.Ext.Mail/Appender/MailKitSmtpTransport.cs b/src/log4net.Ext.Mail/Appender/MailKitSmtpTransport.cs new file mode 100644 index 000000000..e8aa5bdc7 --- /dev/null +++ b/src/log4net.Ext.Mail/Appender/MailKitSmtpTransport.cs @@ -0,0 +1,59 @@ +#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.Net; +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; + +namespace log4net.Ext.Mail.Appender; + +/// +/// The default implementation, backed by +/// . +/// +public sealed class MailKitSmtpTransport : ISmtpTransport +{ + private readonly SmtpClient _client = new(); + + /// + public bool IsConnected => _client.IsConnected; + + /// + public bool IsAuthenticated => _client.IsAuthenticated; + + /// + public void Connect(string host, int port, SecureSocketOptions secureSocketOptions) + => _client.Connect(host, port, secureSocketOptions); + + /// + public void Authenticate(ICredentials credentials) => _client.Authenticate(credentials); + + /// + public void Authenticate(SaslMechanism mechanism) => _client.Authenticate(mechanism); + + /// + public void Send(MimeMessage message) => _client.Send(message); + + /// + public void Disconnect(bool quit) => _client.Disconnect(quit); + + /// + public void Dispose() => _client.Dispose(); +} diff --git a/src/log4net.Ext.Mail/Appender/SmtpAppender.cs b/src/log4net.Ext.Mail/Appender/SmtpAppender.cs new file mode 100644 index 000000000..19f1afa13 --- /dev/null +++ b/src/log4net.Ext.Mail/Appender/SmtpAppender.cs @@ -0,0 +1,482 @@ +#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.IO; +using System.Net; +using System.Net.Mail; +using System.Text; + +using log4net.Appender; +using log4net.Core; +using log4net.Util; +using MailKit.Security; + +using MimeKit; +using MimeKit.Text; + +namespace log4net.Ext.Mail.Appender; + +/// +/// Send an e-mail when a specific logging event occurs, typically on errors +/// or fatal errors, using MailKit as the SMTP client. +/// +/// +/// +/// This appender exposes the same options as , +/// so an existing configuration can be pointed at this type without change. The difference +/// is the transport: sending is delegated to an , which by +/// default wraps instead of the obsolete +/// . +/// +/// +/// The number of logging events delivered in this e-mail depend on +/// the value of option. The +/// keeps only the last +/// logging events in its +/// cyclic buffer. This keeps memory requirements at a reasonable level while +/// still delivering useful application context. +/// +/// +/// Authentication is supported by setting the property to +/// either or . +/// If using authentication then the +/// and properties must also be set. +/// +/// +/// To set the SMTP server port use the property. The default port is 25. +/// +/// +/// Unlike , MailKit has no notion of a machine-wide +/// default SMTP server, so is required. +/// +/// +/// +/// Called once per e-mail to create the transport used to send it. Intended for tests and +/// for hosts that need to configure the MailKit client themselves. +/// +public class SmtpAppender(Func transportFactory) : BufferingAppenderSkeleton +{ + private readonly Func _transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory)); + + /// + /// Default constructor. Sends through . + /// + public SmtpAppender() + : this(static () => new MailKitSmtpTransport()) + { } + + /// + /// Gets or sets a comma-delimited list of recipient e-mail addresses. + /// + public string? To + { + get; + set => field = MaybeTrimSeparators(value); + } + + /// + /// Gets or sets a comma-delimited list of recipient e-mail addresses + /// that will be carbon copied. + /// + public string? Cc + { + get; + set => field = MaybeTrimSeparators(value); + } + + /// + /// Gets or sets a comma-delimited list of recipient e-mail addresses + /// that will be blind carbon copied. + /// + /// + /// A comma-delimited list of e-mail addresses. + /// + /// + /// + /// Semicolons are also accepted as separators, for backward compatibility. + /// + /// + public string? Bcc + { + get; + set => field = MaybeTrimSeparators(value); + } + + /// + /// Gets or sets the e-mail address of the sender. + /// + /// + /// The e-mail address of the sender. + /// + public string? From { get; set; } + + /// + /// Gets or sets the subject line of the e-mail message. + /// + /// + /// The subject line of the e-mail message. + /// + public string? Subject { get; set; } + + /// + /// Gets or sets the name of the SMTP relay mail server to use to send + /// the e-mail messages. + /// + /// + /// The name of the e-mail relay server. + /// + /// + /// + /// This option is required. MailKit, unlike , + /// has no machine-wide default SMTP server to fall back on. + /// + /// + public string? SmtpHost { get; set; } + + /// + /// The mode to use to authentication with the SMTP server + /// + /// + /// + /// Valid Authentication mode values are: , + /// , and . + /// The default value is . When using + /// you must specify the + /// and to use to authenticate. + /// + /// + /// authenticates with the NTLM SASL mechanism. + /// Note that MailKit cannot reuse the Windows logon session of the current thread or + /// process the way could, so + /// and must be supplied for NTLM as well. + /// + /// + public SmtpAuthentication Authentication { get; set; } = SmtpAuthentication.None; + + /// + /// The username to use to authenticate with the SMTP server + /// + /// + /// + /// A and must be specified when + /// is set to + /// or , otherwise the username will be ignored. + /// + /// + public string? Username { get; set; } + + /// + /// The password to use to authenticate with the SMTP server + /// + /// + /// + /// A and must be specified when + /// is set to + /// or , otherwise the password will be ignored. + /// + /// + public string? Password { get; set; } + + /// + /// The port on which the SMTP server is listening + /// + /// + /// + /// The port on which the SMTP server is listening. The default + /// port is 25. + /// + /// + public int Port { get; set; } = 25; + + /// + /// Gets or sets the priority of the e-mail message + /// + /// + /// One of the values. + /// + /// + /// + /// Sets the priority of the e-mails generated by this + /// appender. The default priority is . + /// + /// + /// If you are using this appender to report errors then + /// you may want to set the priority to . + /// + /// + /// The value is mapped onto the MIME Priority header: + /// becomes , + /// becomes and + /// becomes . + /// + /// + public MailPriority Priority { get; set; } = MailPriority.Normal; + + /// + /// Enable or disable use of SSL/TLS when sending e-mail message + /// + /// + /// + /// When , is used, which + /// negotiates implicit TLS or STARTTLS depending on the and + /// what the server advertises. When the connection is not + /// encrypted at all (), matching the behaviour of + /// . + /// + /// + /// Use directly via a custom + /// if you need finer control. + /// + /// + public bool EnableSsl { get; set; } + + /// + /// Gets or sets the reply-to e-mail address. + /// + public string? ReplyTo { get; set; } + + /// + /// Gets or sets the subject encoding to be used. + /// + /// + /// The default encoding is . + /// + public Encoding SubjectEncoding { get; set; } = Encoding.UTF8; + + /// + /// Gets or sets the body encoding to be used. + /// + /// + /// The default encoding is . + /// + public Encoding BodyEncoding { get; set; } = Encoding.UTF8; + + /// + /// Sends the contents of the cyclic buffer as an e-mail message. + /// + /// The logging events to send. + protected override void SendBuffer(LoggingEvent[] events) + { + events.EnsureNotNull(); + // Note: this code already owns the monitor for this + // appender. This frees us from needing to synchronize again. + try + { + using StringWriter writer = new(System.Globalization.CultureInfo.InvariantCulture); + + if (Layout?.Header is string header) + { + writer.Write(header); + } + + for (int i = 0; i < events.Length; i++) + { + // Render the event and append the text to the buffer + RenderLoggingEvent(writer, events[i]); + } + + if (Layout?.Footer is string footer) + { + writer.Write(footer); + } + + SendEmail(writer.ToString()); + } + catch (Exception e) when (!e.IsFatal()) + { + ErrorHandler.Error("Error occurred while sending e-mail notification.", e); + } + } + + /// + /// This appender requires a to be set. + /// + protected override bool RequiresLayout => true; + + /// + /// Send the email message + /// + /// the body text to include in the mail + protected virtual void SendEmail(string messageBody) + { + using MimeMessage message = CreateMessage(messageBody); + using ISmtpTransport transport = _transportFactory().EnsureNotNull(); + + transport.Connect( + SmtpHost.EnsureNotNullOrEmpty(), + Port, + EnableSsl ? SecureSocketOptions.Auto : SecureSocketOptions.None); + try + { + switch (Authentication) + { + case SmtpAuthentication.Basic: + transport.Authenticate(new NetworkCredential(Username, Password)); + break; + case SmtpAuthentication.Ntlm: + transport.Authenticate(new SaslMechanismNtlm(new NetworkCredential(Username, Password))); + break; + case SmtpAuthentication.None: + default: + break; + } + + transport.Send(message); + } + finally + { + transport.Disconnect(true); + } + } + + /// + /// Builds the for the given body text from the configured options. + /// + /// the body text to include in the mail + /// the message to send + protected virtual MimeMessage CreateMessage(string messageBody) + { + MimeMessage message = new(); + message.From.AddRange(ParseAddresses(From.EnsureNotNullOrEmpty())); + message.To.AddRange(ParseAddresses(To.EnsureNotNullOrEmpty())); + if (!string.IsNullOrEmpty(Cc)) + { + message.Cc.AddRange(ParseAddresses(Cc!)); + } + if (!string.IsNullOrEmpty(Bcc)) + { + message.Bcc.AddRange(ParseAddresses(Bcc!)); + } + if (!string.IsNullOrEmpty(ReplyTo)) + { + message.ReplyTo.AddRange(ParseAddresses(ReplyTo!)); + } + + if (Subject is not null) + { + // Set through the header collection so that the configured encoding is honoured. + message.Headers.Replace(HeaderId.Subject, SubjectEncoding, Subject); + } + + message.Priority = Priority switch + { + MailPriority.Low => MessagePriority.NonUrgent, + MailPriority.High => MessagePriority.Urgent, + _ => MessagePriority.Normal, + }; + + TextPart body = new(TextFormat.Plain); + body.SetText(BodyEncoding, messageBody); + message.Body = body; + + return message; + } + + /// + /// Values for the property. + /// + /// + /// + /// SMTP authentication modes. + /// + /// + public enum SmtpAuthentication + { + /// + /// No authentication + /// + None, + + /// + /// Basic authentication. + /// + /// + /// Requires a username and password to be supplied + /// + Basic, + + /// + /// NTLM authentication. + /// + /// + /// Requires a username and password to be supplied; MailKit cannot reuse the + /// Windows logon session of the current thread or process. + /// + Ntlm + } + + // Allow semicolon delimiters for backward compatibility. + private static readonly char[] _addressDelimiters = [',', ';']; + + /// + /// Trims leading and trailing commas or semicolons + /// + private static string? MaybeTrimSeparators(string? s) => s?.Trim(_addressDelimiters); + + /// + /// Parses a comma- or semicolon-delimited list of addresses. + /// + /// + /// RFC 5322 only allows commas, which is what + /// accepts, so semicolon-delimited lists are retried after normalization. + /// + private static InternetAddressList ParseAddresses(string addresses) + => InternetAddressList.TryParse(addresses, out InternetAddressList? list) + ? list + : InternetAddressList.Parse(ReplaceUnquotedSemicolons(addresses)); + + /// + /// Replaces every semicolon that is not inside a quoted string with a comma. + /// + private static string ReplaceUnquotedSemicolons(string addresses) + { + StringBuilder result = new(addresses.Length); + bool inQuotes = false; + bool escaped = false; + foreach (char character in addresses) + { + if (escaped) + { + escaped = false; + result.Append(character); + continue; + } + + switch (character) + { + case '\\' when inQuotes: + escaped = true; + result.Append(character); + break; + case '"': + inQuotes = !inQuotes; + result.Append(character); + break; + case ';' when !inQuotes: + result.Append(','); + break; + default: + result.Append(character); + break; + } + } + return result.ToString(); + } +} diff --git a/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj b/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj index c6d81d48c..55f47ff0d 100644 --- a/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj +++ b/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj @@ -1,4 +1,4 @@ - + log4net.Ext.Mail Apache log4net.Ext.Mail @@ -52,6 +52,14 @@ $(MSBuildProjectDirectory)\=$(MSBuildProjectDirectory.Replace($(MSBuildThisFileDirectory),"D:\Git\apache\logging-log4net"))\ + + + + + + + @@ -61,6 +69,10 @@ + + + + @@ -68,8 +80,8 @@ - - + + \ No newline at end of file diff --git a/src/log4net/Util/Log4NetAssert.cs b/src/log4net/Util/Log4NetAssert.cs index 28ea8894c..88eef2cb9 100644 --- a/src/log4net/Util/Log4NetAssert.cs +++ b/src/log4net/Util/Log4NetAssert.cs @@ -42,7 +42,7 @@ private static ArgumentNullException ArgumentNull(string name, string? errorMess /// Value (when not null) /// public static T EnsureNotNull([NotNull][ValidatedNotNull] this T? value, - [CallerArgumentExpression("value")] string name = "", + [CallerArgumentExpression(nameof(value))] string name = "", string? errorMessage = null) where T : class { @@ -54,6 +54,31 @@ public static T EnsureNotNull([NotNull][ValidatedNotNull] this T? value, return value; } + /// + /// Ensures that is not or empty and returns the validated value + /// + /// Value to validate + /// Name of the value + /// Error message (optional) + /// Value (when not null) + /// + public static string EnsureNotNullOrEmpty( + [NotNull][ValidatedNotNull] this string? value, + [CallerArgumentExpression(nameof(value))] string name = "", + string? errorMessage = null) + { + // Not string.IsNullOrEmpty: its [NotNullWhen(false)] annotation is absent from the + // netstandard2.0 reference assembly, so it would not satisfy [NotNull]. + if (value is string { Length: > 0}) + { + return value; + } + throw ArgumentNull(name, NotNullOrEmptyMessage(errorMessage, name)); + } + + private static string NotNullOrEmptyMessage(string? existingMessage, string name) + => existingMessage ?? string.Format("'{0}' cannot be null or empty.", name); + /// /// Ensures that is not null and an instance of /// and returns the validated value @@ -67,7 +92,7 @@ public static T EnsureNotNull([NotNull][ValidatedNotNull] this T? value, /// public static T EnsureIs( [NotNull][ValidatedNotNull] this object? value, - [CallerArgumentExpression("value")] string name = "", + [CallerArgumentExpression(nameof(value))] string name = "", string? errorMessage = null) { if (value is T result) From 359c548615022077e762ef2313db77b1eaf0feaf Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Tue, 28 Jul 2026 17:44:51 +0200 Subject: [PATCH 3/8] add tests for the MailKit based SmtpAppender --- src/log4net.Ext.Mail.Tests/.editorconfig | 2 + .../Appender/FakeSmtpTransport.cs | 172 ++++++ .../Appender/SmtpAppenderTest.cs | 546 ++++++++++++++++++ .../log4net.Ext.Mail.Tests.csproj | 2 - 4 files changed, 720 insertions(+), 2 deletions(-) create mode 100644 src/log4net.Ext.Mail.Tests/.editorconfig create mode 100644 src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs create mode 100644 src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs 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..c29e38bab --- /dev/null +++ b/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs @@ -0,0 +1,172 @@ +#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 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..0ce51952b --- /dev/null +++ b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs @@ -0,0 +1,546 @@ +#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.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 SmtpAppender(() => _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: "<
>"); + + Append(appender, "the event"); + + string body = _transport.SentMails[0].Body; + Assert.That(body, Does.StartWith("<
>")); + Assert.That(body, Does.Contain("the event")); + Assert.That(body, Does.EndWith("<
>")); + } + + [Test] + public void BodyUsesTheConfiguredEncoding() + { + SmtpAppender appender = CreateAppender(); + appender.BodyEncoding = Encoding.UTF8; + + Append(appender, "weißes Schönwetter"); + + SentMail mail = _transport.SentMails[0]; + Assert.That(mail.BodyCharset, Is.EqualTo("utf-8")); + Assert.That(mail.Body, Does.Contain("weißes Schönwetter")); + } + + [Test] + public void SubjectIsSet() + { + SmtpAppender appender = CreateAppender(); + appender.Subject = "error in production"; + + Append(appender); + + Assert.That(_transport.SentMails[0].Subject, Is.EqualTo("error in production")); + } + + [Test] + public void SubjectUsesTheConfiguredEncoding() + { + SmtpAppender appender = CreateAppender(); + appender.Subject = "Störung im Betrieb"; + appender.SubjectEncoding = Encoding.UTF8; + + Append(appender); + + SentMail mail = _transport.SentMails[0]; + Assert.That(mail.Subject, Is.EqualTo("Störung im Betrieb")); + // A non-ASCII subject must be transfer-encoded, naming the configured charset. + Assert.That(mail.RawSubjectHeader.ToLowerInvariant(), Does.Contain("utf-8")); + } + + [Test] + public void FromAndToAreSet() + { + SmtpAppender appender = CreateAppender(); + appender.From = "logger@example.com"; + appender.To = "ops@example.com"; + + Append(appender); + + SentMail mail = _transport.SentMails[0]; + Assert.That(mail.From, Is.EqualTo(new[] { "logger@example.com" })); + Assert.That(mail.To, Is.EqualTo(new[] { "ops@example.com" })); + } + + [Test] + public void CcAndBccAreOmittedWhenNotConfigured() + { + SmtpAppender appender = CreateAppender(); + + Append(appender); + + SentMail mail = _transport.SentMails[0]; + Assert.That(mail.Cc, Is.Empty); + Assert.That(mail.Bcc, Is.Empty); + Assert.That(mail.ReplyTo, Is.Empty); + } + + [Test] + public void ReplyToIsSet() + { + SmtpAppender appender = CreateAppender(); + appender.ReplyTo = "noreply@example.com"; + + Append(appender); + + Assert.That(_transport.SentMails[0].ReplyTo, Is.EqualTo(new[] { "noreply@example.com" })); + } + + [Test] + public void CommaDelimitedRecipientsAreAllAdded() + { + SmtpAppender appender = CreateAppender(); + appender.To = "a@example.com,b@example.com"; + appender.Cc = "c@example.com, d@example.com"; + appender.Bcc = "e@example.com,f@example.com"; + + Append(appender); + + SentMail mail = _transport.SentMails[0]; + Assert.That(mail.To, Is.EqualTo(new[] { "a@example.com", "b@example.com" })); + Assert.That(mail.Cc, Is.EqualTo(new[] { "c@example.com", "d@example.com" })); + Assert.That(mail.Bcc, Is.EqualTo(new[] { "e@example.com", "f@example.com" })); + } + + [Test] + public void SemicolonDelimitedRecipientsAreAllAdded() + { + SmtpAppender appender = CreateAppender(); + appender.To = "a@example.com;b@example.com"; + appender.Bcc = "e@example.com; f@example.com"; + + Append(appender); + + SentMail mail = _transport.SentMails[0]; + Assert.That(mail.To, Is.EqualTo(new[] { "a@example.com", "b@example.com" })); + Assert.That(mail.Bcc, Is.EqualTo(new[] { "e@example.com", "f@example.com" })); + } + + [Test] + public void DisplayNamesContainingCommasSurviveParsing() + { + SmtpAppender appender = CreateAppender(); + appender.To = "\"Doe, John\" , jane@example.com"; + + Append(appender); + + Assert.That(_transport.SentMails[0].To, Is.EqualTo(new[] { "john@example.com", "jane@example.com" })); + } + + [Test] + public void LeadingAndTrailingSeparatorsAreTrimmedFromRecipients() + { + SmtpAppender appender = CreateAppender(); + appender.To = ",to@example.com;"; + appender.Cc = ";cc@example.com,"; + appender.Bcc = ",bcc@example.com,"; + + Assert.That(appender.To, Is.EqualTo("to@example.com")); + Assert.That(appender.Cc, Is.EqualTo("cc@example.com")); + Assert.That(appender.Bcc, Is.EqualTo("bcc@example.com")); + } + + [TestCase(MailPriority.Low, MessagePriority.NonUrgent)] + [TestCase(MailPriority.Normal, MessagePriority.Normal)] + [TestCase(MailPriority.High, MessagePriority.Urgent)] + public void PriorityIsMappedOntoTheMimePriorityHeader(MailPriority configured, MessagePriority expected) + { + SmtpAppender appender = CreateAppender(); + appender.Priority = configured; + + Append(appender); + + Assert.That(_transport.SentMails[0].Priority, Is.EqualTo(expected)); + } + + [Test] + public void DefaultPriorityIsNormal() + { + SmtpAppender appender = CreateAppender(); + + Assert.That(appender.Priority, Is.EqualTo(MailPriority.Normal)); + } + + [Test] + public void TransportIsDisconnectedAndDisposedAfterSending() + { + SmtpAppender appender = CreateAppender(); + + Append(appender); + + Assert.That(_transport.DisconnectedWithQuit, Is.True); + Assert.That(_transport.IsConnected, Is.False); + Assert.That(_transport.IsDisposed, Is.True); + } + + [Test] + public void TransportIsDisconnectedAndDisposedWhenSendingFails() + { + SmtpAppender appender = CreateAppender(); + _transport.SendException = new InvalidOperationException("relay refused"); + + Append(appender); + + Assert.That(_transport.Calls, Does.Contain("Disconnect")); + Assert.That(_transport.IsDisposed, Is.True); + } + + [Test] + public void SendFailureIsReportedToTheErrorHandlerAndNotThrown() + { + SmtpAppender appender = CreateAppender(); + _transport.SendException = new InvalidOperationException("relay refused"); + + Assert.DoesNotThrow(() => Append(appender)); + + Assert.That(_errorHandler.Message, Does.Contain("Error occurred while sending e-mail notification.")); + Assert.That(_errorHandler.Message, Does.Contain("relay refused")); + } + + [TestCase(nameof(SmtpAppender.SmtpHost))] + [TestCase(nameof(SmtpAppender.From))] + [TestCase(nameof(SmtpAppender.To))] + public void MissingRequiredOptionIsReportedToTheErrorHandler(string option) + { + SmtpAppender appender = CreateAppender(); + switch (option) + { + case nameof(SmtpAppender.SmtpHost): + appender.SmtpHost = null; + break; + case nameof(SmtpAppender.From): + appender.From = null; + break; + default: + appender.To = null; + break; + } + + Assert.DoesNotThrow(() => Append(appender)); + + Assert.That(_transport.SentMails, Is.Empty); + Assert.That(_errorHandler.Message, Does.Contain(option)); + } + + [Test] + public void EachSendUsesAFreshTransport() + { + List transports = []; + PatternLayout layout = new() { ConversionPattern = "%m%n" }; + layout.ActivateOptions(); + SmtpAppender appender = new(() => + { + FakeSmtpTransport transport = new(); + transports.Add(transport); + return transport; + }) + { + Layout = layout, + ErrorHandler = _errorHandler, + SmtpHost = "mail.example.com", + From = "from@example.com", + To = "to@example.com", + BufferSize = 1, + }; + appender.ActivateOptions(); + + appender.DoAppend(CreateEvent("first")); + appender.DoAppend(CreateEvent("second")); + + Assert.That(transports, Has.Count.EqualTo(2)); + Assert.That(transports[0].SentMails[0].Body, Does.Contain("first")); + Assert.That(transports[1].SentMails[0].Body, Does.Contain("second")); + } + + [Test] + public void BufferedEventsAreSentInASingleMail() + { + SmtpAppender appender = CreateAppender(); + appender.BufferSize = 10; + appender.ActivateOptions(); + + appender.DoAppend(CreateEvent("one")); + appender.DoAppend(CreateEvent("two")); + appender.DoAppend(CreateEvent("three")); + Assert.That(_transport.SentMails, Is.Empty, "the buffer is not full yet"); + + appender.Flush(true); + + Assert.That(_transport.SentMails, Has.Count.EqualTo(1)); + string body = _transport.SentMails[0].Body; + Assert.That(body, Does.Contain("one")); + Assert.That(body, Does.Contain("two")); + Assert.That(body, Does.Contain("three")); + } + + [Test] + public void RequiresALayout() + { + PatternLayout layout = new() { ConversionPattern = "%m%n" }; + layout.ActivateOptions(); + SmtpAppender appender = new(() => _transport) + { + ErrorHandler = _errorHandler, + SmtpHost = "mail.example.com", + From = "from@example.com", + To = "to@example.com", + BufferSize = 1, + }; + appender.ActivateOptions(); + + appender.DoAppend(CreateEvent("no layout")); + + Assert.That(_transport.SentMails, Is.Empty); + Assert.That(_errorHandler.Message, Is.Not.Empty); + } + + [Test] + public void NullTransportFactoryIsRejected() + => Assert.Throws(() => new SmtpAppender(null!)); + + [Test] + public void DefaultConstructorUsesTheMailKitTransport() + { + // Nothing is sent here; this only pins down that the log4net-configurable + // parameterless constructor exists and produces a usable appender. + SmtpAppender appender = new(); + + Assert.That(appender.Port, Is.EqualTo(25)); + Assert.That(appender.Authentication, Is.EqualTo(SmtpAppender.SmtpAuthentication.None)); + Assert.That(appender.SubjectEncoding, Is.EqualTo(Encoding.UTF8)); + Assert.That(appender.BodyEncoding, Is.EqualTo(Encoding.UTF8)); + Assert.That(appender.EnableSsl, Is.False); + } +} diff --git a/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj b/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj index c7e3e071d..0add8141d 100644 --- a/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj +++ b/src/log4net.Ext.Mail.Tests/log4net.Ext.Mail.Tests.csproj @@ -26,8 +26,6 @@ - - \ No newline at end of file From 61bf6ff939b7eb9ca3f9a5eb826d86943baa79dd Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Tue, 28 Jul 2026 21:56:19 +0200 Subject: [PATCH 4/8] - TelnetAppender: register client before sending welcome banner (log messages could be dropped via HasConnections race) - test: free TCP port, locked receive buffer, assert on stream content instead of read count, 30s timeout, log client errors --- .../Appender/Internal/SimpleTelnetClient.cs | 54 ++++++---- .../Appender/TelnetAppenderTest.cs | 98 ++++++++++++++----- src/log4net/Appender/TelnetAppender.cs | 6 +- 3 files changed, 114 insertions(+), 44 deletions(-) diff --git a/src/log4net.Tests/Appender/Internal/SimpleTelnetClient.cs b/src/log4net.Tests/Appender/Internal/SimpleTelnetClient.cs index 72767bf7a..38f104ff7 100644 --- a/src/log4net.Tests/Appender/Internal/SimpleTelnetClient.cs +++ b/src/log4net.Tests/Appender/Internal/SimpleTelnetClient.cs @@ -20,6 +20,7 @@ */ using System; +using System.IO; using System.Net; using System.Net.Sockets; using System.Threading; @@ -43,27 +44,46 @@ internal sealed class SimpleTelnetClient( ///
internal void Run(Action log) => Task.Run(() => { - log("client: starting ..."); - _client.Connect(new IPEndPoint(IPAddress.Loopback, port)); - log("client: connected"); - // Get a stream object for reading and writing - using NetworkStream stream = _client.GetStream(); - log("client: has stream"); + try + { + log("client: starting ..."); + _client.Connect(new IPEndPoint(IPAddress.Loopback, port)); + log("client: connected"); + // Get a stream object for reading and writing + using NetworkStream stream = _client.GetStream(); + log("client: has stream"); - int i; - byte[] bytes = new byte[256]; + int i; + byte[] bytes = new byte[256]; - // Loop to receive all the data sent by the server - while ((i = stream.Read(bytes, 0, bytes.Length)) != 0) - { - string data = System.Text.Encoding.ASCII.GetString(bytes, 0, i); - log("client: read: " + data); - received(data); - if (_cancellationTokenSource.Token.IsCancellationRequested) + // Loop to receive all the data sent by the server + while ((i = stream.Read(bytes, 0, bytes.Length)) != 0) { - log("client: canceled"); - return; + string data = System.Text.Encoding.ASCII.GetString(bytes, 0, i); + log("client: read: " + data); + received(data); + if (_cancellationTokenSource.Token.IsCancellationRequested) + { + log("client: canceled"); + return; + } } + log("client: end of stream"); + } + // The test asserts on the received data, so a failing client must not end up + // as an unobserved task exception - log it instead. + catch (SocketException e) + { + log("client: error: " + e); + } + catch (IOException e) + { + log("client: error: " + e); + } + catch (ObjectDisposedException e) + { + // expected when the client is disposed while reading + log("client: disposed: " + e.Message); } }, _cancellationTokenSource.Token); diff --git a/src/log4net.Tests/Appender/TelnetAppenderTest.cs b/src/log4net.Tests/Appender/TelnetAppenderTest.cs index 71b3b0935..9596f065f 100644 --- a/src/log4net.Tests/Appender/TelnetAppenderTest.cs +++ b/src/log4net.Tests/Appender/TelnetAppenderTest.cs @@ -18,7 +18,10 @@ #endregion using System; -using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; using System.Threading; using System.Xml; using log4net.Appender; @@ -43,13 +46,24 @@ public sealed class TelnetAppenderTest /// https://github.com/apache/logging-log4net/issues/194 /// https://stackoverflow.com/questions/79053363/log4net-telnetappender-doesnt-work-after-migrate-to-log4net-3 /// + /// + /// Maximum time to wait for a message to arrive at the client. + /// + private static readonly TimeSpan _receiveTimeout = TimeSpan.FromSeconds(30); + + private const string WelcomeMessage = "TelnetAppender"; + [Test] public void TelnetTest() { - List received = []; + // The received data is a TCP byte stream - the server writes are not necessarily + // mapped 1:1 to the client reads, so the test asserts on the accumulated text + // instead of counting the individual reads. + StringBuilder received = new(); + object receivedSyncRoot = new(); XmlDocument log4NetConfig = new(); - int port = 9090; + int port = FindFreeTcpPort(); log4NetConfig.LoadXml( $""" @@ -68,38 +82,70 @@ public void TelnetTest() string logId = Guid.NewGuid().ToString(); ILoggerRepository repository = LogManager.CreateRepository(logId); XmlConfigurator.Configure(repository, log4NetConfig["log4net"]!); - using (SimpleTelnetClient telnetClient = new(Received, port)) + try + { + using (SimpleTelnetClient telnetClient = new(Received, port)) + { + TestContext.Out.WriteLine("test: starting client ..."); + telnetClient.Run(TestContext.Out.WriteLine); + WaitForReceived("welcome message", WelcomeMessage); + ILogger logger = repository.GetLogger("Telnet"); + TestContext.Out.WriteLine("test: logging to client ..."); + logger.Log(typeof(TelnetAppenderTest), Level.Info, logId, null); + TestContext.Out.WriteLine("test: waiting for message of client ..."); + WaitForReceived("log message", logId); + TestContext.Out.WriteLine("test: canceling client ..."); + } + } + finally + { + repository.Shutdown(); + } + Assert.That(ReceivedText(), Does.StartWith(WelcomeMessage).And.Contain(logId)); + + void Received(string message) { - TestContext.Out.WriteLine("test: starting client ..."); - telnetClient.Run(TestContext.Out.WriteLine); - WaitForReceived(1); // wait for welcome message - ILogger logger = repository.GetLogger("Telnet"); - TestContext.Out.WriteLine("test: logging to client ..."); - logger.Log(typeof(TelnetAppenderTest), Level.Info, logId, null); - TestContext.Out.WriteLine("test: waiting for message of client ..."); - WaitForReceived(2); // wait for log message - TestContext.Out.WriteLine("test: canceling client ..."); + lock (receivedSyncRoot) + { + received.Append(message); + } } - repository.Shutdown(); - Assert.That(received, Has.Count.EqualTo(2)); - Assert.That(received[1], Does.Contain(logId)); - void Received(string message) => received.Add(message); + string ReceivedText() + { + lock (receivedSyncRoot) + { + return received.ToString(); + } + } - void WaitForReceived(int count) + void WaitForReceived(string what, string expected) { - int retries = 1; - while (received.Count < count) + Stopwatch stopwatch = Stopwatch.StartNew(); + while (ReceivedText().IndexOf(expected, StringComparison.Ordinal) < 0) { - retries++; - TestContext.Out.WriteLine($"receiver: waiting for message {count} of client - retry {retries} failed"); - if (retries > 500) + if (stopwatch.Elapsed > _receiveTimeout) { - Assert.Fail("Timeout waiting for received messages"); + Assert.Fail($"Timeout waiting for {what} - received so far: '{ReceivedText()}'"); } - Thread.Sleep(10); + Thread.Sleep(20); } - TestContext.Out.WriteLine($"receiver: waiting for message {count} of client - retry {retries} succeeded"); + TestContext.Out.WriteLine($"receiver: received {what} after {stopwatch.ElapsedMilliseconds} ms"); + } + } + + /// + /// Asks the OS for a currently unused TCP port - a fixed port would collide with + /// other tests or processes on the build machine. + /// + private static int FindFreeTcpPort() + { + using Socket socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(IPAddress.Any, 0)); + if (socket.LocalEndPoint is IPEndPoint endPoint) + { + return endPoint.Port; } + throw new InvalidOperationException("Could not determine a free TCP port"); } } \ No newline at end of file diff --git a/src/log4net/Appender/TelnetAppender.cs b/src/log4net/Appender/TelnetAppender.cs index ca4cff4ca..adb4cdae9 100644 --- a/src/log4net/Appender/TelnetAppender.cs +++ b/src/log4net/Appender/TelnetAppender.cs @@ -338,13 +338,17 @@ private void OnConnect(IAsyncResult asyncResult) int currentActiveConnectionsCount = _clients.Count; if (currentActiveConnectionsCount < MaxConnections) { + // Register the client before sending the welcome message, otherwise a client + // that logs as soon as it has received the welcome message can race with + // AddClient and see HasConnections == false. + AddClient(client); try { client.Send($"TelnetAppender v1.0 ({currentActiveConnectionsCount + 1} active connections)\r\n\r\n"); - AddClient(client); } catch (Exception e) when (!e.IsFatal()) { + RemoveClient(client); client.Dispose(); } } From a4bcb329dc6806b416f1b93e979b016768d5ae80 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Tue, 28 Jul 2026 22:21:43 +0200 Subject: [PATCH 5/8] Move ISmtpTransport and MailKitSmtpTransport to Appender/Internal --- src/log4net.Ext.Mail/Appender/{ => Internal}/ISmtpTransport.cs | 0 .../Appender/{ => Internal}/MailKitSmtpTransport.cs | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/log4net.Ext.Mail/Appender/{ => Internal}/ISmtpTransport.cs (100%) rename src/log4net.Ext.Mail/Appender/{ => Internal}/MailKitSmtpTransport.cs (100%) diff --git a/src/log4net.Ext.Mail/Appender/ISmtpTransport.cs b/src/log4net.Ext.Mail/Appender/Internal/ISmtpTransport.cs similarity index 100% rename from src/log4net.Ext.Mail/Appender/ISmtpTransport.cs rename to src/log4net.Ext.Mail/Appender/Internal/ISmtpTransport.cs diff --git a/src/log4net.Ext.Mail/Appender/MailKitSmtpTransport.cs b/src/log4net.Ext.Mail/Appender/Internal/MailKitSmtpTransport.cs similarity index 100% rename from src/log4net.Ext.Mail/Appender/MailKitSmtpTransport.cs rename to src/log4net.Ext.Mail/Appender/Internal/MailKitSmtpTransport.cs From 0bf9049532c8b1fe5a565e5c1044238ae590b497 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Tue, 28 Jul 2026 22:23:28 +0200 Subject: [PATCH 6/8] Make the MailKit transport internal --- .../Appender/FakeSmtpTransport.cs | 1 + .../Appender/SmtpAppenderTest.cs | 1 + .../Appender/Internal/ISmtpTransport.cs | 4 ++-- .../Appender/Internal/MailKitSmtpTransport.cs | 4 ++-- src/log4net.Ext.Mail/Appender/SmtpAppender.cs | 23 +++++++++++-------- src/log4net.Ext.Mail/log4net.Ext.Mail.csproj | 3 +++ 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs b/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs index c29e38bab..7b268952b 100644 --- a/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs +++ b/src/log4net.Ext.Mail.Tests/Appender/FakeSmtpTransport.cs @@ -24,6 +24,7 @@ using System.Text; using log4net.Ext.Mail.Appender; +using log4net.Ext.Mail.Appender.Internal; using MailKit.Security; diff --git a/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs index 0ce51952b..e6810f52e 100644 --- a/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs +++ b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs @@ -24,6 +24,7 @@ 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; diff --git a/src/log4net.Ext.Mail/Appender/Internal/ISmtpTransport.cs b/src/log4net.Ext.Mail/Appender/Internal/ISmtpTransport.cs index 6f93f4679..65ef28c68 100644 --- a/src/log4net.Ext.Mail/Appender/Internal/ISmtpTransport.cs +++ b/src/log4net.Ext.Mail/Appender/Internal/ISmtpTransport.cs @@ -22,7 +22,7 @@ using MailKit.Security; using MimeKit; -namespace log4net.Ext.Mail.Appender; +namespace log4net.Ext.Mail.Appender.Internal; /// /// The slice of the MailKit SMTP client API used by . @@ -38,7 +38,7 @@ namespace log4net.Ext.Mail.Appender; /// needs no translation logic. /// /// -public interface ISmtpTransport : IDisposable +internal interface ISmtpTransport : IDisposable { /// /// Gets a value indicating whether the transport is connected to a server. diff --git a/src/log4net.Ext.Mail/Appender/Internal/MailKitSmtpTransport.cs b/src/log4net.Ext.Mail/Appender/Internal/MailKitSmtpTransport.cs index e8aa5bdc7..697682429 100644 --- a/src/log4net.Ext.Mail/Appender/Internal/MailKitSmtpTransport.cs +++ b/src/log4net.Ext.Mail/Appender/Internal/MailKitSmtpTransport.cs @@ -22,13 +22,13 @@ using MailKit.Security; using MimeKit; -namespace log4net.Ext.Mail.Appender; +namespace log4net.Ext.Mail.Appender.Internal; /// /// The default implementation, backed by /// . /// -public sealed class MailKitSmtpTransport : ISmtpTransport +internal sealed class MailKitSmtpTransport : ISmtpTransport { private readonly SmtpClient _client = new(); diff --git a/src/log4net.Ext.Mail/Appender/SmtpAppender.cs b/src/log4net.Ext.Mail/Appender/SmtpAppender.cs index 19f1afa13..53f07aa4f 100644 --- a/src/log4net.Ext.Mail/Appender/SmtpAppender.cs +++ b/src/log4net.Ext.Mail/Appender/SmtpAppender.cs @@ -25,6 +25,7 @@ using log4net.Appender; using log4net.Core; +using log4net.Ext.Mail.Appender.Internal; using log4net.Util; using MailKit.Security; @@ -41,9 +42,8 @@ namespace log4net.Ext.Mail.Appender; /// /// This appender exposes the same options as , /// so an existing configuration can be pointed at this type without change. The difference -/// is the transport: sending is delegated to an , which by -/// default wraps instead of the obsolete -/// . +/// is the transport: it sends through instead of +/// the obsolete . /// /// /// The number of logging events delivered in this e-mail depend on @@ -67,13 +67,9 @@ namespace log4net.Ext.Mail.Appender; /// default SMTP server, so is required. /// /// -/// -/// Called once per e-mail to create the transport used to send it. Intended for tests and -/// for hosts that need to configure the MailKit client themselves. -/// -public class SmtpAppender(Func transportFactory) : BufferingAppenderSkeleton +public class SmtpAppender : BufferingAppenderSkeleton { - private readonly Func _transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory)); + private readonly Func _transportFactory; /// /// Default constructor. Sends through . @@ -82,6 +78,15 @@ public SmtpAppender() : this(static () => new MailKitSmtpTransport()) { } + /// + /// Creates an appender that obtains its transport from . + /// + /// + /// Called once per e-mail to create the transport used to send it. + /// + internal SmtpAppender(Func transportFactory) + => _transportFactory = transportFactory.EnsureNotNull(); + /// /// Gets or sets a comma-delimited list of recipient e-mail addresses. /// diff --git a/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj b/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj index 55f47ff0d..30344847a 100644 --- a/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj +++ b/src/log4net.Ext.Mail/log4net.Ext.Mail.csproj @@ -52,6 +52,9 @@ $(MSBuildProjectDirectory)\=$(MSBuildProjectDirectory.Replace($(MSBuildThisFileDirectory),"D:\Git\apache\logging-log4net"))\ + + + From e7d9ff212e15909cbd77edf4740c6640a842a7a5 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Tue, 28 Jul 2026 22:26:29 +0200 Subject: [PATCH 7/8] document the MailKit based SmtpAppender Recommend the MailKit appender from log4net.Ext.Mail over the built-in one, which relies on the deprecated System.Net.Mail.SmtpClient, and port all configuration samples to the new type. Adds the changelog entry for #300. --- .../300-add-mailkit-based-smtpappender.xml | 11 + .../antora/modules/ROOT/pages/features.adoc | 1 + .../pages/manual/configuration/appenders.adoc | 1 + .../configuration/appenders/smtpappender.adoc | 188 +++++++++++++++++- .../appenders/smtppickupdirappender.adoc | 3 + .../pages/manual/supported-frameworks.adoc | 4 + 6 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 src/changelog/3.3.3/300-add-mailkit-based-smtpappender.xml 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/site/antora/modules/ROOT/pages/features.adoc b/src/site/antora/modules/ROOT/pages/features.adoc index b268c3d85..e5eb01413 100644 --- a/src/site/antora/modules/ROOT/pages/features.adoc +++ b/src/site/antora/modules/ROOT/pages/features.adoc @@ -103,6 +103,7 @@ The RollingFileAppender can be configured to log to multiple files based upon da |xref:manual/configuration/appenders/smtpappender.adoc[] |Sends logging events to an email address. +The MailKit based appender from the `log4net.Ext.Mail` package is recommended; the built-in variant relies on the deprecated `System.Net.Mail.SmtpClient`. |xref:manual/configuration/appenders/smtppickupdirappender.adoc[] |Sends logging events to an email address but writes the emails to a configurable directory rather than sending them directly via SMTP. diff --git a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders.adoc b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders.adoc index 6bfa99838..e2e0c18a2 100644 --- a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders.adoc @@ -92,6 +92,7 @@ The RollingFileAppender can be configured to log to multiple files based upon da |xref:manual/configuration/appenders/smtpappender.adoc[] |Sends logging events to an email address. +The MailKit based appender from the `log4net.Ext.Mail` package is recommended; the built-in variant relies on the deprecated `System.Net.Mail.SmtpClient`. |xref:manual/configuration/appenders/smtppickupdirappender.adoc[] |Sends logging events to an email address but writes the emails to a configurable directory rather than sending them directly via SMTP. diff --git a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/smtpappender.adoc b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/smtpappender.adoc index aa1a35e9d..25bb9373f 100644 --- a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/smtpappender.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/smtpappender.adoc @@ -21,6 +21,50 @@ The `SmtpAppender` sends log events via email using the Simple Mail Transfer Protocol (SMTP). This appender is useful for sending email notifications when certain events occur, such as errors or critical failures in your application. +There are two implementations to choose from: + +[cols="Type,Assembly,SMTP client"] +|=== +|Type |Assembly |SMTP client + +|`log4net.Ext.Mail.Appender.SmtpAppender` (recommended) +|`log4net.Ext.Mail` +|https://github.com/jstedfast/MailKit[MailKit] + +|`log4net.Appender.SmtpAppender` (legacy) +|`log4net` +|`System.Net.Mail.SmtpClient` +|=== + +[IMPORTANT] +==== +The built-in `log4net.Appender.SmtpAppender` is *no longer recommended*. + +It sends mail through https://learn.microsoft.com/en-us/dotnet/api/system.net.mail.smtpclient#remarks[System.Net.Mail.SmtpClient], which Microsoft no longer recommends for new development because it does not support many modern protocols. +In practice this shows up as connection failures against modern mail servers, most often around SSL/TLS negotiation. + +Use the xref:#mailkit-smtpappender[MailKit based `SmtpAppender`] instead. +It offers the same configuration options, so migrating usually means changing nothing but the `type` attribute. +==== + +[#mailkit-smtpappender] +== MailKit based SmtpAppender (recommended) + +This appender lives in the separate `log4net.Ext.Mail` package, so that log4net itself does not take on a mail dependency. +Add it to your project: + +[source,shell] +---- +dotnet add package log4net.Ext.Mail +---- + +Because the appender is not part of the `log4net` assembly, the `type` attribute must be assembly-qualified: + +[source,xml] +---- +type="log4net.Ext.Mail.Appender.SmtpAppender, log4net.Ext.Mail" +---- + The following example shows how to configure the `SmtpAppender` to deliver log events via SMTP email. The `To`, `From`, `Subject` and `SmtpHost` are required parameters. This example shows how to deliver only significant events. @@ -33,7 +77,7 @@ Messages not sent will be discarded. [source,xml] ---- - + @@ -53,7 +97,7 @@ This example shows how to configure the `SmtpAppender` to deliver all messages i [source,xml] ---- - + @@ -70,7 +114,7 @@ This example shows a more verbose formatting layout for the mail messages. [source,xml] ---- - + @@ -84,4 +128,140 @@ This example shows a more verbose formatting layout for the mail messages. ----- \ No newline at end of file +---- + +This example authenticates against a mail server that requires an encrypted connection on the submission port. + +[source,xml] +---- + + + + + + + + + + + + + + + + + + + + + + +---- + +[#mailkit-smtpappender-options] +=== Options + +[cols="Option,Description"] +|=== +|Option |Description + +|`to` +|*Required.* Comma-delimited list of recipient email addresses. + +|`from` +|*Required.* The sender's email address. + +|`subject` +|*Required.* The subject line of the email. + +|`smtpHost` +|*Required.* The name or address of the SMTP relay server. + +|`cc` +|Comma-delimited list of addresses to carbon copy. + +|`bcc` +|Comma-delimited list of addresses to blind carbon copy. + +|`replyTo` +|The reply-to address. + +|`port` +|The port the SMTP server listens on. Defaults to `25`. + +|`enableSsl` +|Whether to secure the connection. Defaults to `false`. See xref:#mailkit-smtpappender-differences[]. + +|`authentication` +|One of `None` (default), `Basic` or `Ntlm`. `Basic` and `Ntlm` both require `username` and `password`. + +|`username` +|The user name to authenticate with. Ignored when `authentication` is `None`. + +|`password` +|The password to authenticate with. Ignored when `authentication` is `None`. + +|`priority` +|One of `Low`, `Normal` (default) or `High`, mapped onto the MIME `Priority` header. + +|`subjectEncoding` +|The encoding of the subject line, for example `utf-8`. Defaults to `utf-8`. + +|`bodyEncoding` +|The encoding of the message body, for example `utf-8`. Defaults to `utf-8`. + +|`bufferSize` +|How many log events to buffer into one email. See xref:manual/configuration/appenders.adoc[]. + +|`lossy` +|Whether buffered events may be discarded rather than sent. + +|`evaluator` +|An `ITriggeringEventEvaluator` deciding which events trigger a send. +|=== + +`to`, `cc` and `bcc` also accept semicolons as separators, and quoted display names such as `"Doe, John" `. + +[#mailkit-smtpappender-differences] +=== Differences from the legacy appender + +The options above are named exactly as in the legacy appender, but a few behave differently: + +* `smtpHost` is *required*. MailKit has no notion of a machine-wide default SMTP server, so there is nothing to fall back on when the option is omitted. +* `enableSsl` set to `true` negotiates transport security automatically: implicit TLS on port 465, otherwise `STARTTLS` when the server advertises it. +Set to `false`, the connection is not encrypted at all. +* `authentication` set to `Ntlm` requires `username` and `password`. +MailKit cannot reuse the Windows logon session of the current thread or process, which the legacy appender did. +* Semicolon-delimited recipient lists in `to`, `cc` and `bcc` are parsed correctly. + +[#legacy-smtpappender] +== Built-in SmtpAppender (legacy) + +[NOTE] +==== +This appender is still shipped and still works, but it is not recommended for new configurations. +See the note at the xref:#smtpappender[top of this page]. +==== + +The built-in appender is configured with the same options, except for the differences listed in xref:#mailkit-smtpappender-differences[]. +It needs no assembly-qualified type name, because it is part of the `log4net` assembly. + +[source,xml] +---- + + + + + + + + + + + + + + +---- + +If you cannot take on the MailKit dependency, xref:manual/configuration/appenders/smtppickupdirappender.adoc[] is an alternative that avoids an SMTP client altogether by writing the emails to a pickup directory. \ No newline at end of file diff --git a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/smtppickupdirappender.adoc b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/smtppickupdirappender.adoc index bd4929ec2..742e22d9e 100644 --- a/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/smtppickupdirappender.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/configuration/appenders/smtppickupdirappender.adoc @@ -21,6 +21,9 @@ The `SmtpPickupDirAppender` is configured similarly to the xref:./smtpappender.adoc[]. The only difference is that rather than specify a SmtpHost parameter a `PickupDir` must be specified. +Because this appender writes the emails to disk and leaves their delivery to another process, it uses no SMTP client at all. +It is therefore unaffected by the deprecation of `System.Net.Mail.SmtpClient`, and remains a good option when you cannot take on the MailKit dependency that the recommended xref:./smtpappender.adoc#mailkit-smtpappender[MailKit based `SmtpAppender`] requires. + The `PickupDir` parameter is a path that must exist and the code executing the appender must have permission to create new files and write to them in this directory. The path is relative to the application's base directory (AppDomain.BaseDirectory). diff --git a/src/site/antora/modules/ROOT/pages/manual/supported-frameworks.adoc b/src/site/antora/modules/ROOT/pages/manual/supported-frameworks.adoc index b2571b3d9..682b1c2cc 100644 --- a/src/site/antora/modules/ROOT/pages/manual/supported-frameworks.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/supported-frameworks.adoc @@ -63,3 +63,7 @@ The following appenders are supported on the specified frameworks: | TraceAppender | x | x | UdpAppender | x | x |=== + +The recommended MailKit based `SmtpAppender` is not part of the `log4net` assembly. +It ships in the separate `log4net.Ext.Mail` package, which targets .NET Standard 2.0 and is therefore usable from both .NET Framework 4.7.1 and modern .NET applications. +See xref:manual/configuration/appenders/smtpappender.adoc[] for details. From 62b64b22584488a2ce3b3600a6a1fdcb968a79d0 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Tue, 28 Jul 2026 22:34:32 +0200 Subject: [PATCH 8/8] simplify type in return statement --- src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs index e6810f52e..5a7117c06 100644 --- a/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs +++ b/src/log4net.Ext.Mail.Tests/Appender/SmtpAppenderTest.cs @@ -78,7 +78,7 @@ private SmtpAppender CreateAppender(string? header = null, string? footer = null PatternLayout layout = new() { ConversionPattern = "%m%n", Header = header, Footer = footer }; layout.ActivateOptions(); - return new SmtpAppender(() => _transport) + return new(() => _transport) { Layout = layout, ErrorHandler = _errorHandler,