You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by rg...@apache.org on 2023/06/16 21:45:44 UTC

[logging-log4j2] branch main updated: Add Jakarta version of SMTP appender (#1519)

This is an automated email from the ASF dual-hosted git repository.

rgoers pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git


The following commit(s) were added to refs/heads/main by this push:
     new af6677b98e Add Jakarta version of SMTP appender (#1519)
af6677b98e is described below

commit af6677b98e030d1dc41f374503c13e461ed4f25e
Author: Piotr P. Karwasz <pi...@karwasz.org>
AuthorDate: Fri Jun 16 23:45:37 2023 +0200

    Add Jakarta version of SMTP appender (#1519)
---
 log4j-jakarta-smtp/pom.xml                         | 112 ++++++
 .../logging/log4j/smtp/MimeMessageBuilder.java     |  93 +++++
 .../logging/log4j/smtp/appender/SmtpAppender.java  | 337 +++++++++++++++++
 .../logging/log4j/smtp/appender/SmtpManager.java   | 407 +++++++++++++++++++++
 .../logging/dumbster/smtp/SimpleSmtpServer.java    | 279 ++++++++++++++
 .../logging/dumbster/smtp/SmtpActionType.java      | 202 ++++++++++
 .../apache/logging/dumbster/smtp/SmtpMessage.java  | 154 ++++++++
 .../apache/logging/dumbster/smtp/SmtpRequest.java  | 231 ++++++++++++
 .../apache/logging/dumbster/smtp/SmtpResponse.java |  75 ++++
 .../apache/logging/dumbster/smtp/SmtpState.java    | 121 ++++++
 .../org/apache/logging/dumbster/smtp/readme.txt    |   4 +
 .../log4j/smtp/appender/SmtpAppenderAsyncTest.java |  98 +++++
 .../log4j/smtp/appender/SmtpAppenderTest.java      | 178 +++++++++
 .../log4j/smtp/appender/SmtpManagerTest.java       |  85 +++++
 .../src/test/resources/SmtpAppenderAsyncTest.xml   |  42 +++
 pom.xml                                            |  37 +-
 16 files changed, 2444 insertions(+), 11 deletions(-)

diff --git a/log4j-jakarta-smtp/pom.xml b/log4j-jakarta-smtp/pom.xml
new file mode 100644
index 0000000000..1abba1631f
--- /dev/null
+++ b/log4j-jakarta-smtp/pom.xml
@@ -0,0 +1,112 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.logging.log4j</groupId>
+    <artifactId>log4j</artifactId>
+    <version>3.0.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>log4j-jakarta-smtp</artifactId>
+  <name>Apache Log4j Jakarta SMTP</name>
+  <description>Apache Log4j Simple Mail Transfer Protocol (SMTP) Appender, version for Jakarta EE 9.</description>
+  <properties>
+    <log4jParentDir>${basedir}/..</log4jParentDir>
+    <docLabel>Log4j SMTP Appender Documentation</docLabel>
+    <projectDir>/log4j-jakarta-smtp</projectDir>
+    <module.name>org.apache.logging.log4j.smtp</module.name>
+  </properties>
+
+  <dependencies>
+    <!-- Required for SMTPAppender -->
+    <dependency>
+      <groupId>jakarta.activation</groupId>
+      <artifactId>jakarta.activation-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>jakarta.mail</groupId>
+      <artifactId>jakarta.mail-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.logging.log4j</groupId>
+      <artifactId>log4j-api</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.logging.log4j</groupId>
+      <artifactId>log4j-core</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.angus</groupId>
+      <artifactId>angus-activation</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.angus</groupId>
+      <artifactId>jakarta.mail</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.logging.log4j</groupId>
+      <artifactId>log4j-api-test</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.logging.log4j</groupId>
+      <artifactId>log4j-core-test</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.lmax</groupId>
+      <artifactId>disruptor</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.hamcrest</groupId>
+      <artifactId>hamcrest</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-engine</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.vintage</groupId>
+      <artifactId>junit-vintage-engine</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.felix</groupId>
+        <artifactId>maven-bundle-plugin</artifactId>
+        <configuration>
+          <instructions>
+            <Fragment-Host>org.apache.logging.log4j.core</Fragment-Host>
+            <Export-Package>*</Export-Package>
+          </instructions>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/MimeMessageBuilder.java b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/MimeMessageBuilder.java
new file mode 100644
index 0000000000..f5065448d0
--- /dev/null
+++ b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/MimeMessageBuilder.java
@@ -0,0 +1,93 @@
+/*
+ * 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.
+ */
+package org.apache.logging.log4j.smtp;
+
+import java.nio.charset.StandardCharsets;
+
+import jakarta.mail.Message;
+import jakarta.mail.MessagingException;
+import jakarta.mail.Session;
+import jakarta.mail.internet.AddressException;
+import jakarta.mail.internet.InternetAddress;
+import jakarta.mail.internet.MimeMessage;
+
+import org.apache.logging.log4j.plugins.util.Builder;
+
+/**
+ * Builder for {@link MimeMessage} instances.
+ */
+public class MimeMessageBuilder implements Builder<MimeMessage> {
+    private final MimeMessage message;
+
+    public MimeMessageBuilder(final Session session) {
+        message = new MimeMessage(session);
+    }
+
+    public MimeMessageBuilder setFrom(final String from) throws MessagingException {
+        final InternetAddress address = parseAddress(from);
+
+        if (null != address) {
+            message.setFrom(address);
+        } else {
+            try {
+                message.setFrom();
+            } catch (final Exception ex) {
+                message.setFrom((InternetAddress) null);
+            }
+        }
+        return this;
+    }
+
+    public MimeMessageBuilder setReplyTo(final String replyTo) throws MessagingException {
+        final InternetAddress[] addresses = parseAddresses(replyTo);
+
+        if (null != addresses) {
+            message.setReplyTo(addresses);
+        }
+        return this;
+    }
+
+    public MimeMessageBuilder setRecipients(final Message.RecipientType recipientType, final String recipients)
+        throws MessagingException {
+        final InternetAddress[] addresses = parseAddresses(recipients);
+
+        if (null != addresses) {
+            message.setRecipients(recipientType, addresses);
+        }
+        return this;
+    }
+
+    public MimeMessageBuilder setSubject(final String subject) throws MessagingException {
+        if (subject != null) {
+            message.setSubject(subject, StandardCharsets.UTF_8.name());
+        }
+        return this;
+    }
+
+    @Override
+    public MimeMessage build() {
+        return message;
+    }
+
+    private static InternetAddress parseAddress(final String address) throws AddressException {
+        return address == null ? null : new InternetAddress(address);
+    }
+
+    private static InternetAddress[] parseAddresses(final String addresses) throws AddressException {
+        return addresses == null ? null : InternetAddress.parse(addresses, true);
+    }
+}
diff --git a/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/appender/SmtpAppender.java b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/appender/SmtpAppender.java
new file mode 100644
index 0000000000..5b6e90f79c
--- /dev/null
+++ b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/appender/SmtpAppender.java
@@ -0,0 +1,337 @@
+/*
+ * 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.
+ */
+package org.apache.logging.log4j.smtp.appender;
+
+import org.apache.logging.log4j.core.Appender;
+import org.apache.logging.log4j.core.Filter;
+import org.apache.logging.log4j.core.Layout;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.DefaultConfiguration;
+import org.apache.logging.log4j.core.config.Property;
+import org.apache.logging.log4j.core.filter.ThresholdFilter;
+import org.apache.logging.log4j.core.layout.HtmlLayout;
+import org.apache.logging.log4j.core.net.ssl.SslConfiguration;
+import org.apache.logging.log4j.core.util.Booleans;
+import org.apache.logging.log4j.plugins.Configurable;
+import org.apache.logging.log4j.plugins.Plugin;
+import org.apache.logging.log4j.plugins.PluginAttribute;
+import org.apache.logging.log4j.plugins.PluginElement;
+import org.apache.logging.log4j.plugins.PluginFactory;
+import org.apache.logging.log4j.plugins.validation.constraints.ValidPort;
+
+/**
+ * Send an e-mail when a specific logging event occurs, typically on errors or
+ * fatal errors.
+ *
+ * <p>
+ * The number of logging events delivered in this e-mail depend on the value of
+ * <b>BufferSize</b> option. The <code>SmtpAppender</code> keeps only the last
+ * <code>BufferSize</code> logging events in its cyclic buffer. This keeps
+ * memory requirements at a reasonable level while still delivering useful
+ * application context.
+ *
+ * By default, an email message will formatted as HTML. This can be modified by
+ * setting a layout for the appender.
+ *
+ * By default, an email message will be sent when an ERROR or higher severity
+ * message is appended. This can be modified by setting a filter for the
+ * appender.
+ */
+@Configurable(elementType = Appender.ELEMENT_TYPE, printObject = true)
+@Plugin("SMTP")
+public final class SmtpAppender extends AbstractAppender {
+
+    private static final int DEFAULT_BUFFER_SIZE = 512;
+
+    /** The SMTP Manager */
+    private final SmtpManager manager;
+
+    private SmtpAppender(final String name, final Filter filter, final Layout layout, final boolean ignoreExceptions,
+                         Property[] properties, final SmtpManager manager) {
+        super(name, filter, layout, ignoreExceptions, properties);
+        this.manager = manager;
+    }
+
+    /**
+     * @since 2.13.2
+     */
+    public static class Builder extends AbstractAppender.Builder<Builder>
+            implements org.apache.logging.log4j.plugins.util.Builder<SmtpAppender> {
+        @PluginAttribute
+        private String to;
+
+        @PluginAttribute
+        private String cc;
+
+        @PluginAttribute
+        private String bcc;
+
+        @PluginAttribute
+        private String from;
+
+        @PluginAttribute
+        private String replyTo;
+
+        @PluginAttribute
+        private String subject;
+
+        @PluginAttribute
+        private String smtpProtocol = "smtp";
+
+        @PluginAttribute
+        private String smtpHost;
+
+        @PluginAttribute
+        @ValidPort
+        private int smtpPort;
+
+        @PluginAttribute
+        private String smtpUsername;
+
+        @PluginAttribute(sensitive = true)
+        private String smtpPassword;
+
+        @PluginAttribute
+        private boolean smtpDebug;
+
+        @PluginAttribute
+        private int bufferSize = DEFAULT_BUFFER_SIZE;
+
+        @PluginElement("SSL")
+        private SslConfiguration sslConfiguration;
+
+        /**
+         * Comma-separated list of recipient email addresses.
+         */
+        public Builder setTo(final String to) {
+            this.to = to;
+            return this;
+        }
+
+        /**
+         * Comma-separated list of CC email addresses.
+         */
+        public Builder setCc(final String cc) {
+            this.cc = cc;
+            return this;
+        }
+
+        /**
+         * Comma-separated list of BCC email addresses.
+         */
+        public Builder setBcc(final String bcc) {
+            this.bcc = bcc;
+            return this;
+        }
+
+        /**
+         * Email address of the sender.
+         */
+        public Builder setFrom(final String from) {
+            this.from = from;
+            return this;
+        }
+
+        /**
+         * Comma-separated list of Reply-To email addresses.
+         */
+        public Builder setReplyTo(final String replyTo) {
+            this.replyTo = replyTo;
+            return this;
+        }
+
+        /**
+         * Subject template for the email messages.
+         * @see org.apache.logging.log4j.core.layout.PatternLayout
+         */
+        public Builder setSubject(final String subject) {
+            this.subject = subject;
+            return this;
+        }
+
+        /**
+         * Transport protocol to use for SMTP such as "smtp" or "smtps". Defaults to "smtp".
+         */
+        public Builder setSmtpProtocol(final String smtpProtocol) {
+            this.smtpProtocol = smtpProtocol;
+            return this;
+        }
+
+        /**
+         * Host name of SMTP server to send messages through.
+         */
+        public Builder setSmtpHost(final String smtpHost) {
+            this.smtpHost = smtpHost;
+            return this;
+        }
+
+        /**
+         * Port number of SMTP server to send messages through.
+         */
+        public Builder setSmtpPort(final int smtpPort) {
+            this.smtpPort = smtpPort;
+            return this;
+        }
+
+        /**
+         * Username to authenticate with SMTP server.
+         */
+        public Builder setSmtpUsername(final String smtpUsername) {
+            this.smtpUsername = smtpUsername;
+            return this;
+        }
+
+        /**
+         * Password to authenticate with SMTP server.
+         */
+        public Builder setSmtpPassword(final String smtpPassword) {
+            this.smtpPassword = smtpPassword;
+            return this;
+        }
+
+        /**
+         * Enables or disables mail session debugging on STDOUT. Disabled by default.
+         */
+        public Builder setSmtpDebug(final boolean smtpDebug) {
+            this.smtpDebug = smtpDebug;
+            return this;
+        }
+
+        /**
+         * Number of log events to buffer before sending an email. Defaults to {@value #DEFAULT_BUFFER_SIZE}.
+         */
+        public Builder setBufferSize(final int bufferSize) {
+            this.bufferSize = bufferSize;
+            return this;
+        }
+
+        /**
+         * Specifies an SSL configuration for smtps connections.
+         */
+        public Builder setSslConfiguration(final SslConfiguration sslConfiguration) {
+            this.sslConfiguration = sslConfiguration;
+            return this;
+        }
+
+        /**
+         * Specifies the layout used for the email message body. By default, this uses the
+         * {@linkplain HtmlLayout#createDefaultLayout() default HTML layout}.
+         */
+        @Override
+        public Builder setLayout(final Layout layout) {
+            return super.setLayout(layout);
+        }
+
+        /**
+         * Specifies the filter used for this appender. By default, uses a {@link ThresholdFilter} with a level of
+         * ERROR.
+         */
+        @Override
+        public Builder setFilter(final Filter filter) {
+            return super.setFilter(filter);
+        }
+
+        @Override
+        public SmtpAppender build() {
+            if (getLayout() == null) {
+                setLayout(HtmlLayout.createDefaultLayout());
+            }
+            if (getFilter() == null) {
+                setFilter(ThresholdFilter.createFilter(null, null, null));
+            }
+            final SmtpManager smtpManager = SmtpManager.getSmtpManager(getConfiguration(), to, cc, bcc, from, replyTo,
+                    subject, smtpProtocol, smtpHost, smtpPort, smtpUsername, smtpPassword, smtpDebug,
+                    getFilter().toString(), bufferSize, sslConfiguration);
+            return new SmtpAppender(getName(), getFilter(), getLayout(), isIgnoreExceptions(), getPropertyArray(), smtpManager);
+        }
+    }
+
+    /**
+     * @since 2.13.2
+     */
+    @PluginFactory
+    public static Builder newBuilder() {
+        return new Builder();
+    }
+
+    /**
+     * Create a SmtpAppender.
+     * @deprecated Use {@link #newBuilder()} to create and configure a {@link Builder} instance.
+     * @see Builder
+     */
+    public static SmtpAppender createAppender(final Configuration config, final String name, 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 String smtpPortStr,
+                                              final String smtpUsername, final String smtpPassword,
+                                              final String smtpDebug, final String bufferSizeStr,
+                                              Layout layout, Filter filter,
+                                              final String ignore) {
+        if (name == null) {
+            LOGGER.error("No name provided for SmtpAppender");
+            return null;
+        }
+
+        final boolean ignoreExceptions = Booleans.parseBoolean(ignore, true);
+        final int smtpPort = AbstractAppender.parseInt(smtpPortStr, 0);
+        final boolean isSmtpDebug = Boolean.parseBoolean(smtpDebug);
+        final int bufferSize = bufferSizeStr == null ? DEFAULT_BUFFER_SIZE : Integer.parseInt(bufferSizeStr);
+
+        if (layout == null) {
+            layout = HtmlLayout.createDefaultLayout();
+        }
+        if (filter == null) {
+            filter = ThresholdFilter.createFilter(null, null, null);
+        }
+        final Configuration configuration = config != null ? config : new DefaultConfiguration();
+
+        final SmtpManager manager = SmtpManager.getSmtpManager(configuration, to, cc, bcc, from, replyTo, subject, smtpProtocol,
+            smtpHost, smtpPort, smtpUsername, smtpPassword, isSmtpDebug, filter.toString(),  bufferSize, null);
+        if (manager == null) {
+            return null;
+        }
+
+        return new SmtpAppender(name, filter, layout, ignoreExceptions, Property.EMPTY_ARRAY, manager);
+    }
+
+    /**
+     * Capture all events in CyclicBuffer.
+     * @param event The Log event.
+     * @return true if the event should be filtered.
+     */
+    @Override
+    public boolean isFiltered(final LogEvent event) {
+        final boolean filtered = super.isFiltered(event);
+        if (filtered) {
+            manager.add(event);
+        }
+        return filtered;
+    }
+
+    /**
+     * Perform SmtpAppender specific appending actions, mainly adding the event
+     * to a cyclic buffer and checking if the event triggers an e-mail to be
+     * sent.
+     * @param event The Log event.
+     */
+    @Override
+    public void append(final LogEvent event) {
+        manager.sendEvents(getLayout(), event);
+    }
+}
diff --git a/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/appender/SmtpManager.java b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/appender/SmtpManager.java
new file mode 100644
index 0000000000..6c48e0c355
--- /dev/null
+++ b/log4j-jakarta-smtp/src/main/java/org/apache/logging/log4j/smtp/appender/SmtpManager.java
@@ -0,0 +1,407 @@
+/*
+ * 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.
+ */
+package org.apache.logging.log4j.smtp.appender;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Date;
+import java.util.Properties;
+
+import javax.net.ssl.SSLSocketFactory;
+
+import jakarta.activation.DataSource;
+import jakarta.mail.Authenticator;
+import jakarta.mail.Message;
+import jakarta.mail.MessagingException;
+import jakarta.mail.PasswordAuthentication;
+import jakarta.mail.Session;
+import jakarta.mail.Transport;
+import jakarta.mail.internet.InternetHeaders;
+import jakarta.mail.internet.MimeBodyPart;
+import jakarta.mail.internet.MimeMessage;
+import jakarta.mail.internet.MimeMultipart;
+import jakarta.mail.internet.MimeUtility;
+import jakarta.mail.util.ByteArrayDataSource;
+
+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.appender.AbstractManager;
+import org.apache.logging.log4j.core.appender.ManagerFactory;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.layout.AbstractStringLayout.Serializer;
+import org.apache.logging.log4j.core.layout.PatternLayout;
+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.smtp.MimeMessageBuilder;
+import org.apache.logging.log4j.util.PropertiesUtil;
+import org.apache.logging.log4j.util.Strings;
+
+/**
+ * Manager for sending SMTP events.
+ */
+public class SmtpManager extends AbstractManager {
+    private static final SMTPManagerFactory FACTORY = new SMTPManagerFactory();
+
+    private final Session session;
+
+    private final CyclicBuffer<LogEvent> buffer;
+
+    private volatile MimeMessage message;
+
+    private final FactoryData data;
+
+    private static MimeMessage createMimeMessage(final FactoryData data, final Session session, final LogEvent appendEvent)
+            throws MessagingException {
+        return new MimeMessageBuilder(session).setFrom(data.from).setReplyTo(data.replyto)
+                .setRecipients(Message.RecipientType.TO, data.to).setRecipients(Message.RecipientType.CC, data.cc)
+                .setRecipients(Message.RecipientType.BCC, data.bcc).setSubject(data.subject.toSerializable(appendEvent))
+                .build();
+    }
+
+    protected SmtpManager(final String name, final Session session, final MimeMessage message,
+                          final FactoryData data) {
+        super(null, name);
+        this.session = session;
+        this.message = message;
+        this.data = data;
+        this.buffer = new CyclicBuffer<>(LogEvent.class, data.numElements);
+    }
+
+    public void add(final LogEvent event) {
+        buffer.add(event.toImmutable());
+    }
+
+    public static SmtpManager getSmtpManager(
+                                             final Configuration config,
+                                             final String to, final String cc, final String bcc,
+                                             final String from, final String replyTo,
+                                             final String subject, String protocol, final String host,
+                                             final int port, final String username, final String password,
+                                             final boolean isDebug, final String filterName, final int numElements,
+                                             final SslConfiguration sslConfiguration) {
+        if (Strings.isEmpty(protocol)) {
+            protocol = "smtp";
+        }
+
+        final String name = createManagerName(to, cc, bcc, from, replyTo, subject, protocol, host, port, username, isDebug, filterName);
+        final Serializer subjectSerializer = PatternLayout.newSerializerBuilder().setConfiguration(config).setPattern(subject).build();
+
+        return getManager(name, FACTORY, new FactoryData(to, cc, bcc, from, replyTo, subjectSerializer,
+            protocol, host, port, username, password, isDebug, numElements, sslConfiguration));
+
+    }
+
+    /**
+     * 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
+     */
+    static String createManagerName(
+            final String to,
+            final String cc,
+            final String bcc,
+            final String from,
+            final String replyTo,
+            final String subject,
+            final String protocol,
+            final String host,
+            final int port,
+            final String username,
+            final boolean isDebug,
+            final String filterName) {
+
+        final StringBuilder sb = new StringBuilder();
+
+        if (to != null) {
+            sb.append(to);
+        }
+        sb.append(':');
+        if (cc != null) {
+            sb.append(cc);
+        }
+        sb.append(':');
+        if (bcc != null) {
+            sb.append(bcc);
+        }
+        sb.append(':');
+        if (from != null) {
+            sb.append(from);
+        }
+        sb.append(':');
+        if (replyTo != null) {
+            sb.append(replyTo);
+        }
+        sb.append(':');
+        if (subject != null) {
+            sb.append(subject);
+        }
+        sb.append(':');
+        sb.append(protocol).append(':').append(host).append(':').append(port).append(':');
+        if (username != null) {
+            sb.append(username);
+        }
+        sb.append(isDebug ? ":debug:" : "::");
+        sb.append(filterName);
+
+        return "SMTP:" + sb.toString();
+    }
+
+    /**
+     * Send the contents of the cyclic buffer as an e-mail message.
+     * @param layout The layout for formatting the events.
+     * @param appendEvent The event that triggered the send.
+     */
+    public void sendEvents(final Layout layout, final LogEvent appendEvent) {
+        if (message == null) {
+            connect(appendEvent);
+        }
+        try {
+            final LogEvent[] priorEvents = removeAllBufferedEvents();
+            // LOG4J-310: log appendEvent even if priorEvents is empty
+
+            final byte[] rawBytes = formatContentToBytes(priorEvents, appendEvent, layout);
+
+            final String contentType = layout.getContentType();
+            final String encoding = getEncoding(rawBytes, contentType);
+            final byte[] encodedBytes = encodeContentToBytes(rawBytes, encoding);
+
+            final InternetHeaders headers = getHeaders(contentType, encoding);
+            final MimeMultipart mp = getMimeMultipart(encodedBytes, headers);
+
+            final String subject = data.subject.toSerializable(appendEvent);
+
+            sendMultipartMessage(message, mp, subject);
+        } catch (final MessagingException | IOException | RuntimeException e) {
+            logError("Caught exception while sending e-mail notification.", e);
+            throw new LoggingException("Error occurred while sending email", e);
+        }
+    }
+
+    LogEvent[] removeAllBufferedEvents() {
+        return buffer.removeAll();
+    }
+
+    protected byte[] formatContentToBytes(final LogEvent[] priorEvents, final LogEvent appendEvent,
+                                          final Layout layout) throws IOException {
+        final ByteArrayOutputStream raw = new ByteArrayOutputStream();
+        writeContent(priorEvents, appendEvent, layout, raw);
+        return raw.toByteArray();
+    }
+
+    private void writeContent(final LogEvent[] priorEvents, final LogEvent appendEvent, final Layout layout,
+                              final ByteArrayOutputStream out)
+        throws IOException {
+        writeHeader(layout, out);
+        writeBuffer(priorEvents, appendEvent, layout, out);
+        writeFooter(layout, out);
+    }
+
+    protected void writeHeader(final Layout layout, final OutputStream out) throws IOException {
+        final byte[] header = layout.getHeader();
+        if (header != null) {
+            out.write(header);
+        }
+    }
+
+    protected void writeBuffer(final LogEvent[] priorEvents, final LogEvent appendEvent, final Layout layout,
+                               final OutputStream out) throws IOException {
+        for (final LogEvent priorEvent : priorEvents) {
+            final byte[] bytes = layout.toByteArray(priorEvent);
+            out.write(bytes);
+        }
+
+        final byte[] bytes = layout.toByteArray(appendEvent);
+        out.write(bytes);
+    }
+
+    protected void writeFooter(final Layout layout, final OutputStream out) throws IOException {
+        final byte[] footer = layout.getFooter();
+        if (footer != null) {
+            out.write(footer);
+        }
+    }
+
+    protected String getEncoding(final byte[] rawBytes, final String contentType) {
+        final DataSource dataSource = new ByteArrayDataSource(rawBytes, contentType);
+        return MimeUtility.getEncoding(dataSource);
+    }
+
+    protected byte[] encodeContentToBytes(final byte[] rawBytes, final String encoding)
+        throws MessagingException, IOException {
+        final ByteArrayOutputStream encoded = new ByteArrayOutputStream();
+        encodeContent(rawBytes, encoding, encoded);
+        return encoded.toByteArray();
+    }
+
+    protected void encodeContent(final byte[] bytes, final String encoding, final ByteArrayOutputStream out)
+            throws MessagingException, IOException {
+        try (final OutputStream encoder = MimeUtility.encode(out, encoding)) {
+            encoder.write(bytes);
+        }
+    }
+
+    protected InternetHeaders getHeaders(final String contentType, final String encoding) {
+        final InternetHeaders headers = new InternetHeaders();
+        headers.setHeader("Content-Type", contentType + "; charset=UTF-8");
+        headers.setHeader("Content-Transfer-Encoding", encoding);
+        return headers;
+    }
+
+    protected MimeMultipart getMimeMultipart(final byte[] encodedBytes, final InternetHeaders headers)
+        throws MessagingException {
+        final MimeMultipart mp = new MimeMultipart();
+        final MimeBodyPart part = new MimeBodyPart(headers, encodedBytes);
+        mp.addBodyPart(part);
+        return mp;
+    }
+
+    /**
+     * @deprecated Please use the {@link #sendMultipartMessage(MimeMessage, MimeMultipart, String)} method instead.
+     */
+    @Deprecated
+    protected void sendMultipartMessage(final MimeMessage msg, final MimeMultipart mp) throws MessagingException {
+        synchronized (msg) {
+            msg.setContent(mp);
+            msg.setSentDate(new Date());
+            Transport.send(msg);
+        }
+    }
+
+    protected void sendMultipartMessage(final MimeMessage msg, final MimeMultipart mp, final String subject) throws MessagingException {
+        synchronized (msg) {
+            msg.setContent(mp);
+            msg.setSentDate(new Date());
+            msg.setSubject(subject);
+            Transport.send(msg);
+        }
+    }
+
+    /**
+     * Factory data.
+     */
+    private static class FactoryData {
+        private final String to;
+        private final String cc;
+        private final String bcc;
+        private final String from;
+        private final String replyto;
+        private final Serializer subject;
+        private final String protocol;
+        private final String host;
+        private final int port;
+        private final String username;
+        private final String password;
+        private final boolean isDebug;
+        private final int numElements;
+        private final SslConfiguration sslConfiguration;
+
+        public FactoryData(final String to, final String cc, final String bcc, final String from, final String replyTo,
+                           final Serializer subjectSerializer, final String protocol, final String host, final int port,
+                           final String username, final String password, final boolean isDebug, final int numElements,
+                           final SslConfiguration sslConfiguration) {
+            this.to = to;
+            this.cc = cc;
+            this.bcc = bcc;
+            this.from = from;
+            this.replyto = replyTo;
+            this.subject = subjectSerializer;
+            this.protocol = protocol;
+            this.host = host;
+            this.port = port;
+            this.username = username;
+            this.password = password;
+            this.isDebug = isDebug;
+            this.numElements = numElements;
+            this.sslConfiguration = sslConfiguration;
+        }
+    }
+
+    private synchronized void connect(final LogEvent appendEvent) {
+        if (message != null) {
+            return;
+        }
+        try {
+            message = createMimeMessage(data, session, appendEvent);
+        } catch (final MessagingException e) {
+            logError("Could not set SmtpAppender message options", e);
+            message = null;
+        }
+    }
+
+    /**
+     * Factory to create the SMTP Manager.
+     */
+    private static class SMTPManagerFactory implements ManagerFactory<SmtpManager, FactoryData> {
+
+        @Override
+        public SmtpManager createManager(final String name, final FactoryData data) {
+            final String prefix = "mail." + data.protocol;
+
+            final Properties properties = PropertiesUtil.getSystemProperties();
+            properties.setProperty("mail.transport.protocol", data.protocol);
+            if (properties.getProperty("mail.host") == null) {
+                // Prevent an UnknownHostException in Java 7
+                properties.setProperty("mail.host", NetUtils.getLocalHostname());
+            }
+
+            if (null != data.host) {
+                properties.setProperty(prefix + ".host", data.host);
+            }
+            if (data.port > 0) {
+                properties.setProperty(prefix + ".port", String.valueOf(data.port));
+            }
+
+            final Authenticator authenticator = buildAuthenticator(data.username, data.password);
+            if (null != authenticator) {
+                properties.setProperty(prefix + ".auth", "true");
+            }
+
+            if (data.protocol.equals("smtps")) {
+                final SslConfiguration sslConfiguration = data.sslConfiguration;
+                if (sslConfiguration != null) {
+                    final SSLSocketFactory sslSocketFactory = sslConfiguration.getSslSocketFactory();
+                    properties.put(prefix + ".ssl.socketFactory", sslSocketFactory);
+                    properties.setProperty(prefix + ".ssl.checkserveridentity", Boolean.toString(sslConfiguration.isVerifyHostName()));
+                }
+            }
+
+            final Session session = Session.getInstance(properties, authenticator);
+            session.setProtocolForAddress("rfc822", data.protocol);
+            session.setDebug(data.isDebug);
+            return new SmtpManager(name, session, null, data);
+        }
+
+        private Authenticator buildAuthenticator(final String username, final String password) {
+            if (null != password && null != username) {
+                return new Authenticator() {
+                    private final PasswordAuthentication passwordAuthentication =
+                        new PasswordAuthentication(username, password);
+
+                    @Override
+                    protected PasswordAuthentication getPasswordAuthentication() {
+                        return passwordAuthentication;
+                    }
+                };
+            }
+            return null;
+        }
+    }
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SimpleSmtpServer.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SimpleSmtpServer.java
new file mode 100644
index 0000000000..d96b1c5dec
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SimpleSmtpServer.java
@@ -0,0 +1,279 @@
+/*
+ * 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.
+ */
+package org.apache.logging.dumbster.smtp;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.logging.log4j.util.Strings;
+
+/**
+ * Dummy SMTP server for testing purposes.
+ *
+ * @todo constructor allowing user to pass preinitialized ServerSocket
+ */
+public class SimpleSmtpServer implements Runnable {
+    /**
+     * Stores all of the email received since this instance started up.
+     */
+    private final List<SmtpMessage> receivedMail;
+
+    /**
+     * Default SMTP port is 25.
+     */
+    public static final int DEFAULT_SMTP_PORT = 25;
+
+    /**
+     * Indicates whether this server is stopped or not.
+     */
+    private volatile boolean stopped = true;
+
+    /**
+     * Handle to the server socket this server listens to.
+     */
+    private ServerSocket serverSocket;
+
+    /**
+     * Port the server listens on - set to the default SMTP port initially.
+     */
+    private int port = DEFAULT_SMTP_PORT;
+
+    /**
+     * Timeout listening on server socket.
+     */
+    private static final int TIMEOUT = 500;
+
+    /**
+     * Constructor.
+     *
+     * @param port port number
+     */
+    public SimpleSmtpServer(final int port) {
+        receivedMail = new ArrayList<>();
+        this.port = port;
+    }
+
+    /**
+     * Main loop of the SMTP server.
+     */
+    @Override
+    public void run() {
+        stopped = false;
+        try {
+            try {
+                serverSocket = new ServerSocket(port);
+                serverSocket.setSoTimeout(TIMEOUT); // Block for maximum of 1.5 seconds
+            } finally {
+                synchronized (this) {
+                    // Notify when server socket has been created
+                    notifyAll();
+                }
+            }
+
+            // Server: loop until stopped
+            while (!isStopped()) {
+                // Start server socket and listen for client connections
+                Socket socket = null;
+                try {
+                    socket = serverSocket.accept();
+                } catch (final Exception e) {
+                    if (socket != null) {
+                        socket.close();
+                    }
+                    continue; // Non-blocking socket timeout occurred: try accept() again
+                }
+
+                // Get the input and output streams
+                final BufferedReader input = new BufferedReader(new InputStreamReader(socket.getInputStream()));
+                final PrintWriter out = new PrintWriter(socket.getOutputStream());
+
+                synchronized (this) {
+                    /*
+                     * We synchronize over the handle method and the list update because the client call completes
+                     * inside the handle method and we have to prevent the client from reading the list until we've
+                     * updated it. For higher concurrency, we could just change handle to return void and update the
+                     * list inside the method to limit the duration that we hold the lock.
+                     */
+                    final List<SmtpMessage> msgs = handleTransaction(out, input);
+                    receivedMail.addAll(msgs);
+                }
+                socket.close();
+            }
+        } catch (final Exception e) {
+            /** @todo Should throw an appropriate exception here. */
+            e.printStackTrace();
+        } finally {
+            if (serverSocket != null) {
+                try {
+                    serverSocket.close();
+                } catch (final IOException e) {
+                    e.printStackTrace();
+                }
+            }
+        }
+    }
+
+    /**
+     * Check if the server has been placed in a stopped state. Allows another thread to
+     * stop the server safely.
+     *
+     * @return true if the server has been sent a stop signal, false otherwise
+     */
+    public synchronized boolean isStopped() {
+        return stopped;
+    }
+
+    /**
+     * Stops the server. Server is shutdown after processing of the current request is complete.
+     */
+    public synchronized void stop() {
+        // Mark us closed
+        stopped = true;
+        try {
+            // Kick the server accept loop
+            serverSocket.close();
+        } catch (final IOException e) {
+            // Ignore
+        }
+    }
+
+    /**
+     * Handle an SMTP transaction, i.e. all activity between initial connect and QUIT command.
+     *
+     * @param out   output stream
+     * @param input input stream
+     * @return List of SmtpMessage
+     * @throws IOException
+     */
+    private List<SmtpMessage> handleTransaction(final PrintWriter out, final BufferedReader input) throws IOException {
+        // Initialize the state machine
+        SmtpState smtpState = SmtpState.CONNECT;
+        final SmtpRequest smtpRequest = new SmtpRequest(SmtpActionType.CONNECT, Strings.EMPTY, smtpState);
+
+        // Execute the connection request
+        final SmtpResponse smtpResponse = smtpRequest.execute();
+
+        // Send initial response
+        sendResponse(out, smtpResponse);
+        smtpState = smtpResponse.getNextState();
+
+        final List<SmtpMessage> msgList = new ArrayList<>();
+        SmtpMessage msg = new SmtpMessage();
+
+        while (smtpState != SmtpState.CONNECT) {
+            final String line = input.readLine();
+
+            if (line == null) {
+                break;
+            }
+
+            // Create request from client input and current state
+            final SmtpRequest request = SmtpRequest.createRequest(line, smtpState);
+            // Execute request and create response object
+            final SmtpResponse response = request.execute();
+            // Move to next internal state
+            smtpState = response.getNextState();
+            // Send response to client
+            sendResponse(out, response);
+
+            // Store input in message
+            final String params = request.getParams();
+            msg.store(response, params);
+
+            // If message reception is complete save it
+            if (smtpState == SmtpState.QUIT) {
+                msgList.add(msg);
+                msg = new SmtpMessage();
+            }
+        }
+
+        return msgList;
+    }
+
+    /**
+     * Send response to client.
+     *
+     * @param out          socket output stream
+     * @param smtpResponse response object
+     */
+    private static void sendResponse(final PrintWriter out, final SmtpResponse smtpResponse) {
+        if (smtpResponse.getCode() > 0) {
+            final int code = smtpResponse.getCode();
+            final String message = smtpResponse.getMessage();
+            out.print(code + " " + message + "\r\n");
+            out.flush();
+        }
+    }
+
+    /**
+     * Get email received by this instance since start up.
+     *
+     * @return List of String
+     */
+    public synchronized Iterator<SmtpMessage> getReceivedEmail() {
+        return receivedMail.iterator();
+    }
+
+    /**
+     * Get the number of messages received.
+     *
+     * @return size of received email list
+     */
+    public synchronized int getReceivedEmailSize() {
+        return receivedMail.size();
+    }
+
+    /**
+     * Creates an instance of SimpleSmtpServer and starts it. Will listen on the default port.
+     *
+     * @return a reference to the SMTP server
+     */
+    public static SimpleSmtpServer start() {
+        return start(DEFAULT_SMTP_PORT);
+    }
+
+    /**
+     * Creates an instance of SimpleSmtpServer and starts it.
+     *
+     * @param port port number the server should listen to
+     * @return a reference to the SMTP server
+     */
+    public static SimpleSmtpServer start(final int port) {
+        final SimpleSmtpServer server = new SimpleSmtpServer(port);
+        final Thread t = new Thread(server);
+
+
+        // Block until the server socket is created
+        synchronized (server) {
+            t.start();
+            try {
+                server.wait();
+            } catch (final InterruptedException e) {
+                // Ignore don't care.
+            }
+        }
+        return server;
+    }
+
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpActionType.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpActionType.java
new file mode 100644
index 0000000000..b7245b435c
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpActionType.java
@@ -0,0 +1,202 @@
+/*
+ * 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.
+ */
+package org.apache.logging.dumbster.smtp;
+
+/**
+ * Represents an SMTP action or command.
+ */
+public final class SmtpActionType {
+    /**
+     * Internal value for the action type.
+     */
+    private final byte value;
+
+    /**
+     * Internal representation of the CONNECT action.
+     */
+    private static final byte CONNECT_BYTE = (byte) 1;
+    /**
+     * Internal representation of the EHLO action.
+     */
+    private static final byte EHLO_BYTE = (byte) 2;
+    /**
+     * Internal representation of the MAIL FROM action.
+     */
+    private static final byte MAIL_BYTE = (byte) 3;
+    /**
+     * Internal representation of the RCPT action.
+     */
+    private static final byte RCPT_BYTE = (byte) 4;
+    /**
+     * Internal representation of the DATA action.
+     */
+    private static final byte DATA_BYTE = (byte) 5;
+    /**
+     * Internal representation of the DATA END (.) action.
+     */
+    private static final byte DATA_END_BYTE = (byte) 6;
+    /**
+     * Internal representation of the QUIT action.
+     */
+    private static final byte QUIT_BYTE = (byte) 7;
+    /**
+     * Internal representation of an unrecognized action: body text gets this action type.
+     */
+    private static final byte UNREC_BYTE = (byte) 8;
+    /**
+     * Internal representation of the blank line action: separates headers and body text.
+     */
+    private static final byte BLANK_LINE_BYTE = (byte) 9;
+
+    /**
+     * Internal representation of the stateless RSET action.
+     */
+    private static final byte RSET_BYTE = (byte) -1;
+    /**
+     * Internal representation of the stateless VRFY action.
+     */
+    private static final byte VRFY_BYTE = (byte) -2;
+    /**
+     * Internal representation of the stateless EXPN action.
+     */
+    private static final byte EXPN_BYTE = (byte) -3;
+    /**
+     * Internal representation of the stateless HELP action.
+     */
+    private static final byte HELP_BYTE = (byte) -4;
+    /**
+     * Internal representation of the stateless NOOP action.
+     */
+    private static final byte NOOP_BYTE = (byte) -5;
+
+    /**
+     * CONNECT action.
+     */
+    public static final SmtpActionType CONNECT = new SmtpActionType(CONNECT_BYTE);
+    /**
+     * EHLO action.
+     */
+    public static final SmtpActionType EHLO = new SmtpActionType(EHLO_BYTE);
+    /**
+     * MAIL action.
+     */
+    public static final SmtpActionType MAIL = new SmtpActionType(MAIL_BYTE);
+    /**
+     * RCPT action.
+     */
+    public static final SmtpActionType RCPT = new SmtpActionType(RCPT_BYTE);
+    /**
+     * DATA action.
+     */
+    public static final SmtpActionType DATA = new SmtpActionType(DATA_BYTE);
+    /**
+     * "." action.
+     */
+    public static final SmtpActionType DATA_END = new SmtpActionType(DATA_END_BYTE);
+    /**
+     * Body text action.
+     */
+    public static final SmtpActionType UNRECOG = new SmtpActionType(UNREC_BYTE);
+    /**
+     * QUIT action.
+     */
+    public static final SmtpActionType QUIT = new SmtpActionType(QUIT_BYTE);
+    /**
+     * Header/body separator action.
+     */
+    public static final SmtpActionType BLANK_LINE = new SmtpActionType(BLANK_LINE_BYTE);
+
+    /**
+     * Stateless RSET action.
+     */
+    public static final SmtpActionType RSET = new SmtpActionType(RSET_BYTE);
+    /**
+     * Stateless VRFY action.
+     */
+    public static final SmtpActionType VRFY = new SmtpActionType(VRFY_BYTE);
+    /**
+     * Stateless EXPN action.
+     */
+    public static final SmtpActionType EXPN = new SmtpActionType(EXPN_BYTE);
+    /**
+     * Stateless HELP action.
+     */
+    public static final SmtpActionType HELP = new SmtpActionType(HELP_BYTE);
+    /**
+     * Stateless NOOP action.
+     */
+    public static final SmtpActionType NOOP = new SmtpActionType(NOOP_BYTE);
+
+    /**
+     * Create a new SMTP action type. Private to ensure no invalid values.
+     *
+     * @param value one of the _BYTE values
+     */
+    private SmtpActionType(final byte value) {
+        this.value = value;
+    }
+
+    /**
+     * Indicates whether the action is stateless or not.
+     *
+     * @return true iff the action is stateless
+     */
+    public boolean isStateless() {
+        return value < 0;
+    }
+
+    /**
+     * String representation of this SMTP action type.
+     *
+     * @return a String
+     */
+    @Override
+    public String toString() {
+        switch (value) {
+            case CONNECT_BYTE:
+                return "Connect";
+            case EHLO_BYTE:
+                return "EHLO";
+            case MAIL_BYTE:
+                return "MAIL";
+            case RCPT_BYTE:
+                return "RCPT";
+            case DATA_BYTE:
+                return "DATA";
+            case DATA_END_BYTE:
+                return ".";
+            case QUIT_BYTE:
+                return "QUIT";
+            case RSET_BYTE:
+                return "RSET";
+            case VRFY_BYTE:
+                return "VRFY";
+            case EXPN_BYTE:
+                return "EXPN";
+            case HELP_BYTE:
+                return "HELP";
+            case NOOP_BYTE:
+                return "NOOP";
+            case UNREC_BYTE:
+                return "Unrecognized command / data";
+            case BLANK_LINE_BYTE:
+                return "Blank line";
+            default:
+                return "Unknown";
+        }
+    }
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpMessage.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpMessage.java
new file mode 100644
index 0000000000..e21b6ba205
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpMessage.java
@@ -0,0 +1,154 @@
+/*
+ * 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.
+ */
+package org.apache.logging.dumbster.smtp;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Container for a complete SMTP message - headers and message body.
+ */
+public class SmtpMessage {
+    /**
+     * Headers: Map of List of String hashed on header name.
+     */
+    private final Map<String, List<String>> headers;
+    /**
+     * Message body.
+     */
+    private final StringBuffer body;
+
+    /**
+     * Constructor. Initializes headers Map and body buffer.
+     */
+    public SmtpMessage() {
+        headers = new HashMap<>(10);
+        body = new StringBuffer();
+    }
+
+    /**
+     * Update the headers or body depending on the SmtpResponse object and line of input.
+     *
+     * @param response SmtpResponse object
+     * @param params   remainder of input line after SMTP command has been removed
+     */
+    public void store(final SmtpResponse response, final String params) {
+        if (params != null) {
+            if (SmtpState.DATA_HDR.equals(response.getNextState())) {
+                final int headerNameEnd = params.indexOf(':');
+                if (headerNameEnd >= 0) {
+                    final String name = params.substring(0, headerNameEnd).trim();
+                    final String value = params.substring(headerNameEnd + 1).trim();
+                    addHeader(name, value);
+                }
+            } else if (SmtpState.DATA_BODY == response.getNextState()) {
+                body.append(params);
+            }
+        }
+    }
+
+    /**
+     * Get an Iterator over the header names.
+     *
+     * @return an Iterator over the set of header names (String)
+     */
+    public Iterator<String> getHeaderNames() {
+        final Set<String> nameSet = headers.keySet();
+        return nameSet.iterator();
+    }
+
+    /**
+     * Get the value(s) associated with the given header name.
+     *
+     * @param name header name
+     * @return value(s) associated with the header name
+     */
+    public String[] getHeaderValues(final String name) {
+        final List<String> values = headers.get(name);
+        if (values == null) {
+            return new String[0];
+        }
+        return values.toArray(new String[values.size()]);
+    }
+
+    /**
+     * Get the first values associated with a given header name.
+     *
+     * @param name header name
+     * @return first value associated with the header name
+     */
+    public String getHeaderValue(final String name) {
+        final List<String> values = headers.get(name);
+        if (values == null) {
+            return null;
+        }
+        final Iterator<String> iterator = values.iterator();
+        return iterator.hasNext() ? iterator.next() : null;
+    }
+
+    /**
+     * Get the message body.
+     *
+     * @return message body
+     */
+    public String getBody() {
+        return body.toString();
+    }
+
+    /**
+     * Adds a header to the Map.
+     *
+     * @param name  header name
+     * @param value header value
+     */
+    private void addHeader(final String name, final String value) {
+        List<String> valueList = headers.get(name);
+        if (valueList == null) {
+            valueList = new ArrayList<>(1);
+            headers.put(name, valueList);
+        }
+        valueList.add(value);
+    }
+
+    /**
+     * String representation of the SmtpMessage.
+     *
+     * @return a String
+     */
+    @Override
+    public String toString() {
+        final StringBuilder msg = new StringBuilder();
+        for (final Map.Entry<String, List<String>> entry : headers.entrySet()) {
+            final String name = entry.getKey();
+            final List<String> values = entry.getValue();
+            for (final String value : values) {
+                msg.append(name);
+                msg.append(": ");
+                msg.append(value);
+                msg.append('\n');
+            }
+        }
+        msg.append('\n');
+        msg.append(body);
+        msg.append('\n');
+        return msg.toString();
+    }
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpRequest.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpRequest.java
new file mode 100644
index 0000000000..fd65d971e3
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpRequest.java
@@ -0,0 +1,231 @@
+/*
+ * 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.
+ */
+package org.apache.logging.dumbster.smtp;
+
+import org.apache.logging.log4j.util.Strings;
+
+/**
+ * Contains an SMTP client request. Handles state transitions using the following state transition table.
+ * <PRE>
+ * -----------+-------------------------------------------------------------------------------------------------
+ * |                                 State
+ * Action    +-------------+-----------+-----------+--------------+---------------+---------------+------------
+ * | CONNECT     | GREET     | MAIL      | RCPT         | DATA_HDR      | DATA_BODY     | QUIT
+ * -----------+-------------+-----------+-----------+--------------+---------------+---------------+------------
+ * connect    | 220/GREET   | 503/GREET | 503/MAIL  | 503/RCPT     | 503/DATA_HDR  | 503/DATA_BODY | 503/QUIT
+ * ehlo       | 503/CONNECT | 250/MAIL  | 503/MAIL  | 503/RCPT     | 503/DATA_HDR  | 503/DATA_BODY | 503/QUIT
+ * mail       | 503/CONNECT | 503/GREET | 250/RCPT  | 503/RCPT     | 503/DATA_HDR  | 503/DATA_BODY | 250/RCPT
+ * rcpt       | 503/CONNECT | 503/GREET | 503/MAIL  | 250/RCPT     | 503/DATA_HDR  | 503/DATA_BODY | 503/QUIT
+ * data       | 503/CONNECT | 503/GREET | 503/MAIL  | 354/DATA_HDR | 503/DATA_HDR  | 503/DATA_BODY | 503/QUIT
+ * data_end   | 503/CONNECT | 503/GREET | 503/MAIL  | 503/RCPT     | 250/QUIT      | 250/QUIT      | 503/QUIT
+ * unrecog    | 500/CONNECT | 500/GREET | 500/MAIL  | 500/RCPT     | ---/DATA_HDR  | ---/DATA_BODY | 500/QUIT
+ * quit       | 503/CONNECT | 503/GREET | 503/MAIL  | 503/RCPT     | 503/DATA_HDR  | 503/DATA_BODY | 250/CONNECT
+ * blank_line | 503/CONNECT | 503/GREET | 503/MAIL  | 503/RCPT     | ---/DATA_BODY | ---/DATA_BODY | 503/QUIT
+ * rset       | 250/GREET   | 250/GREET | 250/GREET | 250/GREET    | 250/GREET     | 250/GREET     | 250/GREET
+ * vrfy       | 252/CONNECT | 252/GREET | 252/MAIL  | 252/RCPT     | 252/DATA_HDR  | 252/DATA_BODY | 252/QUIT
+ * expn       | 252/CONNECT | 252/GREET | 252/MAIL  | 252/RCPT     | 252/DATA_HDR  | 252/DATA_BODY | 252/QUIT
+ * help       | 211/CONNECT | 211/GREET | 211/MAIL  | 211/RCPT     | 211/DATA_HDR  | 211/DATA_BODY | 211/QUIT
+ * noop       | 250/CONNECT | 250/GREET | 250/MAIL  | 250/RCPT     | 250|DATA_HDR  | 250/DATA_BODY | 250/QUIT
+ * </PRE>
+ */
+public class SmtpRequest {
+    /**
+     * SMTP action received from client.
+     */
+    private final SmtpActionType action;
+    /**
+     * Current state of the SMTP state table.
+     */
+    private final SmtpState state;
+    /**
+     * Additional information passed from the client with the SMTP action.
+     */
+    private final String params;
+
+    /**
+     * Create a new SMTP client request.
+     *
+     * @param actionType type of action/command
+     * @param params     remainder of command line once command is removed
+     * @param state      current SMTP server state
+     */
+    public SmtpRequest(final SmtpActionType actionType, final String params, final SmtpState state) {
+        this.action = actionType;
+        this.state = state;
+        this.params = params;
+    }
+
+    /**
+     * Execute the SMTP request returning a response. This method models the state transition table for the SMTP server.
+     *
+     * @return reponse to the request
+     */
+    public SmtpResponse execute() {
+        SmtpResponse response = null;
+        if (action.isStateless()) {
+            if (SmtpActionType.EXPN == action || SmtpActionType.VRFY == action) {
+                response = new SmtpResponse(252, "Not supported", this.state);
+            } else if (SmtpActionType.HELP == action) {
+                response = new SmtpResponse(211, "No help available", this.state);
+            } else if (SmtpActionType.NOOP == action) {
+                response = new SmtpResponse(250, "OK", this.state);
+            } else if (SmtpActionType.VRFY == action) {
+                response = new SmtpResponse(252, "Not supported", this.state);
+            } else if (SmtpActionType.RSET == action) {
+                response = new SmtpResponse(250, "OK", SmtpState.GREET);
+            } else {
+                response = new SmtpResponse(500, "Command not recognized", this.state);
+            }
+        } else { // Stateful commands
+            if (SmtpActionType.CONNECT == action) {
+                if (SmtpState.CONNECT == state) {
+                    response = new SmtpResponse(220, "localhost Dumbster SMTP service ready", SmtpState.GREET);
+                } else {
+                    response = new SmtpResponse(503, "Bad sequence of commands: " + action, this.state);
+                }
+            } else if (SmtpActionType.EHLO == action) {
+                if (SmtpState.GREET == state) {
+                    response = new SmtpResponse(250, "OK", SmtpState.MAIL);
+                } else {
+                    response = new SmtpResponse(503, "Bad sequence of commands: " + action, this.state);
+                }
+            } else if (SmtpActionType.MAIL == action) {
+                if (SmtpState.MAIL == state || SmtpState.QUIT == state) {
+                    response = new SmtpResponse(250, "OK", SmtpState.RCPT);
+                } else {
+                    response = new SmtpResponse(503, "Bad sequence of commands: " + action, this.state);
+                }
+            } else if (SmtpActionType.RCPT == action) {
+                if (SmtpState.RCPT == state) {
+                    response = new SmtpResponse(250, "OK", this.state);
+                } else {
+                    response = new SmtpResponse(503, "Bad sequence of commands: " + action, this.state);
+                }
+            } else if (SmtpActionType.DATA == action) {
+                if (SmtpState.RCPT == state) {
+                    response = new SmtpResponse(354, "Start mail input; end with <CRLF>.<CRLF>", SmtpState.DATA_HDR);
+                } else {
+                    response = new SmtpResponse(503, "Bad sequence of commands: " + action, this.state);
+                }
+            } else if (SmtpActionType.UNRECOG == action) {
+                if (SmtpState.DATA_HDR == state || SmtpState.DATA_BODY == state) {
+                    response = new SmtpResponse(-1, Strings.EMPTY, this.state);
+                } else {
+                    response = new SmtpResponse(500, "Command not recognized", this.state);
+                }
+            } else if (SmtpActionType.DATA_END == action) {
+                if (SmtpState.DATA_HDR == state || SmtpState.DATA_BODY == state) {
+                    response = new SmtpResponse(250, "OK", SmtpState.QUIT);
+                } else {
+                    response = new SmtpResponse(503, "Bad sequence of commands: " + action, this.state);
+                }
+            } else if (SmtpActionType.BLANK_LINE == action) {
+                if (SmtpState.DATA_HDR == state) {
+                    response = new SmtpResponse(-1, Strings.EMPTY, SmtpState.DATA_BODY);
+                } else if (SmtpState.DATA_BODY == state) {
+                    response = new SmtpResponse(-1, Strings.EMPTY, this.state);
+                } else {
+                    response = new SmtpResponse(503, "Bad sequence of commands: " + action, this.state);
+                }
+            } else if (SmtpActionType.QUIT == action) {
+                if (SmtpState.QUIT == state) {
+                    response = new SmtpResponse(221, "localhost Dumbster service closing transmission channel",
+                        SmtpState.CONNECT);
+                } else {
+                    response = new SmtpResponse(503, "Bad sequence of commands: " + action, this.state);
+                }
+            } else {
+                response = new SmtpResponse(500, "Command not recognized", this.state);
+            }
+        }
+        return response;
+    }
+
+    /**
+     * Create an SMTP request object given a line of the input stream from the client and the current internal state.
+     *
+     * @param s     line of input
+     * @param state current state
+     * @return a populated SmtpRequest object
+     */
+    public static SmtpRequest createRequest(final String s, final SmtpState state) {
+        SmtpActionType action = null;
+        String params = null;
+
+        if (state == SmtpState.DATA_HDR) {
+            if (s.equals(".")) {
+                action = SmtpActionType.DATA_END;
+            } else if (s.length() < 1) {
+                action = SmtpActionType.BLANK_LINE;
+            } else {
+                action = SmtpActionType.UNRECOG;
+                params = s;
+            }
+        } else if (state == SmtpState.DATA_BODY) {
+            if (s.equals(".")) {
+                action = SmtpActionType.DATA_END;
+            } else {
+                action = SmtpActionType.UNRECOG;
+                if (s.length() < 1) {
+                    params = "\n";
+                } else {
+                    params = s;
+                }
+            }
+        } else {
+            final String su = s.toUpperCase();
+            if (su.startsWith("EHLO ") || su.startsWith("HELO")) {
+                action = SmtpActionType.EHLO;
+                params = s.substring(5);
+            } else if (su.startsWith("MAIL FROM:")) {
+                action = SmtpActionType.MAIL;
+                params = s.substring(10);
+            } else if (su.startsWith("RCPT TO:")) {
+                action = SmtpActionType.RCPT;
+                params = s.substring(8);
+            } else if (su.startsWith("DATA")) {
+                action = SmtpActionType.DATA;
+            } else if (su.startsWith("QUIT")) {
+                action = SmtpActionType.QUIT;
+            } else if (su.startsWith("RSET")) {
+                action = SmtpActionType.RSET;
+            } else if (su.startsWith("NOOP")) {
+                action = SmtpActionType.NOOP;
+            } else if (su.startsWith("EXPN")) {
+                action = SmtpActionType.EXPN;
+            } else if (su.startsWith("VRFY")) {
+                action = SmtpActionType.VRFY;
+            } else if (su.startsWith("HELP")) {
+                action = SmtpActionType.HELP;
+            } else {
+                action = SmtpActionType.UNRECOG;
+            }
+        }
+
+        final SmtpRequest req = new SmtpRequest(action, params, state);
+        return req;
+    }
+
+    /**
+     * Get the parameters of this request (remainder of command line once the command is removed.
+     *
+     * @return parameters
+     */
+    public String getParams() {
+        return params;
+    }
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpResponse.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpResponse.java
new file mode 100644
index 0000000000..0a71dbb526
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpResponse.java
@@ -0,0 +1,75 @@
+/*
+ * 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.
+ */
+package org.apache.logging.dumbster.smtp;
+
+/**
+ * SMTP response container.
+ */
+public class SmtpResponse {
+    /**
+     * Response code - see RFC-2821.
+     */
+    private final int code;
+    /**
+     * Response message.
+     */
+    private final String message;
+    /**
+     * New state of the SMTP server once the request has been executed.
+     */
+    private final SmtpState nextState;
+
+    /**
+     * Constructor.
+     *
+     * @param code    response code
+     * @param message response message
+     * @param next    next state of the SMTP server
+     */
+    public SmtpResponse(final int code, final String message, final SmtpState next) {
+        this.code = code;
+        this.message = message;
+        this.nextState = next;
+    }
+
+    /**
+     * Get the response code.
+     *
+     * @return response code
+     */
+    public int getCode() {
+        return code;
+    }
+
+    /**
+     * Get the response message.
+     *
+     * @return response message
+     */
+    public String getMessage() {
+        return message;
+    }
+
+    /**
+     * Get the next SMTP server state.
+     *
+     * @return state
+     */
+    public SmtpState getNextState() {
+        return nextState;
+    }
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpState.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpState.java
new file mode 100644
index 0000000000..74c5bd3bbf
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/SmtpState.java
@@ -0,0 +1,121 @@
+/*
+ * 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.
+ */
+package org.apache.logging.dumbster.smtp;
+
+/**
+ * SMTP server state.
+ */
+public final class SmtpState {
+    /**
+     * Internal representation of the state.
+     */
+    private final byte value;
+
+    /**
+     * Internal representation of the CONNECT state.
+     */
+    private static final byte CONNECT_BYTE = (byte) 1;
+    /**
+     * Internal representation of the GREET state.
+     */
+    private static final byte GREET_BYTE = (byte) 2;
+    /**
+     * Internal representation of the MAIL state.
+     */
+    private static final byte MAIL_BYTE = (byte) 3;
+    /**
+     * Internal representation of the RCPT state.
+     */
+    private static final byte RCPT_BYTE = (byte) 4;
+    /**
+     * Internal representation of the DATA_HEADER state.
+     */
+    private static final byte DATA_HEADER_BYTE = (byte) 5;
+    /**
+     * Internal representation of the DATA_BODY state.
+     */
+    private static final byte DATA_BODY_BYTE = (byte) 6;
+    /**
+     * Internal representation of the QUIT state.
+     */
+    private static final byte QUIT_BYTE = (byte) 7;
+
+    /**
+     * CONNECT state: waiting for a client connection.
+     */
+    public static final SmtpState CONNECT = new SmtpState(CONNECT_BYTE);
+    /**
+     * GREET state: wating for a ELHO message.
+     */
+    public static final SmtpState GREET = new SmtpState(GREET_BYTE);
+    /**
+     * MAIL state: waiting for the MAIL FROM: command.
+     */
+    public static final SmtpState MAIL = new SmtpState(MAIL_BYTE);
+    /**
+     * RCPT state: waiting for a RCPT &lt;email address&gt; command.
+     */
+    public static final SmtpState RCPT = new SmtpState(RCPT_BYTE);
+    /**
+     * Waiting for headers.
+     */
+    public static final SmtpState DATA_HDR = new SmtpState(DATA_HEADER_BYTE);
+    /**
+     * Processing body text.
+     */
+    public static final SmtpState DATA_BODY = new SmtpState(DATA_BODY_BYTE);
+    /**
+     * End of client transmission.
+     */
+    public static final SmtpState QUIT = new SmtpState(QUIT_BYTE);
+
+    /**
+     * Create a new SmtpState object. Private to ensure that only valid states can be created.
+     *
+     * @param value one of the _BYTE values.
+     */
+    private SmtpState(final byte value) {
+        this.value = value;
+    }
+
+    /**
+     * String representation of this SmtpState.
+     *
+     * @return a String
+     */
+    @Override
+    public String toString() {
+        switch (value) {
+            case CONNECT_BYTE:
+                return "CONNECT";
+            case GREET_BYTE:
+                return "GREET";
+            case MAIL_BYTE:
+                return "MAIL";
+            case RCPT_BYTE:
+                return "RCPT";
+            case DATA_HEADER_BYTE:
+                return "DATA_HDR";
+            case DATA_BODY_BYTE:
+                return "DATA_BODY";
+            case QUIT_BYTE:
+                return "QUIT";
+            default:
+                return "Unknown";
+        }
+    }
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/readme.txt b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/readme.txt
new file mode 100644
index 0000000000..aded490e72
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/dumbster/smtp/readme.txt
@@ -0,0 +1,4 @@
+Dumbster was an open source project hosted at Sourceforge. Unfortunately, that project has not had any commits
+to it since 2005. The code was copied here to be able to address a race condition in SimpleSmtpServer.
+
+Dumbster is Copyright 2004 Jason Paul Kitchen
\ No newline at end of file
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpAppenderAsyncTest.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpAppenderAsyncTest.java
new file mode 100644
index 0000000000..f28b5797b9
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpAppenderAsyncTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.
+ */
+package org.apache.logging.log4j.smtp.appender;
+
+import java.util.Iterator;
+
+import org.apache.logging.dumbster.smtp.SimpleSmtpServer;
+import org.apache.logging.dumbster.smtp.SmtpMessage;
+import org.apache.logging.log4j.ThreadContext;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.test.AvailablePortFinder;
+import org.apache.logging.log4j.core.test.junit.LoggerContextRule;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+public class SmtpAppenderAsyncTest {
+
+    private static int PORT;
+
+    private SimpleSmtpServer smtpServer;
+
+    @BeforeClass
+    public static void setupClass() {
+        PORT = AvailablePortFinder.getNextAvailable();
+        System.setProperty("smtp.port", String.valueOf(PORT));
+    }
+
+    @Before
+    public void setup() {
+        smtpServer = SimpleSmtpServer.start(PORT);
+    }
+
+    @Rule
+    public LoggerContextRule ctx = new LoggerContextRule("SmtpAppenderAsyncTest.xml");
+
+    @Test
+    public void testSync() {
+        testSmtpAppender(ctx.getLogger("sync"));
+    }
+
+    @Test
+    public void testAsync() {
+        testSmtpAppender(ctx.getLogger("async"));
+    }
+
+    private void testSmtpAppender(final Logger logger) {
+        ThreadContext.put("MDC1", "mdc1");
+        logger.error("the message");
+        ctx.getLoggerContext().stop();
+        smtpServer.stop();
+
+        assertEquals(1, smtpServer.getReceivedEmailSize());
+        final Iterator<SmtpMessage> messages = smtpServer.getReceivedEmail();
+        final SmtpMessage email = messages.next();
+
+        assertEquals("to@example.com", email.getHeaderValue("To"));
+        assertEquals("from@example.com", email.getHeaderValue("From"));
+        assertEquals("[mdc1]", email.getHeaderValue("Subject"));
+
+        final String body = email.getBody();
+        if (!body.contains("Body:[mdc1]")) {
+            fail(body);
+        }
+    }
+
+    @After
+    public void teardown() {
+        if (smtpServer != null) {
+            smtpServer.stop();
+        }
+    }
+
+    @AfterClass
+    public static void teardownClass() {
+        System.clearProperty("smtp.port");
+    }
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpAppenderTest.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpAppenderTest.java
new file mode 100644
index 0000000000..636e55629a
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpAppenderTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.
+ */
+package org.apache.logging.log4j.smtp.appender;
+
+import java.util.Iterator;
+
+import jakarta.mail.Address;
+import jakarta.mail.Message;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.InternetAddress;
+
+import org.apache.logging.dumbster.smtp.SimpleSmtpServer;
+import org.apache.logging.dumbster.smtp.SmtpMessage;
+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.test.AvailablePortFinder;
+import org.apache.logging.log4j.core.test.categories.Appenders;
+import org.apache.logging.log4j.smtp.MimeMessageBuilder;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.*;
+
+@Category(Appenders.Smtp.class)
+public class SmtpAppenderTest {
+
+    private static final String HOST = "localhost";
+
+    @Test
+    public void testMessageFactorySetFrom() throws MessagingException {
+        final MimeMessageBuilder builder = new MimeMessageBuilder(null);
+        final String address = "testing@example.com";
+
+        assertNull(builder.build().getFrom());
+
+        builder.setFrom(null);
+        Address[] array = null;
+        final Address addr = InternetAddress.getLocalAddress(null);
+        if (addr != null) {
+            array = new Address[] { addr };
+        }
+        assertArrayEquals(array, builder.build().getFrom());
+
+        builder.setFrom(address);
+        assertArrayEquals(new Address[] { new InternetAddress(address) },
+                builder.build().getFrom());
+    }
+
+    @Test
+    public void testMessageFactorySetReplyTo() throws MessagingException {
+        final MimeMessageBuilder builder = new MimeMessageBuilder(null);
+        final String addresses = "testing1@example.com,testing2@example.com";
+
+        assertNull(builder.build().getReplyTo());
+
+        builder.setReplyTo(null);
+        assertNull(builder.build().getReplyTo());
+
+        builder.setReplyTo(addresses);
+        assertArrayEquals(InternetAddress.parse(addresses), builder
+                .build().getReplyTo());
+    }
+
+    @Test
+    public void testMessageFactorySetRecipients() throws MessagingException {
+        final MimeMessageBuilder builder = new MimeMessageBuilder(null);
+        final String addresses = "testing1@example.com,testing2@example.com";
+
+        assertNull(builder.build().getRecipients(
+                Message.RecipientType.TO));
+
+        builder.setRecipients(Message.RecipientType.TO, null);
+        assertNull(builder.build().getRecipients(
+                Message.RecipientType.TO));
+
+        builder.setRecipients(Message.RecipientType.TO, addresses);
+        assertArrayEquals(InternetAddress.parse(addresses), builder
+                .build().getRecipients(Message.RecipientType.TO));
+    }
+
+    @Test
+    public void testMessageFactorySetSubject() throws MessagingException {
+        final MimeMessageBuilder builder = new MimeMessageBuilder(null);
+        final String subject = "Test Subject";
+
+        assertNull(builder.build().getSubject());
+
+        builder.setSubject(null);
+        assertNull(builder.build().getSubject());
+
+        builder.setSubject(subject);
+        assertEquals(subject, builder.build().getSubject());
+    }
+
+    @Test
+    public void testDelivery() {
+        final String subjectKey = getClass().getName();
+        final String subjectValue = "SubjectValue1";
+        ThreadContext.put(subjectKey, subjectValue);
+        final int smtpPort = AvailablePortFinder.getNextAvailable();
+        final SmtpAppender appender = SmtpAppender.newBuilder()
+                .setName("Test")
+                .setTo("to@example.com")
+                .setCc("cc@example.com")
+                .setBcc("bcc@example.com")
+                .setFrom("from@example.com")
+                .setReplyTo("replyTo@example.com")
+                .setSubject("Subject Pattern %X{" + subjectKey + "} %maxLen{%m}{10}")
+                .setSmtpHost(HOST)
+                .setSmtpPort(smtpPort)
+                .setBufferSize(3)
+                .build();
+        appender.start();
+
+        final LoggerContext context = LoggerContext.getContext();
+        final Logger root = context.getLogger("SMTPAppenderTest");
+        root.addAppender(appender);
+        root.setAdditive(false);
+        root.setLevel(Level.DEBUG);
+
+        final SimpleSmtpServer server = SimpleSmtpServer.start(smtpPort);
+
+        root.debug("Debug message #1");
+        root.debug("Debug message #2");
+        root.debug("Debug message #3");
+        root.debug("Debug message #4");
+        root.error("Error with exception", new RuntimeException("Exception message"));
+        root.error("Error message #2");
+
+        server.stop();
+        assertTrue(server.getReceivedEmailSize() == 2);
+        final Iterator<SmtpMessage> messages = server.getReceivedEmail();
+        final SmtpMessage email = messages.next();
+
+        assertEquals("to@example.com", email.getHeaderValue("To"));
+        assertEquals("cc@example.com", email.getHeaderValue("Cc"));
+        // assertEquals("bcc@example.com", email.getHeaderValue("Bcc")); // BCC
+        // can't be tested with Dumpster 1.6
+        assertEquals("from@example.com", email.getHeaderValue("From"));
+        assertEquals("replyTo@example.com", email.getHeaderValue("Reply-To"));
+        assertEquals("Subject Pattern " + subjectValue +" Error with", email.getHeaderValue("Subject"));
+
+        final String body = email.getBody();
+        assertFalse(body.contains("Debug message #1"));
+        assertTrue(body.contains("Debug message #2"));
+        assertTrue(body.contains("Debug message #3"));
+        assertTrue(body.contains("Debug message #4"));
+        assertTrue(body.contains("Error with exception"));
+        assertTrue(body.contains("RuntimeException"));
+        assertTrue(body.contains("Exception message"));
+        assertFalse(body.contains("Error message #2"));
+
+        final SmtpMessage email2 = messages.next();
+
+        assertEquals("Subject Pattern " + subjectValue +" Error mess", email2.getHeaderValue("Subject"));
+
+        final String body2 = email2.getBody();
+        assertFalse(body2.contains("Debug message #4"));
+        assertFalse(body2.contains("Error with exception"));
+        assertTrue(body2.contains("Error message #2"));
+    }
+}
diff --git a/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpManagerTest.java b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpManagerTest.java
new file mode 100644
index 0000000000..9779480b17
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/java/org/apache/logging/log4j/smtp/appender/SmtpManagerTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.
+ */
+package org.apache.logging.log4j.smtp.appender;
+
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.async.RingBufferLogEvent;
+import org.apache.logging.log4j.core.impl.Log4jLogEvent;
+import org.apache.logging.log4j.core.impl.MementoMessage;
+import org.apache.logging.log4j.core.impl.MutableLogEvent;
+import org.apache.logging.log4j.core.time.ClockFactory;
+import org.apache.logging.log4j.core.time.internal.DummyNanoClock;
+import org.apache.logging.log4j.message.ReusableMessage;
+import org.apache.logging.log4j.message.ReusableSimpleMessage;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Unit tests for {@link SmtpManager}.
+ */
+public class SmtpManagerTest {
+
+    @Test
+    void testCreateManagerName() {
+        final String managerName = SmtpManager.createManagerName("to", "cc", null, "from", null, "LOG4J2-3107",
+            "proto", "smtp.log4j.com", 4711, "username", false, "filter");
+        assertEquals("SMTP:to:cc::from::LOG4J2-3107:proto:smtp.log4j.com:4711:username::filter", managerName);
+    }
+
+    private void testAdd(final LogEvent event) {
+        final SmtpManager smtpManager = SmtpManager.getSmtpManager(null, "to", "cc", "bcc", "from", "replyTo", "subject", "protocol", "host", 0, "username", "password", false, "filterName", 10, null);
+        smtpManager.removeAllBufferedEvents(); // in case this smtpManager is reused
+        smtpManager.add(event);
+
+        final LogEvent[] bufferedEvents = smtpManager.removeAllBufferedEvents();
+        assertThat("unexpected number of buffered events", bufferedEvents.length, is(1));
+        assertThat("expected the immutable version of the event to be buffered", bufferedEvents[0].getMessage(), is(instanceOf(MementoMessage.class)));
+    }
+
+    // LOG4J2-3172: make sure existing protections are not violated
+    @Test
+    void testAdd_WhereLog4jLogEventWithReusableMessage() {
+        final LogEvent event = new Log4jLogEvent.Builder().setMessage(getReusableMessage("test message")).build();
+        testAdd(event);
+    }
+
+    // LOG4J2-3172: make sure existing protections are not violated
+    @Test
+    void testAdd_WhereMutableLogEvent() {
+        final MutableLogEvent event = new MutableLogEvent(new StringBuilder("test message"), null);
+        testAdd(event);
+    }
+
+    // LOG4J2-3172
+    @Test
+    void testAdd_WhereRingBufferLogEvent() {
+        final RingBufferLogEvent event = new RingBufferLogEvent();
+        event.setValues(null, null, null, null, null, getReusableMessage("test message"), null, null, null, 0, null, 0, null, ClockFactory.getClock(), new DummyNanoClock());
+        testAdd(event);
+    }
+
+    private ReusableMessage getReusableMessage(final String text) {
+        final ReusableSimpleMessage message = new ReusableSimpleMessage();
+        message.set(text);
+        return message;
+    }
+
+}
diff --git a/log4j-jakarta-smtp/src/test/resources/SmtpAppenderAsyncTest.xml b/log4j-jakarta-smtp/src/test/resources/SmtpAppenderAsyncTest.xml
new file mode 100644
index 0000000000..ff29f544df
--- /dev/null
+++ b/log4j-jakarta-smtp/src/test/resources/SmtpAppenderAsyncTest.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<Configuration name="SmtpAppenderAsyncTest" status="WARN">
+    <Appenders>
+        <SMTP name="mail-sync" to="to@example.com" from="from@example.com" smtpHost="localhost"
+              smtpPort="${sys:smtp.port}" ignoreExceptions="false" subject="[%X{MDC1}]">
+            <PatternLayout pattern="Body:[%X{MDC1}]" />
+        </SMTP>
+        <Async name="mail-async">
+            <AppenderRef ref="mail-sync"/>
+        </Async>
+        <Console name="Console" target="SYSTEM_OUT">
+            <PatternLayout pattern="%d [%t] %-5level: %msg%n%throwable" />
+        </Console>
+    </Appenders>
+    <Loggers>
+        <Root level="FATAL">
+          <AppenderRef ref="Console"/>
+        </Root>
+        <Logger name="sync" level="INFO">
+            <AppenderRef ref="mail-sync"/>
+        </Logger>
+        <Logger name="async" level="INFO">
+            <AppenderRef ref="mail-async"/>
+        </Logger>
+    </Loggers>
+</Configuration>
diff --git a/pom.xml b/pom.xml
index fcefb3a320..96d5b7eb3d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -230,6 +230,7 @@
     <module>log4j-flume-ng</module>
     <module>log4j-gctests</module>
     <module>log4j-iostreams</module>
+    <module>log4j-jakarta-smtp</module>
     <module>log4j-jakarta-web</module>
     <module>log4j-jcl</module>
     <module>log4j-jdbc</module>
@@ -382,6 +383,8 @@
          Dependency version properties (in alphabetical order)
          ===================================================== -->
     <activemq.version>5.17.3</activemq.version>
+    <angus-activation.version>2.0.0</angus-activation.version>
+    <angus-mail.version>2.0.1</angus-mail.version>
     <assertj.version>3.23.1</assertj.version>
     <awaitility.version>4.2.0</awaitility.version>
     <bsh.version>2.0b6</bsh.version>
@@ -412,9 +415,9 @@
     <HdrHistogram.version>2.1.12</HdrHistogram.version>
     <hsqldb.version>2.5.2</hsqldb.version>
     <jackson-bom.version>2.14.1</jackson-bom.version>
-    <!-- Implementation of `jakarta.activation-api`: -->
-    <jakarta-activation.version>2.0.1</jakarta-activation.version>
-    <jakarta-mail.version>2.0.1</jakarta-mail.version>
+    <!-- Override the version in Jakarta EE 9 BOM -->
+    <jakarta-activation.version>2.1.1</jakarta-activation.version>
+    <jakarta-mail.version>2.1.1</jakarta-mail.version>
     <!-- BOM with Jakarta EE 9 APIs: -->
     <jakartaee-bom.version>9.0.0</jakartaee-bom.version>
     <!-- No BOM for Java EE 8 APIs, so we list them separately: -->
@@ -573,6 +576,12 @@
         <version>${activemq.version}</version>
       </dependency>
 
+      <dependency>
+        <groupId>org.eclipse.angus</groupId>
+        <artifactId>angus-activation</artifactId>
+        <version>${angus-activation.version}</version>
+      </dependency>
+
       <dependency>
         <groupId>org.assertj</groupId>
         <artifactId>assertj-core</artifactId>
@@ -870,11 +879,23 @@
       </dependency>
 
       <dependency>
-        <groupId>com.sun.activation</groupId>
-        <artifactId>jakarta.activation</artifactId>
+        <groupId>jakarta.activation</groupId>
+        <artifactId>jakarta.activation-api</artifactId>
         <version>${jakarta-activation.version}</version>
       </dependency>
 
+      <dependency>
+        <groupId>org.eclipse.angus</groupId>
+        <artifactId>jakarta.mail</artifactId>
+        <version>${angus-mail.version}</version>
+      </dependency>
+
+      <dependency>
+        <groupId>jakarta.mail</groupId>
+        <artifactId>jakarta.mail-api</artifactId>
+        <version>${jakarta-mail.version}</version>
+      </dependency>
+
       <dependency>
         <groupId>org.fusesource.jansi</groupId>
         <artifactId>jansi</artifactId>
@@ -1225,12 +1246,6 @@
         <version>${slf4j.version}</version>
       </dependency>
 
-      <dependency>
-        <groupId>com.sun.mail</groupId>
-        <artifactId>smtp</artifactId>
-        <version>${jakarta-mail.version}</version>
-      </dependency>
-
       <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot</artifactId>