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
 |

Reply via email to