DEVCON 2026    |    2-5 November 2026 – QEII Centre – London, UK    |    Register now! 

Live Webinar    |    June 30    |    CE is sunsetted. What's your next move?    |    Register now! 

Blogs

Mission-Critical Email Delivery in Liferay

How durable messaging, Apache Artemis, and Apache Camel can eliminate email loss during SMTP outages...

David H Nebinger
David H Nebinger
10 minuters läsning

A Simple Question

A customer recently reached out after experiencing a six-hour outage of their mail server.

The outage had nothing to do with Liferay. The platform was healthy. Their application was healthy. Business processes continued to run normally.

The problem was that some of those business processes generated emails.

Not marketing emails.

Not newsletters.

Mission-critical emails.

The outage raised an uncomfortable question:

What happens to those emails when the mail server is unavailable?

They're lost, I responded.

That quickly led to a follow-up question:

How can I guarantee email delivery from Liferay?

And I had to give them an honest answer:

You can't.

At least not using Liferay's standard email delivery implementation.

That answer surprised them, so let's talk about how email delivery works today and why solving the problem requires a different architectural approach.

How Liferay Sends Email

When a process in Liferay wants to send an email, it typically doesn't connect directly to the SMTP server.

Instead, Liferay creates a mail message and posts it to the Liferay Message Bus (LMB).

The LMB is Liferay's internal asynchronous messaging system. It allows work to be handed off from one thread to another, letting the original process continue without waiting for the email to be delivered.

This approach has several advantages.

  • The user doesn't wait for SMTP communication.
  • Business processes complete more quickly.
  • Email delivery happens asynchronously.

The important detail, however, is that the LMB is an in-memory messaging system. It is not a durable message broker.

If the SMTP server is unavailable, the failure is logged, but the message is discarded.

If Liferay crashes or is restarted while messages remain queued in the LMB, those queued messages are lost.

For most organizations this is perfectly acceptable. Password reset emails, notifications, subscription updates, and workflow messages can usually tolerate occasional failures.

Mission-critical messaging often cannot.

This Isn't Really a Liferay Problem

It's important to recognize that this isn't a flaw in Liferay.

The platform was designed around immediate email delivery. For the vast majority of deployments, that's exactly the right tradeoff.

The customer's problem wasn't really about email.

The customer's problem was about preserving an important business event until it can be processed successfully.

That's a messaging problem.

And when someone asks me how to make sure something gets done reliably, I immediately start thinking about durable messaging.

Reliable Delivery Starts with JMS

When someone asks me how to make sure something gets done reliably, I immediately start thinking about JMS.

JMS, or Java Message Service, is a standard messaging API used throughout enterprise Java applications. Rather than performing work immediately, applications can place a message onto a durable queue where it waits until a consumer is ready to process it.

That distinction is important.

Instead of asking:

Can I send this email right now?

We can ask:

Can I preserve the request to send this email until it can be processed successfully?

Those are very different questions.

With JMS, the email itself becomes a message placed onto a queue.

A separate process consumes messages from that queue and performs the actual work.

When combined with a durable messaging platform, this provides several advantages:

  • The message survives process restarts.
  • The message survives application crashes.
  • The message survives temporary infrastructure failures.
  • The message remains available until it is successfully processed.

That last point is the important one.

The queue doesn't care whether the SMTP server is currently available.

It simply holds the message until a consumer successfully processes it.

Of course, JMS itself is just an API and a programming model.

To actually store, manage, and deliver messages, we need a messaging platform behind it.

That's where an Enterprise Message Bus comes in.

Introducing an Enterprise Message Bus

For the proof of concept I chose Apache Artemis as the Enterprise Message Bus (EMB).

Artemis provides durable JMS queues, persistence, retry policies, dead letter queue support, monitoring, and operational tooling.

Note: If you're using the repo Docker composition, you're going to log into http://localhost:8161 with username mailuser and password mailpass.

The important point isn't Artemis specifically.

This same architectural pattern would work with RabbitMQ, ActiveMQ Classic, IBM MQ, Amazon MQ, Azure Service Bus, or any other enterprise messaging platform capable of durable message persistence.

Artemis simply worked well for the proof of concept and is easy to run locally using Docker.

Why Apache Camel?

Once the message is sitting in a durable queue, something needs to process it.

Apache Camel is not required for this solution.

A custom Java application could consume JMS messages and perform SMTP delivery directly.

A custom solution could even move template processing out of Liferay entirely and generate emails during message processing.

There are plenty of possibilities.

For this proof of concept, however, Apache Camel dramatically reduced the amount of code required.

One of the reasons I frequently pair an Enterprise Message Bus with Apache Camel is that Camel eliminates a tremendous amount of integration plumbing.

Without Camel, we'd need to write:

  • JMS consumer logic.
  • Transaction management.
  • SMTP integration.
  • Retry handling.
  • Connection management.
  • Error propagation.

Camel already knows how to consume JMS messages.

Camel already knows how to work with SMTP.

Camel already knows how to manage transactions.

For example, the route for processing the email via camel is a Java class defining the config:

public class MailRelayRoute extends RouteBuilder {
  @Override
  public void configure() {
    from("sjms2:queue:mail.outbound?connectionFactory" + 
        "=#artemisCF&transacted=true")
      .routeId("mail-outbound-relay")
      .log("Relaying message ${header.To}")
      .bean("smtpSender", "send");
  }
}

The resulting route is surprisingly small because Camel is hiding a large amount of infrastructure code we'd otherwise have to write ourselves.

For me, Enterprise Message Buses and Apache Camel go together like peanut butter and jelly.

The broker provides durability and reliable delivery.

Camel provides connectivity and routing.

Together they allow us to focus on business requirements instead of plumbing.

The New Architecture

Instead of sending emails directly from the Liferay mail listener, the proof of concept diverts messages into a durable JMS queue.

The actual customization is surprisingly small.

A custom listener subscribes to the same mail destination used by Liferay's built-in mail sender.

When a mail message arrives:

  1. Receive the mail message.
  2. Serialize it as an RFC822 email.
  3. Publish it to a durable JMS queue.
  4. Return.

At that point Liferay's responsibility ends.

The email request has been durably persisted.

Everything that happens afterward becomes a messaging concern rather than a Liferay concern.

Preventing Duplicate Delivery

One obvious question is:

If Liferay already has a mail sender, won't both listeners process the message?

The answer is yes.

To avoid duplicate delivery, the proof of concept disables the default Liferay mail sender and replaces it with the JMS publisher.

We create a osgi/configs/com.liferay.portal.component.blacklist.internal.configuration.ComponentBlacklistConfiguration.config file that contains the following:

blacklistComponentNames=
  ["com.liferay.mail.messaging.internal.MailMessageListener"]

This ensures there is only one path for outbound email processing.

It blocks the Liferay component from running, so it won't be pulling messages from the LMB, only our customization will.

Why RFC822 Serialization Matters

Rather than creating a custom message format, the proof of concept serializes the complete email into a standard RFC822 message.

This preserves:

  • HTML content.
  • Attachments.
  • Reply-To addresses.
  • Message headers.
  • Message IDs.
  • Custom metadata.

Camel doesn't need to understand anything about Liferay mail messages.

It simply receives a standard email and forwards it appropriately.

This keeps the integration clean and loosely coupled.

Reliable Delivery Through Transactions

One small configuration detail carries a lot of weight.

The Camel route consumes messages transactionally.

from("sjms2:queue:mail.outbound?connectionFactory=" + 
    "#artemisCF&transacted=true")
  .routeId("mail-outbound-relay")
  .bean("smtpSender", "send");

If SMTP delivery succeeds, the transaction commits and Artemis removes the message from the queue.

If SMTP delivery fails, the transaction rolls back.

The message remains in the queue.

Nothing is lost.

That transactional boundary is what makes reliable delivery possible.

Repository Contents

The repository accompanying this post (https://github.com/dnebing/liferay-reliable-email-delivery) contains everything needed to build and run the proof of concept.

  • A Liferay Workspace.
  • The email diversion module.
  • Docker Compose configuration.
  • Apache Artemis.
  • Apache Camel JBang.
  • SMTP4Dev.

The Liferay Workspace contains the customization module that diverts email messages from the LMB into JMS.

The Docker composition starts Liferay DXP 2026.Q1.8, Artemis, Camel JBang, and SMTP4Dev.

Building and Running the Proof of Concept

Build the module from the Liferay Workspace:

$ cd liferay-workspace
$ ./gradlew :modules:guaranteed-email-delivery:jar

Copy the generated module into the Liferay deployment location used by the Docker composition.

$ cp modules/guaranteed-email-delivery/build/libs/*.jar \
  ../docker/liferay/files/osgi/modules/

Start the environment:

$ cd ../docker
$ docker compose up -d

Once the containers are running, verify that:

  • Liferay is available on port 8080.
  • Artemis is available on port 8161.
  • SMTP4Dev is available on port 5080.

Why SMTP4Dev?

For a proof of concept, I didn't want to depend on a real mail server.

SMTP4Dev is a lightweight SMTP server designed specifically for testing and development.

It accepts emails, stores them locally, and exposes a web interface for reviewing received messages.

This gives us several advantages:

  • No external mail dependency.
  • No risk of sending emails to real users.
  • Easy inspection of message contents.
  • Simple outage simulation.

Most importantly, it allows us to reproduce the original customer problem.

When SMTP4Dev is running, messages should be delivered immediately.

When SMTP4Dev is stopped, Camel cannot deliver messages, Artemis retains them in the durable queue, and the queue depth increases.

When SMTP4Dev is started again, Camel resumes processing and the backlog drains.

Generating Test Emails

To make testing easier, the repository includes a simple Groovy script that can be executed from the Liferay Script Console.

The script sends an email using Liferay's standard MailServiceUtil API.

import com.liferay.mail.kernel.model.MailMessage
import com.liferay.mail.kernel.service.MailServiceUtil

import jakarta.mail.internet.InternetAddress

def from = new InternetAddress("noreply@example.com",
  "Liferay Reliable Mail Demo")
def to = new InternetAddress("test@example.com", 
  "Test Recipient")

def subject = "Reliable email delivery test - " + new Date()

def body = "<html>" +
"    <body>" +
"        <h2>Liferay Reliable Email Delivery Test</h2>" +
"        <p>This email was generated from the Liferay Script "
      + "          Console.</p>" +
"        <p>" +
"            If SMTP4Dev is running, it should appear " +
"immediately.<br />" +
"            If SMTP4Dev is stopped, it should remain queued 
        "in Artemis" +
"            until the mail server is available again." +
"        </p>" +
"        <p>Generated at: " + new Date().toString() + "</p>" +
"    </body>" +
"</html>"

def mailMessage = new MailMessage(from, to, subject, 
  body, true)

MailServiceUtil.sendEmail(mailMessage)

out.println("Queued email using Liferay MailServiceUtil: " + 
  subject)
NOTE: The script may not copy correctly and paste into the console because it's html. Check the sendmail.groovy script in the repo, it should work fine.

Because the script uses the normal Liferay mail infrastructure, it follows exactly the same path as any other email generated by the platform.

In Liferay, open the Script Console, select Groovy, paste the script, and execute it.

Testing the Solution

With everything running normally, execute the Groovy script.

The email should immediately appear in SMTP4Dev.

Note: If using the docker container, go to http://localhost:5080 and you'll see all of the email that has been delivered.

Now stop the SMTP server:

$ docker compose stop smtp4dev

Execute the Groovy script several more times.

At this point:

  • Liferay continues generating email requests.
  • Messages are published to Artemis.
  • Camel attempts delivery.
  • SMTP delivery fails.
  • Artemis retains the messages.

Open the Artemis console and inspect the queue.

The queue depth should continue increasing as new messages are generated.

Nothing is lost.

Recovering from the Outage

Start SMTP4Dev again:

$ docker compose start smtp4dev

Camel reconnects and resumes processing.

Messages begin leaving the queue.

The backlog drains.

Emails appear in SMTP4Dev.

This is exactly the behavior we wanted to validate.

The six-hour mail server outage that prompted the original question no longer results in lost messages.

Artemis Retry Configuration

The proof of concept includes a retry strategy configured directly in Artemis.

<redelivery-delay>5000</redelivery-delay>
<redelivery-delay-multiplier>2.0</redelivery-delay-multiplier>
<max-redelivery-delay>300000</max-redelivery-delay>
<max-delivery-attempts>-1</max-delivery-attempts>

These settings provide:

  • An initial retry delay.
  • Exponential backoff.
  • A maximum retry interval.
  • Unlimited delivery attempts.

These values were selected to support the reliable delivery goal demonstrated in the proof of concept.

A production implementation would tune these values based on operational requirements, expected outage duration, message urgency, and business expectations.

The important part is that Artemis avoids continuously hammering an unavailable SMTP server while still preserving messages for later delivery.

Production Considerations

The proof of concept intentionally focuses on a single failure mode:

SMTP server unavailable.

That was the original customer scenario, and it is the scenario this proof of concept validates.

A production implementation would need additional handling for permanent failures.

Examples include:

  • Invalid recipient addresses.
  • Invalid sender addresses.
  • Oversized messages.
  • Malformed MIME content.
  • Domain resolution failures.
  • Mail server policy rejections.
  • Missing required message fields.

Retrying these messages forever usually doesn't make sense.

A production implementation would likely inspect delivery failures and make routing decisions based on the type of failure.

Some messages should remain in the send queue because the failure is transient.

Some messages should move to a dead letter queue.

Some messages should move to an undeliverable queue.

Some messages may require manual review.

Some messages may be safe to discard according to business policy.

A more complete production topology might look like this:

The correct handling depends entirely on business requirements.

A password reset email might be discarded after a reasonable expiration window.

A purchase confirmation email might require manual review.

A regulatory notification might require alerting and escalation.

The important point is that these decisions happen after the message has already been durably persisted.

The email request is never silently lost because of a temporary infrastructure failure.

What About Custom Email Code?

This solution works automatically for applications using Liferay's standard mail APIs.

If custom code bypasses Liferay's mail infrastructure and connects directly to SMTP, those messages will not pass through the durable queue.

Additional customization would be required to bring those emails into the same processing pipeline, but it's not that complicated. You're already preparing a MailMessage and then invoking Transport.send() with it. Instead of sending it, take a look at how JmsMailDivertListener is posting the mail to the JMS queue. Your custom code would do the same thing and the rest of the implementation will just work.

This is an important operational detail.

If reliable email delivery matters, you need to understand every path your system uses to send email.

Final Thoughts

The interesting thing about this project is that it started with an email question and ended with a messaging solution.

The customer wasn't really asking how to send email more reliably.

They were asking how to ensure an important business event could not be lost.

Durable messaging solves that problem.

And it solves the problem regardless of the scenario, whether it is simply sending an email or updating a record in an external system or writing a file to the filesystem... They're all the same - it's an important business event that cannot be lost, and an outcome that must be reliably satisfied. JMS and durable queues are the way to ensure that.

For reliable email delivery, once email requests are persisted in a durable queue, temporary infrastructure outages stop being catastrophic events and become operational inconveniences.

Liferay provides a clean interception point through the Liferay Message Bus.

JMS provides queue durability.

Apache Camel provides integration.

Together they create a relatively simple solution for a problem many organizations assume requires expensive proprietary infrastructure.

And perhaps the biggest lesson is this:

When something absolutely must happen, don't rely on immediate execution.

Publish (enqueue) the intent first.

Then process it reliably.

URL for the repo if you didn't see it already:

Kommentarer

Related Assets...

Inga resultat hittades

More Blog Entries...