| #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.Ext.Mail.Appender.Internal; |
| using log4net.Util; |
| using MailKit.Security; |
| |
| using MimeKit; |
| using MimeKit.Text; |
| |
| namespace log4net.Ext.Mail.Appender; |
| |
| /// <summary> |
| /// Send an e-mail when a specific logging event occurs, typically on errors |
| /// or fatal errors, using MailKit as the SMTP client. |
| /// </summary> |
| /// <remarks> |
| /// <para> |
| /// This appender exposes the same options as <see cref="log4net.Appender.SmtpAppender"/>, |
| /// so an existing configuration can be pointed at this type without change. The difference |
| /// is the transport: it sends through <see cref="MailKit.Net.Smtp.SmtpClient"/> instead of |
| /// the obsolete <see cref="System.Net.Mail.SmtpClient"/>. |
| /// </para> |
| /// <para> |
| /// The number of logging events delivered in this e-mail depend on |
| /// the value of <see cref="BufferingAppenderSkeleton.BufferSize"/> option. The |
| /// <see cref="SmtpAppender"/> keeps only the last |
| /// <see cref="BufferingAppenderSkeleton.BufferSize"/> logging events in its |
| /// cyclic buffer. This keeps memory requirements at a reasonable level while |
| /// still delivering useful application context. |
| /// </para> |
| /// <para> |
| /// Authentication is supported by setting the <see cref="Authentication"/> property to |
| /// either <see cref="SmtpAuthentication.Basic"/> or <see cref="SmtpAuthentication.Ntlm"/>. |
| /// If using <see cref="SmtpAuthentication.Basic"/> authentication then the <see cref="Username"/> |
| /// and <see cref="Password"/> properties must also be set. |
| /// </para> |
| /// <para> |
| /// To set the SMTP server port use the <see cref="Port"/> property. The default port is 25. |
| /// </para> |
| /// <para> |
| /// Unlike <see cref="System.Net.Mail.SmtpClient"/>, MailKit has no notion of a machine-wide |
| /// default SMTP server, so <see cref="SmtpHost"/> is required. |
| /// </para> |
| /// </remarks> |
| public class SmtpAppender : BufferingAppenderSkeleton |
| { |
| /// <summary> |
| /// The port reserved for SMTP over implicit TLS, where TLS starts before the SMTP greeting |
| /// rather than being negotiated with <c>STARTTLS</c>. |
| /// </summary> |
| private const int ImplicitTlsPort = 465; |
| |
| private readonly Func<ISmtpTransport> _transportFactory; |
| |
| /// <summary> |
| /// Default constructor. Sends through <see cref="MailKitSmtpTransport"/>. |
| /// </summary> |
| public SmtpAppender() |
| : this(static () => new MailKitSmtpTransport()) |
| { } |
| |
| /// <summary> |
| /// Creates an appender that obtains its transport from <paramref name="transportFactory"/>. |
| /// </summary> |
| /// <param name="transportFactory"> |
| /// Called once per e-mail to create the transport used to send it. |
| /// </param> |
| internal SmtpAppender(Func<ISmtpTransport> transportFactory) |
| => _transportFactory = transportFactory.EnsureNotNull(); |
| |
| /// <summary> |
| /// Gets or sets a comma-delimited list of recipient e-mail addresses. |
| /// </summary> |
| public string? To |
| { |
| get; |
| set => field = MaybeTrimSeparators(value); |
| } |
| |
| /// <summary> |
| /// Gets or sets a comma-delimited list of recipient e-mail addresses |
| /// that will be carbon copied. |
| /// </summary> |
| public string? Cc |
| { |
| get; |
| set => field = MaybeTrimSeparators(value); |
| } |
| |
| /// <summary> |
| /// Gets or sets a comma-delimited list of recipient e-mail addresses |
| /// that will be blind carbon copied. |
| /// </summary> |
| /// <value> |
| /// A comma-delimited list of e-mail addresses. |
| /// </value> |
| /// <remarks> |
| /// <para> |
| /// Semicolons are also accepted as separators, for backward compatibility. |
| /// </para> |
| /// </remarks> |
| public string? Bcc |
| { |
| get; |
| set => field = MaybeTrimSeparators(value); |
| } |
| |
| /// <summary> |
| /// Gets or sets the e-mail address of the sender. |
| /// </summary> |
| /// <value> |
| /// The e-mail address of the sender. |
| /// </value> |
| public string? From { get; set; } |
| |
| /// <summary> |
| /// Gets or sets the subject line of the e-mail message. |
| /// </summary> |
| /// <value> |
| /// The subject line of the e-mail message. |
| /// </value> |
| public string? Subject { get; set; } |
| |
| /// <summary> |
| /// Gets or sets the name of the SMTP relay mail server to use to send |
| /// the e-mail messages. |
| /// </summary> |
| /// <value> |
| /// The name of the e-mail relay server. |
| /// </value> |
| /// <remarks> |
| /// <para> |
| /// This option is required. MailKit, unlike <see cref="System.Net.Mail.SmtpClient"/>, |
| /// has no machine-wide default SMTP server to fall back on. |
| /// </para> |
| /// </remarks> |
| public string? SmtpHost { get; set; } |
| |
| /// <summary> |
| /// The mode to use to authentication with the SMTP server |
| /// </summary> |
| /// <remarks> |
| /// <para> |
| /// Valid Authentication mode values are: <see cref="SmtpAuthentication.None"/>, |
| /// <see cref="SmtpAuthentication.Basic"/>, and <see cref="SmtpAuthentication.Ntlm"/>. |
| /// The default value is <see cref="SmtpAuthentication.None"/>. When using |
| /// <see cref="SmtpAuthentication.Basic"/> you must specify the <see cref="Username"/> |
| /// and <see cref="Password"/> to use to authenticate. |
| /// </para> |
| /// <para> |
| /// <see cref="SmtpAuthentication.Ntlm"/> authenticates with the NTLM SASL mechanism. |
| /// Note that MailKit cannot reuse the Windows logon session of the current thread or |
| /// process the way <see cref="System.Net.Mail.SmtpClient"/> could, so |
| /// <see cref="Username"/> and <see cref="Password"/> must be supplied for NTLM as well. |
| /// </para> |
| /// </remarks> |
| public SmtpAuthentication Authentication { get; set; } = SmtpAuthentication.None; |
| |
| /// <summary> |
| /// The username to use to authenticate with the SMTP server |
| /// </summary> |
| /// <remarks> |
| /// <para> |
| /// A <see cref="Username"/> and <see cref="Password"/> must be specified when |
| /// <see cref="Authentication"/> is set to <see cref="SmtpAuthentication.Basic"/> |
| /// or <see cref="SmtpAuthentication.Ntlm"/>, otherwise the username will be ignored. |
| /// </para> |
| /// </remarks> |
| public string? Username { get; set; } |
| |
| /// <summary> |
| /// The password to use to authenticate with the SMTP server |
| /// </summary> |
| /// <remarks> |
| /// <para> |
| /// A <see cref="Username"/> and <see cref="Password"/> must be specified when |
| /// <see cref="Authentication"/> is set to <see cref="SmtpAuthentication.Basic"/> |
| /// or <see cref="SmtpAuthentication.Ntlm"/>, otherwise the password will be ignored. |
| /// </para> |
| /// </remarks> |
| public string? Password { get; set; } |
| |
| /// <summary> |
| /// The port on which the SMTP server is listening |
| /// </summary> |
| /// <remarks> |
| /// <para> |
| /// The port on which the SMTP server is listening. The default |
| /// port is <c>25</c>. |
| /// </para> |
| /// </remarks> |
| public int Port { get; set; } = 25; |
| |
| /// <summary> |
| /// Gets or sets the priority of the e-mail message |
| /// </summary> |
| /// <value> |
| /// One of the <see cref="MailPriority"/> values. |
| /// </value> |
| /// <remarks> |
| /// <para> |
| /// Sets the priority of the e-mails generated by this |
| /// appender. The default priority is <see cref="MailPriority.Normal"/>. |
| /// </para> |
| /// <para> |
| /// If you are using this appender to report errors then |
| /// you may want to set the priority to <see cref="MailPriority.High"/>. |
| /// </para> |
| /// <para> |
| /// The value is mapped onto the MIME <c>Priority</c> header: |
| /// <see cref="MailPriority.Low"/> becomes <see cref="MessagePriority.NonUrgent"/>, |
| /// <see cref="MailPriority.Normal"/> becomes <see cref="MessagePriority.Normal"/> and |
| /// <see cref="MailPriority.High"/> becomes <see cref="MessagePriority.Urgent"/>. |
| /// </para> |
| /// </remarks> |
| public MailPriority Priority { get; set; } = MailPriority.Normal; |
| |
| /// <summary> |
| /// Enable or disable use of SSL/TLS when sending e-mail message |
| /// </summary> |
| /// <remarks> |
| /// <para> |
| /// This is a shorthand for <see cref="TransportSecurity"/>: setting it to |
| /// <see langword="true"/> selects <see cref="SmtpTransportSecurity.Required"/> and setting it to |
| /// <see langword="false"/> selects <see cref="SmtpTransportSecurity.None"/>. The two properties |
| /// are the same setting, so the one assigned last wins. |
| /// </para> |
| /// <para> |
| /// When <see langword="true"/>, transport security is required: implicit TLS on port 465 and |
| /// <c>STARTTLS</c> on every other port. Connecting fails if the server does not offer TLS, |
| /// rather than continuing unencrypted, which matches the behaviour of |
| /// <see cref="System.Net.Mail.SmtpClient.EnableSsl"/>. Use <see cref="TransportSecurity"/> when |
| /// the server needs something else. |
| /// </para> |
| /// </remarks> |
| public bool EnableSsl |
| { |
| get => TransportSecurity != SmtpTransportSecurity.None; |
| set => TransportSecurity = value ? SmtpTransportSecurity.Required : SmtpTransportSecurity.None; |
| } |
| |
| /// <summary> |
| /// Gets or sets how the connection to the SMTP server is secured. |
| /// </summary> |
| /// <value> |
| /// One of the <see cref="SmtpTransportSecurity"/> values. The default is |
| /// <see cref="SmtpTransportSecurity.None"/>. |
| /// </value> |
| /// <remarks> |
| /// <para> |
| /// <see cref="EnableSsl"/> is a shorthand for this property and covers the usual cases; set this |
| /// one when the server expects implicit TLS on a port other than 465, or when only opportunistic |
| /// <c>STARTTLS</c> is possible. |
| /// </para> |
| /// </remarks> |
| public SmtpTransportSecurity TransportSecurity { get; set; } = SmtpTransportSecurity.None; |
| |
| /// <summary> |
| /// Gets or sets the reply-to e-mail address. |
| /// </summary> |
| public string? ReplyTo { get; set; } |
| |
| /// <summary> |
| /// Gets or sets the subject encoding to be used. |
| /// </summary> |
| /// <remarks> |
| /// The default encoding is <see cref="Encoding.UTF8"/>. |
| /// </remarks> |
| public Encoding SubjectEncoding { get; set; } = Encoding.UTF8; |
| |
| /// <summary> |
| /// Gets or sets the body encoding to be used. |
| /// </summary> |
| /// <remarks> |
| /// The default encoding is <see cref="Encoding.UTF8"/>. |
| /// </remarks> |
| public Encoding BodyEncoding { get; set; } = Encoding.UTF8; |
| |
| /// <summary> |
| /// Sends the contents of the cyclic buffer as an e-mail message. |
| /// </summary> |
| /// <param name="events">The logging events to send.</param> |
| 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); |
| } |
| } |
| |
| /// <summary> |
| /// This appender requires a <see cref="AppenderSkeleton.Layout"/> to be set. |
| /// </summary> |
| protected override bool RequiresLayout => true; |
| |
| /// <summary> |
| /// Translates <see cref="TransportSecurity"/> into the mail library's own representation. |
| /// </summary> |
| /// <returns>The transport security to connect with.</returns> |
| /// <remarks> |
| /// <para> |
| /// <see cref="SecureSocketOptions.Auto"/> is deliberately never used: away from port 465 it is |
| /// opportunistic, so an attacker who strips <c>STARTTLS</c> from the EHLO response silently |
| /// downgrades the session to plaintext, taking the credentials and the log content with it. |
| /// Opportunistic behavior is available, but only by asking for it with |
| /// <see cref="SmtpTransportSecurity.StartTlsWhenAvailable"/>. |
| /// </para> |
| /// </remarks> |
| private SecureSocketOptions ResolveSecureSocketOptions() => TransportSecurity switch |
| { |
| SmtpTransportSecurity.None => SecureSocketOptions.None, |
| SmtpTransportSecurity.Required => Port == ImplicitTlsPort |
| ? SecureSocketOptions.SslOnConnect |
| : SecureSocketOptions.StartTls, |
| SmtpTransportSecurity.ImplicitTls => SecureSocketOptions.SslOnConnect, |
| SmtpTransportSecurity.StartTls => SecureSocketOptions.StartTls, |
| SmtpTransportSecurity.StartTlsWhenAvailable => SecureSocketOptions.StartTlsWhenAvailable, |
| _ => throw SystemInfo.CreateArgumentOutOfRangeException(nameof(TransportSecurity), TransportSecurity, |
| $"The value specified for TransportSecurity is not one of the {nameof(SmtpTransportSecurity)} values.") |
| }; |
| |
| /// <summary> |
| /// Send the email message |
| /// </summary> |
| /// <param name="messageBody">the body text to include in the mail</param> |
| protected virtual void SendEmail(string messageBody) |
| { |
| using MimeMessage message = CreateMessage(messageBody); |
| using ISmtpTransport transport = _transportFactory().EnsureNotNull(); |
| |
| transport.Connect(SmtpHost.EnsureNotNullOrEmpty(), Port, ResolveSecureSocketOptions()); |
| 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); |
| } |
| } |
| |
| /// <summary> |
| /// Builds the <see cref="MimeMessage"/> for the given body text from the configured options. |
| /// </summary> |
| /// <param name="messageBody">the body text to include in the mail</param> |
| /// <returns>the message to send</returns> |
| /// <remarks> |
| /// <para> |
| /// Not CLS compliant, because <see cref="MimeMessage"/> comes from MimeKit, which does not |
| /// declare itself CLS compliant. |
| /// </para> |
| /// </remarks> |
| [CLSCompliant(false)] |
| 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; |
| } |
| |
| /// <summary> |
| /// Values for the <see cref="Authentication"/> property. |
| /// </summary> |
| /// <remarks> |
| /// <para> |
| /// SMTP authentication modes. |
| /// </para> |
| /// </remarks> |
| public enum SmtpAuthentication |
| { |
| /// <summary> |
| /// No authentication |
| /// </summary> |
| None, |
| |
| /// <summary> |
| /// Basic authentication. |
| /// </summary> |
| /// <remarks> |
| /// Requires a username and password to be supplied |
| /// </remarks> |
| Basic, |
| |
| /// <summary> |
| /// NTLM authentication. |
| /// </summary> |
| /// <remarks> |
| /// Requires a username and password to be supplied; MailKit cannot reuse the |
| /// Windows logon session of the current thread or process. |
| /// </remarks> |
| Ntlm |
| } |
| |
| // Allow semicolon delimiters for backward compatibility. |
| private static readonly char[] _addressDelimiters = [',', ';']; |
| |
| /// <summary> |
| /// Trims leading and trailing commas or semicolons |
| /// </summary> |
| private static string? MaybeTrimSeparators(string? s) => s?.Trim(_addressDelimiters); |
| |
| /// <summary> |
| /// Parses a comma- or semicolon-delimited list of addresses. |
| /// </summary> |
| /// <remarks> |
| /// RFC 5322 only allows commas, which is what <see cref="InternetAddressList.Parse(string)"/> |
| /// accepts, so semicolon-delimited lists are retried after normalization. |
| /// </remarks> |
| private static InternetAddressList ParseAddresses(string addresses) |
| => InternetAddressList.TryParse(addresses, out InternetAddressList? list) |
| ? list |
| : InternetAddressList.Parse(ReplaceUnquotedSemicolons(addresses)); |
| |
| /// <summary> |
| /// Replaces every semicolon that is not inside a quoted string with a comma. |
| /// </summary> |
| 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(); |
| } |
| } |