This is an automated email from the ASF dual-hosted git repository. ramanathan1504 pushed a commit to branch feature/custom-header-smtp-appender in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git
commit fa94c0b3e38ca4a39fad1a51bef620407930430c Author: Ramanathan <[email protected]> AuthorDate: Mon Aug 31 00:14:02 2026 +0530 Added support for custom `SmtpAppender` message headers, configured with nested `Property` elements. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JGFUJHbTHA7pLK47CVyacv --- .../log4j/core/appender/SmtpAppenderTest.java | 53 ++++++++++ .../logging/log4j/core/net/SmtpManagerTest.java | 59 +++++++++++ .../logging/log4j/core/appender/SmtpAppender.java | 76 +++++++++++++- .../apache/logging/log4j/core/net/MailManager.java | 115 ++++++++++++++++++++- .../apache/logging/log4j/core/net/SmtpManager.java | 68 +++++++++++- .../logging/log4j/core/net/package-info.java | 2 +- .../org/apache/logging/log4j/smtp/SmtpManager.java | 66 +++++++++++- .../apache/logging/log4j/smtp/package-info.java | 2 +- .../logging/log4j/smtp/SmtpAppenderTest.java | 53 ++++++++++ .../apache/logging/log4j/smtp/SmtpManagerTest.java | 36 +++++++ .../.2.x.x/3704_smtp_appender_custom_headers.xml | 13 +++ .../ROOT/pages/manual/appenders/network.adoc | 18 ++++ 12 files changed, 555 insertions(+), 6 deletions(-) diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/SmtpAppenderTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/SmtpAppenderTest.java index 1ad3d8f8d2..68ae3aa4cf 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/SmtpAppenderTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/SmtpAppenderTest.java @@ -33,6 +33,7 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.ThreadContext; import org.apache.logging.log4j.core.Logger; import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Property; import org.apache.logging.log4j.core.net.MimeMessageBuilder; import org.apache.logging.log4j.core.net.SmtpManager; import org.apache.logging.log4j.core.test.AvailablePortFinder; @@ -177,4 +178,56 @@ class SmtpAppenderTest { assertFalse(body2.contains("Error with exception")); assertTrue(body2.contains("Error message #2")); } + + @Test + void testCustomHeaders() { + final String traceKey = getClass().getName() + ".traceId"; + final String traceValue = "TraceValue1"; + ThreadContext.put(traceKey, traceValue); + final int smtpPort = AvailablePortFinder.getNextAvailable(); + final SmtpAppender appender = SmtpAppender.newBuilder() + .setName("TestHeaders") + .setTo("[email protected]") + .setFrom("[email protected]") + .setSubject("Headers Subject") + .setSmtpHost(HOST) + .setSmtpPort(smtpPort) + .setBufferSize(3) + .addHeader(Property.createProperty("X-Static", "fixed-value")) + .addHeader(Property.createProperty("X-Trace-Id", "%X{" + traceKey + "}")) + .addHeader(Property.createProperty("X-Tag", "first")) + .addHeader(Property.createProperty("X-Tag", "second")) + .addHeader(Property.createProperty("X-Message", "%m")) + .addHeader(Property.createProperty("X:Invalid", "ignored")) + .build(); + assertNotNull(appender); + assertInstanceOf(SmtpManager.class, appender.getManager()); + appender.start(); + + final LoggerContext context = LoggerContext.getContext(); + final Logger root = context.getLogger("SMTPAppenderHeadersTest"); + root.addAppender(appender); + root.setAdditive(false); + root.setLevel(Level.DEBUG); + + final SimpleSmtpServer server = SimpleSmtpServer.start(smtpPort); + try { + root.error("safe\r\nX-Evil: injected"); + } finally { + server.stop(); + root.removeAppender(appender); + appender.stop(); + ThreadContext.remove(traceKey); + } + + assertEquals(1, server.getReceivedEmailSize()); + final SmtpMessage email = server.getReceivedEmail().next(); + + assertEquals("fixed-value", email.getHeaderValue("X-Static")); + assertEquals(traceValue, email.getHeaderValue("X-Trace-Id")); + assertArrayEquals(new String[] {"first", "second"}, email.getHeaderValues("X-Tag")); + assertEquals("safe X-Evil: injected", email.getHeaderValue("X-Message")); + assertEquals(0, email.getHeaderValues("X-Evil").length); + assertEquals(0, email.getHeaderValues("X").length); + } } diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/net/SmtpManagerTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/net/SmtpManagerTest.java index 5e3dd846a1..4ae6763505 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/net/SmtpManagerTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/net/SmtpManagerTest.java @@ -19,9 +19,12 @@ package org.apache.logging.log4j.core.net; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.Arrays; +import javax.mail.MessagingException; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.appender.SmtpAppender; import org.apache.logging.log4j.core.async.RingBufferLogEvent; +import org.apache.logging.log4j.core.config.Property; import org.apache.logging.log4j.core.impl.Log4jLogEvent; import org.apache.logging.log4j.core.impl.MutableLogEvent; import org.apache.logging.log4j.core.util.ClockFactory; @@ -50,6 +53,62 @@ class SmtpManagerTest { assertEquals("SMTP:to:cc::from::LOG4J2-3107:proto:smtp.log4j.com:4711:username::filter", managerName); } + @Test + void testCreateManagerNameDistinguishesHeaders() { + assertThat(managerNameWithHeaders(Property.createProperty("X-Tag", "a"))) + .isNotEqualTo(managerNameWithHeaders(Property.createProperty("X-Tag", "b"))); + } + + private static String managerNameWithHeaders(final Property... headers) { + return SmtpManager.createManagerName( + "to", + "cc", + null, + "from", + null, + "LOG4J2-3107", + "proto", + "smtp.log4j.com", + 4711, + "username", + false, + "filter", + headers); + } + + @Test + void testEncodeHeaderValueLeavesPlainAsciiAlone() throws MessagingException { + assertEquals("plain value", SmtpManager.encodeHeaderValue("X-Test", "plain value")); + } + + @Test + void testEncodeHeaderValueNeutralizesControlCharacters() throws MessagingException { + assertEquals("safe X-Evil: injected", SmtpManager.encodeHeaderValue("X-Test", "safe\r\nX-Evil: injected")); + } + + @Test + void testEncodeHeaderValueIsAsciiOnly() throws MessagingException { + final String encoded = SmtpManager.encodeHeaderValue("X-Test", "Jos\u00e9 \u20b9500"); + assertThat(encoded.chars().allMatch(c -> c < 128)).isTrue(); + } + + @Test + void testEncodeHeaderValueRespectsLineLengthLimit() throws MessagingException { + final char[] chars = new char[5_000]; + Arrays.fill(chars, 'x'); + assertLineLengthLimit(SmtpManager.encodeHeaderValue("X-Test", new String(chars))); + Arrays.fill(chars, '\u00e9'); + assertLineLengthLimit(SmtpManager.encodeHeaderValue("X-Test", new String(chars))); + } + + private static void assertLineLengthLimit(final String encoded) { + int used = "X-Test".length() + 2; + for (final String line : encoded.split("\r\n", -1)) { + assertThat(used + line.length()).isLessThanOrEqualTo(998); + used = 0; + } + } + private void testAdd(final LogEvent event) { final SmtpAppender appender = SmtpAppender.newBuilder() .setName("smtp") diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/SmtpAppender.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/SmtpAppender.java index b610603b4b..f84e35b11f 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/SmtpAppender.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/SmtpAppender.java @@ -17,6 +17,9 @@ package org.apache.logging.log4j.core.appender; import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.ServiceLoader; import org.apache.logging.log4j.core.Appender; import org.apache.logging.log4j.core.Core; @@ -138,6 +141,9 @@ public final class SmtpAppender extends AbstractAppender { @PluginElement("SSL") private SslConfiguration sslConfiguration; + @PluginElement("Headers") + private Property[] headers; + /** * Comma-separated list of recipient email addresses. */ @@ -251,6 +257,33 @@ public final class SmtpAppender extends AbstractAppender { return this; } + /** + * Specifies custom headers to add to every message. Header values are {@link PatternLayout} patterns, + * evaluated against the event that triggers the message. + * + * @since 2.27.0 + */ + public Builder setHeaders(final Property[] headers) { + this.headers = headers; + return this; + } + + /** + * Adds a single custom header. The header value is a {@link PatternLayout} pattern, evaluated against the + * event that triggers the message. + * + * @since 2.27.0 + */ + public Builder addHeader(final Property header) { + if (header != null) { + final Property[] oldHeaders = headers != null ? headers : Property.EMPTY_ARRAY; + final Property[] newHeaders = Arrays.copyOf(oldHeaders, oldHeaders.length + 1); + newHeaders[oldHeaders.length] = header; + headers = newHeaders; + } + return this; + } + /** * Specifies the layout used for the email message body. By default, this uses the * {@linkplain HtmlLayout#createDefaultLayout() default HTML layout}. @@ -284,6 +317,14 @@ public final class SmtpAppender extends AbstractAppender { .setConfiguration(getConfiguration()) .setPattern(subject) .build(); + final Property[] headerArray = filterValidHeaders(headers); + final Serializer[] headerSerializers = new Serializer[headerArray.length]; + for (int i = 0; i < headerArray.length; i++) { + headerSerializers[i] = PatternLayout.newSerializerBuilder() + .setConfiguration(getConfiguration()) + .setPattern(headerArray[i].getValue()) + .build(); + } final FactoryData data = new FactoryData( to, cc, @@ -300,7 +341,9 @@ public final class SmtpAppender extends AbstractAppender { smtpDebug, bufferSize, sslConfiguration, - getFilter().toString()); + getFilter().toString(), + headerArray, + headerSerializers); final MailManagerFactory factory = ServiceLoaderUtil.safeStream( MailManagerFactory.class, ServiceLoader.load( @@ -317,6 +360,37 @@ public final class SmtpAppender extends AbstractAppender { return new SmtpAppender( getName(), getFilter(), getLayout(), smtpManager, isIgnoreExceptions(), getPropertyArray()); } + + private Property[] filterValidHeaders(final Property[] headers) { + if (headers == null || headers.length == 0) { + return Property.EMPTY_ARRAY; + } + final List<Property> validHeaders = new ArrayList<>(headers.length); + for (final Property header : headers) { + if (isValidHeaderName(header.getName())) { + validHeaders.add(header); + } else { + LOGGER.error( + "SmtpAppender '{}' ignores the header with the invalid name '{}'.", + getName(), + header.getName()); + } + } + return validHeaders.toArray(Property.EMPTY_ARRAY); + } + + private static boolean isValidHeaderName(final String name) { + if (Strings.isEmpty(name)) { + return false; + } + for (int i = 0; i < name.length(); i++) { + final char c = name.charAt(i); + if (c < '!' || c > '~' || c == ':') { + return false; + } + } + return true; + } } /** diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/net/MailManager.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/net/MailManager.java index ea0ea1585c..f410cdc97d 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/net/MailManager.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/net/MailManager.java @@ -20,6 +20,7 @@ import org.apache.logging.log4j.core.Layout; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.appender.AbstractManager; +import org.apache.logging.log4j.core.config.Property; import org.apache.logging.log4j.core.layout.AbstractStringLayout.Serializer; import org.apache.logging.log4j.core.net.ssl.SslConfiguration; @@ -29,6 +30,8 @@ import org.apache.logging.log4j.core.net.ssl.SslConfiguration; */ public abstract class MailManager extends AbstractManager { + private static final Serializer[] EMPTY_SERIALIZERS = {}; + /** * Creates a unique-per-configuration name for an smtp manager using the * specified the parameters.<br> @@ -49,6 +52,44 @@ public abstract class MailManager extends AbstractManager { final String smtpUsername, final boolean smtpDebug, final String filterName) { + return createManagerName( + to, + cc, + bcc, + from, + replyTo, + subject, + smtpProtocol, + smtpHost, + smtpPort, + smtpUsername, + smtpDebug, + filterName, + Property.EMPTY_ARRAY); + } + + /** + * Creates a unique-per-configuration name for an smtp manager using the + * specified the parameters.<br> + * Using such a name allows us to maintain singletons per unique configurations. + * + * @return smtp manager name + * @since 2.27.0 + */ + static String createManagerName( + final String to, + final String cc, + final String bcc, + final String from, + final String replyTo, + final String subject, + final String smtpProtocol, + final String smtpHost, + final int smtpPort, + final String smtpUsername, + final boolean smtpDebug, + final String filterName, + final Property[] headers) { final StringBuilder sb = new StringBuilder(); @@ -87,6 +128,11 @@ public abstract class MailManager extends AbstractManager { } sb.append(smtpDebug ? ":debug:" : "::"); sb.append(filterName); + if (headers != null) { + for (final Property header : headers) { + sb.append(':').append(header.getName()).append('=').append(header.getValue()); + } + } return "SMTP:" + sb.toString(); } @@ -107,6 +153,8 @@ public abstract class MailManager extends AbstractManager { private final boolean smtpDebug; private final int bufferSize; private final SslConfiguration sslConfiguration; + private final Property[] headers; + private final Serializer[] headerSerializers; private final String filterName; private final String managerName; @@ -127,6 +175,49 @@ public abstract class MailManager extends AbstractManager { final int bufferSize, final SslConfiguration sslConfiguration, final String filterName) { + this( + to, + cc, + bcc, + from, + replyTo, + subject, + subjectSerializer, + smtpProtocol, + smtpHost, + smtpPort, + smtpUsername, + smtpPassword, + smtpDebug, + bufferSize, + sslConfiguration, + filterName, + Property.EMPTY_ARRAY, + EMPTY_SERIALIZERS); + } + + /** + * @since 2.27.0 + */ + public FactoryData( + final String to, + final String cc, + final String bcc, + final String from, + final String replyTo, + final String subject, + final Serializer subjectSerializer, + final String smtpProtocol, + final String smtpHost, + final int smtpPort, + final String smtpUsername, + final String smtpPassword, + final boolean smtpDebug, + final int bufferSize, + final SslConfiguration sslConfiguration, + final String filterName, + final Property[] headers, + final Serializer[] headerSerializers) { this.to = to; this.cc = cc; this.bcc = bcc; @@ -142,6 +233,8 @@ public abstract class MailManager extends AbstractManager { this.smtpDebug = smtpDebug; this.bufferSize = bufferSize; this.sslConfiguration = sslConfiguration; + this.headers = headers != null ? headers : Property.EMPTY_ARRAY; + this.headerSerializers = headerSerializers != null ? headerSerializers : EMPTY_SERIALIZERS; this.filterName = filterName; this.managerName = createManagerName( to, @@ -155,7 +248,8 @@ public abstract class MailManager extends AbstractManager { smtpPort, smtpUsername, smtpDebug, - filterName); + filterName, + this.headers); } public String getTo() { @@ -218,6 +312,25 @@ public abstract class MailManager extends AbstractManager { return sslConfiguration; } + /** + * Returns the custom message headers, in configuration order. + * + * @since 2.27.0 + */ + public Property[] getHeaders() { + return headers; + } + + /** + * Returns the {@link org.apache.logging.log4j.core.layout.PatternLayout} serializers rendering the + * {@linkplain #getHeaders() header} values, in the same order. + * + * @since 2.27.0 + */ + public Serializer[] getHeaderSerializers() { + return headerSerializers; + } + public String getFilterName() { return filterName; } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/net/SmtpManager.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/net/SmtpManager.java index 72376d3965..4ad0ef184a 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/net/SmtpManager.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/net/SmtpManager.java @@ -19,6 +19,8 @@ package org.apache.logging.log4j.core.net; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.Properties; import javax.activation.DataSource; @@ -40,6 +42,7 @@ import org.apache.logging.log4j.LoggingException; import org.apache.logging.log4j.core.Layout; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.config.Property; import org.apache.logging.log4j.core.internal.annotation.SuppressFBWarnings; import org.apache.logging.log4j.core.layout.AbstractStringLayout.Serializer; import org.apache.logging.log4j.core.layout.PatternLayout; @@ -47,6 +50,7 @@ import org.apache.logging.log4j.core.net.ssl.SslConfiguration; import org.apache.logging.log4j.core.util.CyclicBuffer; import org.apache.logging.log4j.core.util.NetUtils; import org.apache.logging.log4j.util.PropertiesUtil; +import org.apache.logging.log4j.util.Strings; /** * Manager for sending SMTP events. @@ -54,6 +58,8 @@ import org.apache.logging.log4j.util.PropertiesUtil; public class SmtpManager extends MailManager { public static final SMTPManagerFactory FACTORY = new SMTPManagerFactory(); + private static final int MAX_LINE_LENGTH = 998; + private final Session session; private final CyclicBuffer<LogEvent> buffer; @@ -150,7 +156,7 @@ public class SmtpManager extends MailManager { final String subject = data.getSubjectSerializer().toSerializable(appendEvent); - sendMultipartMessage(message, mp, subject); + sendMultipartMessage(message, mp, subject, appendEvent); } catch (final MessagingException | IOException | RuntimeException e) { logError("Caught exception while sending e-mail notification.", e); throw new LoggingException("Error occurred while sending email", e); @@ -264,6 +270,66 @@ public class SmtpManager extends MailManager { } } + /** + * @since 2.27.0 + */ + @SuppressFBWarnings( + value = "SMTP_HEADER_INJECTION", + justification = "Header values are stripped of control characters and MIME-encoded before use.") + protected void sendMultipartMessage( + final MimeMessage msg, final MimeMultipart mp, final String subject, final LogEvent appendEvent) + throws MessagingException { + synchronized (msg) { + msg.setContent(mp); + msg.setSentDate(new Date()); + msg.setSubject(subject); + applyHeaders(msg, appendEvent); + Transport.send(msg); + } + } + + private void applyHeaders(final MimeMessage msg, final LogEvent appendEvent) throws MessagingException { + final Property[] headers = data.getHeaders(); + if (headers.length == 0) { + return; + } + final Serializer[] serializers = data.getHeaderSerializers(); + for (final Property header : headers) { + msg.removeHeader(header.getName()); + } + for (int i = 0; i < headers.length; i++) { + final String name = headers[i].getName(); + msg.addHeader(name, encodeHeaderValue(name, serializers[i].toSerializable(appendEvent))); + } + } + + static String encodeHeaderValue(final String name, final String value) throws MessagingException { + final int used = name.length() + 2; + String sanitized = replaceControlCharacters(value != null ? value : Strings.EMPTY); + if (sanitized.length() > MAX_LINE_LENGTH - used) { + sanitized = sanitized.substring(0, MAX_LINE_LENGTH - used); + } + try { + return MimeUtility.fold(used, MimeUtility.encodeText(sanitized, StandardCharsets.UTF_8.name(), null)); + } catch (final UnsupportedEncodingException error) { + throw new MessagingException("Failed encoding the value of the `" + name + "` header.", error); + } + } + + private static String replaceControlCharacters(final String value) { + StringBuilder replacement = null; + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + if (c < ' ' || c == 0x7F) { + if (replacement == null) { + replacement = new StringBuilder(value); + } + replacement.setCharAt(i, ' '); + } + } + return replacement != null ? replacement.toString() : value; + } + private synchronized void connect(final LogEvent appendEvent) { if (message != null) { return; diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/net/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/net/package-info.java index 1793a9165f..b0c926ee45 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/net/package-info.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/net/package-info.java @@ -25,7 +25,7 @@ * </ul> */ @Export -@Version("2.25.3") +@Version("2.27.0") package org.apache.logging.log4j.core.net; import org.osgi.annotation.bundle.Export; diff --git a/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/SmtpManager.java b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/SmtpManager.java index f98457a410..cc2d1b304f 100644 --- a/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/SmtpManager.java +++ b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/SmtpManager.java @@ -34,6 +34,8 @@ import jakarta.mail.util.ByteArrayDataSource; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.Properties; import javax.net.ssl.SSLContext; @@ -41,18 +43,23 @@ import javax.net.ssl.SSLSocketFactory; import org.apache.logging.log4j.LoggingException; import org.apache.logging.log4j.core.Layout; import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.layout.AbstractStringLayout.Serializer; import org.apache.logging.log4j.core.net.MailManager; import org.apache.logging.log4j.core.net.MailManagerFactory; import org.apache.logging.log4j.core.net.ssl.SslConfiguration; import org.apache.logging.log4j.core.util.CyclicBuffer; import org.apache.logging.log4j.core.util.NetUtils; import org.apache.logging.log4j.util.PropertiesUtil; +import org.apache.logging.log4j.util.Strings; /** * Manager for sending SMTP events. */ public class SmtpManager extends MailManager { + private static final int MAX_LINE_LENGTH = 998; + private final Session session; private final CyclicBuffer<LogEvent> buffer; @@ -106,7 +113,7 @@ public class SmtpManager extends MailManager { final String subject = data.getSubjectSerializer().toSerializable(appendEvent); - sendMultipartMessage(message, mp, subject); + sendMultipartMessage(message, mp, subject, appendEvent); } catch (final MessagingException | IOException | RuntimeException e) { logError("Caught exception while sending e-mail notification.", e); throw new LoggingException("Error occurred while sending email", e); @@ -217,6 +224,63 @@ public class SmtpManager extends MailManager { } } + /** + * @since 2.27.0 + */ + protected void sendMultipartMessage( + final MimeMessage msg, final MimeMultipart mp, final String subject, final LogEvent appendEvent) + throws MessagingException { + synchronized (msg) { + msg.setContent(mp); + msg.setSentDate(new Date()); + msg.setSubject(subject); + applyHeaders(msg, appendEvent); + Transport.send(msg); + } + } + + private void applyHeaders(final MimeMessage msg, final LogEvent appendEvent) throws MessagingException { + final Property[] headers = data.getHeaders(); + if (headers.length == 0) { + return; + } + final Serializer[] serializers = data.getHeaderSerializers(); + for (final Property header : headers) { + msg.removeHeader(header.getName()); + } + for (int i = 0; i < headers.length; i++) { + final String name = headers[i].getName(); + msg.addHeader(name, encodeHeaderValue(name, serializers[i].toSerializable(appendEvent))); + } + } + + static String encodeHeaderValue(final String name, final String value) throws MessagingException { + final int used = name.length() + 2; + String sanitized = replaceControlCharacters(value != null ? value : Strings.EMPTY); + if (sanitized.length() > MAX_LINE_LENGTH - used) { + sanitized = sanitized.substring(0, MAX_LINE_LENGTH - used); + } + try { + return MimeUtility.fold(used, MimeUtility.encodeText(sanitized, StandardCharsets.UTF_8.name(), null)); + } catch (final UnsupportedEncodingException error) { + throw new MessagingException("Failed encoding the value of the `" + name + "` header.", error); + } + } + + private static String replaceControlCharacters(final String value) { + StringBuilder replacement = null; + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + if (c < ' ' || c == 0x7F) { + if (replacement == null) { + replacement = new StringBuilder(value); + } + replacement.setCharAt(i, ' '); + } + } + return replacement != null ? replacement.toString() : value; + } + private synchronized void connect(final LogEvent appendEvent) { if (message != null) { return; diff --git a/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/package-info.java b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/package-info.java index 3272da320f..4f32dce7af 100644 --- a/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/package-info.java +++ b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/package-info.java @@ -15,7 +15,7 @@ * limitations under the license. */ @Export -@Version("2.20.1") +@Version("2.27.0") package org.apache.logging.log4j.smtp; import org.osgi.annotation.bundle.Export; diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/SmtpAppenderTest.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/SmtpAppenderTest.java index 2250a04e93..bc7146fb1a 100644 --- a/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/SmtpAppenderTest.java +++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/SmtpAppenderTest.java @@ -34,6 +34,7 @@ import org.apache.logging.log4j.ThreadContext; import org.apache.logging.log4j.core.Logger; import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.appender.SmtpAppender; +import org.apache.logging.log4j.core.config.Property; import org.apache.logging.log4j.core.test.AvailablePortFinder; import org.apache.logging.log4j.core.test.smtp.SimpleSmtpServer; import org.apache.logging.log4j.core.test.smtp.SmtpMessage; @@ -176,4 +177,56 @@ class SmtpAppenderTest { assertFalse(body2.contains("Error with exception")); assertTrue(body2.contains("Error message #2")); } + + @Test + void testCustomHeaders() { + final String traceKey = getClass().getName() + ".traceId"; + final String traceValue = "TraceValue1"; + ThreadContext.put(traceKey, traceValue); + final int smtpPort = AvailablePortFinder.getNextAvailable(); + final SmtpAppender appender = SmtpAppender.newBuilder() + .setName("TestHeaders") + .setTo("[email protected]") + .setFrom("[email protected]") + .setSubject("Headers Subject") + .setSmtpHost(HOST) + .setSmtpPort(smtpPort) + .setBufferSize(3) + .addHeader(Property.createProperty("X-Static", "fixed-value")) + .addHeader(Property.createProperty("X-Trace-Id", "%X{" + traceKey + "}")) + .addHeader(Property.createProperty("X-Tag", "first")) + .addHeader(Property.createProperty("X-Tag", "second")) + .addHeader(Property.createProperty("X-Message", "%m")) + .addHeader(Property.createProperty("X:Invalid", "ignored")) + .build(); + assertNotNull(appender); + assertInstanceOf(SmtpManager.class, appender.getManager()); + appender.start(); + + final LoggerContext context = LoggerContext.getContext(); + final Logger root = context.getLogger("SMTPAppenderHeadersTest"); + root.addAppender(appender); + root.setAdditive(false); + root.setLevel(Level.DEBUG); + + final SimpleSmtpServer server = SimpleSmtpServer.start(smtpPort); + try { + root.error("safe\r\nX-Evil: injected"); + } finally { + server.stop(); + root.removeAppender(appender); + appender.stop(); + ThreadContext.remove(traceKey); + } + + assertEquals(1, server.getReceivedEmailSize()); + final SmtpMessage email = server.getReceivedEmail().next(); + + assertEquals("fixed-value", email.getHeaderValue("X-Static")); + assertEquals(traceValue, email.getHeaderValue("X-Trace-Id")); + assertArrayEquals(new String[] {"first", "second"}, email.getHeaderValues("X-Tag")); + assertEquals("safe X-Evil: injected", email.getHeaderValue("X-Message")); + assertEquals(0, email.getHeaderValues("X-Evil").length); + assertEquals(0, email.getHeaderValues("X").length); + } } diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/SmtpManagerTest.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/SmtpManagerTest.java index 9db730ddf9..d30080acce 100644 --- a/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/SmtpManagerTest.java +++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/SmtpManagerTest.java @@ -17,7 +17,10 @@ package org.apache.logging.log4j.smtp; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import jakarta.mail.MessagingException; +import java.util.Arrays; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.appender.SmtpAppender; import org.apache.logging.log4j.core.async.RingBufferLogEvent; @@ -105,4 +108,37 @@ class SmtpManagerTest { message.set(text); return message; } + + @Test + void testEncodeHeaderValueLeavesPlainAsciiAlone() throws MessagingException { + assertEquals("plain value", SmtpManager.encodeHeaderValue("X-Test", "plain value")); + } + + @Test + void testEncodeHeaderValueNeutralizesControlCharacters() throws MessagingException { + assertEquals("safe X-Evil: injected", SmtpManager.encodeHeaderValue("X-Test", "safe\r\nX-Evil: injected")); + } + + @Test + void testEncodeHeaderValueIsAsciiOnly() throws MessagingException { + final String encoded = SmtpManager.encodeHeaderValue("X-Test", "Jos\u00e9 \u20b9500"); + assertThat(encoded.chars().allMatch(c -> c < 128)).isTrue(); + } + + @Test + void testEncodeHeaderValueRespectsLineLengthLimit() throws MessagingException { + final char[] chars = new char[5_000]; + Arrays.fill(chars, 'x'); + assertLineLengthLimit(SmtpManager.encodeHeaderValue("X-Test", new String(chars))); + Arrays.fill(chars, '\u00e9'); + assertLineLengthLimit(SmtpManager.encodeHeaderValue("X-Test", new String(chars))); + } + + private static void assertLineLengthLimit(final String encoded) { + int used = "X-Test".length() + 2; + for (final String line : encoded.split("\r\n", -1)) { + assertThat(used + line.length()).isLessThanOrEqualTo(998); + used = 0; + } + } } diff --git a/src/changelog/.2.x.x/3704_smtp_appender_custom_headers.xml b/src/changelog/.2.x.x/3704_smtp_appender_custom_headers.xml new file mode 100644 index 0000000000..1a45a21010 --- /dev/null +++ b/src/changelog/.2.x.x/3704_smtp_appender_custom_headers.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="added"> + <issue id="3704" link="https://github.com/apache/logging-log4j2/issues/3704"/> + <issue id="3704" link="https://github.com/apache/logging-log4j2/pull/4266"/> + <description format="asciidoc"> + Added support for custom `SmtpAppender` message headers, configured with nested `Property` elements. + </description> +</entry> \ No newline at end of file diff --git a/src/site/antora/modules/ROOT/pages/manual/appenders/network.adoc b/src/site/antora/modules/ROOT/pages/manual/appenders/network.adoc index 7425e72c1a..c738474949 100644 --- a/src/site/antora/modules/ROOT/pages/manual/appenders/network.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/appenders/network.adoc @@ -457,6 +457,9 @@ All the log events received by the appender are added to a cyclic log event buff If the filter accepts a message, an e-mail is sent. ==== +Custom e-mail headers can be configured using the nested +<<SmtpAppender-element-Property,`Property` elements>>. + [#SmtpAppender-attributes] .SMTP Appender configuration attributes [cols="1m,1,2,5"] @@ -633,6 +636,21 @@ The choice of the layout is also responsible for the `Content-Type` header of e- See xref:manual/layouts.adoc[] for more information. +| [[SmtpAppender-element-Property]]xref:plugin-reference.adoc#org-apache-logging-log4j_log4j-core_org-apache-logging-log4j-core-config-Property[`Property`] +| zero or more +a| +Additional e-mail headers to add to every message. + +Unlike the <<HttpAppender-element-Property,`Property` elements of the HTTP Appender>>, the values can contain +xref:manual/pattern-layout.adoc#converters[pattern converters] +and are evaluated against the log event that triggers the e-mail. + +Values are stripped of control characters, MIME-encoded and folded to comply with +https://datatracker.ietf.org/doc/html/rfc5322[RFC 5322], and headers with a name that is not a valid +https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.8[field name] are ignored. + +Repeating the same header name adds the header more than once. + | [[SmtpAppender-element-SslConfiguration]]<<SslConfiguration,`SSL`>> | zero or one |
