Skip to content

Transactional Email

How Badger Commerce renders and sends transactional emails (order confirmations, dispatches, gift cards, password resets, gift aid notifications, fundraiser updates, etc.) — the rendering engine, the worker offload for the high-volume flows, and the rules for adding a new email type.

Why this is its own subsystem

Email had two coupled problems that pulled it out of the request path:

  1. Templates live under a theme prefix. Every email lives at commerce-core/src/main/resources/templates/<theme>/email/<name>.html. The renderer must map the unprefixed email/orderPlaced (the name configured per site) to bootstrap/email/orderPlaced (or nova/email/... with a bootstrap fallback for an override theme). That mapping was historically only configured in web-mvc's view-resolver chain — the rest and worker Spring Boot apps depend on commerce-core only and had no theme-aware resolver, so templateEngine.process("email/orderPlaced") simply could not find the template on those nodes.
  2. Sending should not happen on request-handling threads. SMTP-bound work on the web/rest nodes adds tail latency to checkouts and POS submits, and a worker that can render emails independently lets the request nodes stay lightweight.

The result is a small renderer that works on any node, plus a RabbitMQ work queue that the worker drains.

The rendering engine: EmailTemplateRenderer

commerce-core/src/main/java/uk/co/kedos/badger/settbuilder/email/render/EmailTemplateRenderer.java owns a privately-constructed SpringTemplateEngine. It is not a SpringTemplateEngine bean — that would trip Spring Boot's @ConditionalOnMissingBean(SpringTemplateEngine.class) and disable the auto-configured engine the web app uses to render pages. Keeping the email engine private leaves the web view chain untouched.

The engine carries the minimum dialects email templates actually use:

  • The Spring standard dialect (added automatically by SpringTemplateEngine) — ${...}, #{...}, #numbers, #strings, #lists, #dates and, since Thymeleaf 3.1 integrated it into core, #temporals. No thymeleaf-extras-java8time dependency is needed.
  • The platform's site-aware MessageSource (BadgerMessageSource) for #{...} lookups, set via setTemplateEngineMessageSource(...). Because that message source reads the active site from TenantContext, the renderer sets TenantContext (and LocaleContextHolder) around every process() call and restores the previous values in finally, so it works equally on the web request thread and on the worker.

Theme resolution is request-context-free. The renderer doesn't ask SiteContextProducer for the theme; instead it builds Thymeleaf templateResolutionAttributes from the SiteContext the caller passed in (themeTemplatePath, baseThemeTemplatePath, overrideTheme). Those attributes become part of the cache key, so cached templates never bleed across themes (see Thymeleaf #380). Three chained EmailTemplateResolver instances run with checkExistence=true, giving the same "nova then bootstrap" fallback as the web theme resolver:

Order Leg Resolves
0 override <themePath>/email/X — only when the theme is an override theme
1 base <baseThemePath>/email/X (override themes) or <themePath>/email/X (non-override)
2 default-theme bootstrap/email/X, whatever the theme is

That last leg matters for themes configured with overrideTheme = false. Such a theme has no baseThemeTemplatePath, so legs 0 and 1 both point at the theme's own path — and any email the theme ships no template for would fail to render and the job would be dropped. Leg 2 degrades it to the default theme instead. The web-side BadgerSpringResourceTemplateResolver has a matching defaultThemeTemplateResolver leg for exactly the same reason.

The plain-text variant (email/<name>-text) is optional — renderOptional returns null if no -text template exists rather than throwing. MailtrapEmailService only sets the text body when one was rendered.

The async offload: EmailJob queue

The order-pipeline emails publish a thin job to RabbitMQ; the worker consumes, rebuilds the template model from the IDs the job carries, renders via the engine above and sends.

+-------------+   publish   +-----------------+   consume   +-------------------+
|  web/rest   | ----------> |  email-jobs-    | ----------> |  worker           |
|  pipeline   |             |  queue (AMQP)   |             |  EmailJobListener |
|  step       |             +-----------------+             |                   |
|             |                                             |  -> reconstruct   |
|  publishes  |                                             |  -> render        |
|  EmailJob   |                                             |  -> SMTP send     |
+-------------+                                             +-------------------+

Wire format — IDs, never domain objects

EmailJob (in commerce-core/.../email/queue/EmailJob.java) carries:

  • Everything resolved from the request-scoped SiteContext + tenant config as plain strings: templateName, formatted subject, from, to, bcc, locale, siteId.
  • An EmailModelSpec describing how to rebuild the template model on the worker, never the domain objects themselves.
public class EmailModelSpec {
  public enum Kind { ORDER, ORDER_FULFILMENT, SIMPLE }
  private Kind kind;
  private String orderId;        // ORDER, ORDER_FULFILMENT
  private String fulfillerId;    // ORDER_FULFILMENT
  private Map<String, Object> inline;  // SIMPLE + extras
}

That keeps the payload small, dodges fragile Jackson round-trips for Mongo documents, and matches the existing event-listener pattern in the codebase (SubscriptionCancellationEmailListener, FundraiserEventListener).

Reconstruction lives in commerce-core

EmailJobModelReconstructor (commerce-core) is what knows how to turn each Kind back into a template model. The worker listener (worker/.../email/EmailJobListener.java) is a thin shell that calls into it. Putting the reconstruction in commerce-core means each kind is unit-testable without standing up the worker.

Kind Looks up Adds to model
ORDER OrderService.findOrder(orderId, site) order, orderNumber, dOrder
ORDER_FULFILMENT OrderService.findOrder(orderId, site) + DisplayTools.constructFulfilmentDisplayOrder(..., fulfillerId) dOrder (filtered to that fulfiller), orderNumber, siteName
SIMPLE nothing — inline-only the inline map entries

siteContext is always added; branding is added later by MailtrapEmailService from the resolved site.

Templates take the header logo from ${branding.logoUrl}, resolved by EmailBrandingService:

  1. the tenant's email-brand-logo-url config value (System > Site Config > email-branding), if set;
  2. otherwise the storefront logo, SiteContext.logoURL.

Either way the value is made absolute against the tenant's domainURL before it reaches the template. This matters because SiteContext.logoURL is stored as a site-relative media path — every web template renders it on the tenant's own domain, where the browser resolves it against the current host. An email client has no such base, so a relative src renders as a broken image.

A value that cannot be made into something an email client will render — a relative path on a tenant with no domainURL, or a non-http/data scheme — resolves to null rather than a broken image, and templates fall back to a text heading of the site name. Absolute https:// and http:// URLs are passed through untouched; a protocol-relative //host/path is given an explicit https: scheme, since an email has no containing page for it to inherit one from.

Templates should therefore never reference ${siteContext.logoURL} directly.

Queue declaration

AMQPConfig.EMAIL_JOBS_QUEUE (email-jobs-queue) is declared durable and bound by name to the AMQP default exchange — standard work-queue pattern, one consumer group (the worker). EmailJobPublisher.publish uses rabbitTemplate.convertAndSend(EMAIL_JOBS_QUEUE, job). Serialization is handled by the project-wide Jackson2JsonMessageConverter declared in AMQPConfig.

What is async vs synchronous today

Phase 1 (the rendering engine) makes email rendering work identically on every node. Phase 2 (the queue offload) is opt-in per email type — we migrate flows that are genuinely fire-and-forget and leave interactive flows synchronous so they can give the user immediate sent/failed feedback. The current split:

Async via EmailJobPublisher → worker:

  • Order confirmation (SendOrderConfirmationEmail, submitOrder pipeline)
  • Order dispatch (SendOrderDispatchEmailProcessor, dispatchOrder pipeline)
  • Order confirmation resend (OrderEmailResendServiceImpl.resendConfirmationEmail)
  • Subscription dunning chaser (SubscriptionDunningServiceImpl)
  • Password reset (UserPasswordServiceImpl.sendPasswordResetEmail, SIMPLE kind) — the reset link is the only copy the user gets, so it must not be lost to a transient provider blip on a request thread

Synchronous (rendered + sent on the calling node, all now working via the new renderer):

  • Order fulfilment per-fulfiller (EmailFulfilmentProcessor, OrderEmailResendServiceImpl.resendFulfilmentEmail) — bespoke per-fulfiller filtering, follows the same pattern when migrated.
  • Gift-card delivery (VirtualGiftCardFulfilmentProcessor) — carries a one-time plaintext PIN that is hashed and not stored, so reconstruct-from-IDs is impossible and the secret is deliberately kept off the queue (where the AMQP error handler would otherwise log failed payloads).
  • Gift Aid notification (GiftAidNotificationService) and subscription cancellation (SubscriptionCancellationEmailListener) — straightforward to migrate via SIMPLE or a new Kind, follow-up work.
  • Interactive flows that need immediate sent/failed feedback to a user: Contact-Us (ContactUsService, ContactUsExtension), BillPay (BillPayAdminController) and the admin "send test email" (EmailBrandingController).
  • Fundraiser emails (FundraiserEventListener) — already on the worker via the existing fundraiser events queue; the new renderer fixed a latent rendering issue there.

The auth server's AuthEmailService references templates by their fully-qualified path (auth/email/...) and is intentionally outside this subsystem.

Delivery failures: bounded retry, then parked

EmailJobListener throws on any job it cannot deliver — a provider rejection, a null response, a malformed job, an unresolvable site, a model it cannot reconstruct. Throwing is what hands the message to the listener container's retry advice; acking (which this used to do) would lose the email.

The listener runs on its own container factory, emailJobsListenerContainerFactory, not the shared rabbitListenerContainerFactory — a retry chain on the shared factory would change redelivery behaviour for every listener in the platform at once. It is configured with:

  • Bounded retry — AMQPConfig.EMAIL_JOB_MAX_RETRIES (3) retries after the first attempt, starting at 2s and tripling, capped at 20s.
  • A parking queue — once attempts are exhausted, a RepublishMessageRecoverer republishes the message to email-jobs-dead-queue (AMQPConfig.EMAIL_JOBS_DEAD_QUEUE) with the failure reason in the x-exception-message header.

email-jobs-dead-queue is a separate queue, not an x-dead-letter-exchange argument on email-jobs-queue. RabbitMQ queue arguments are immutable, so a broker-level DLQ would have meant draining and recreating the live queue; republishing from the application achieves the same outcome with no change to the existing queue.

Nothing consumes the parking queue

Parked jobs accumulate in email-jobs-dead-queue until someone looks at them in the RabbitMQ console. This turns silent loss into silent parking — which is strictly better, but an alert on queue depth (or a small admin view) is still needed before anyone reliably finds out that a customer's email never arrived. Treat a non-zero depth as a bug, not a backlog.

Why permanent failures are retried too

EmailSendFailedException carries a permanent flag — set for a 4xx from the provider (except 408 and 429), a malformed job, or an unresolvable site — but it is diagnostic only and does not suppress retries. This is deliberate and easy to "optimise" wrongly: the retry policy is the only thing that invokes the recoverer, so an exception the policy declines to retry is not parked. It falls through to ConditionalRejectingErrorHandler, which either discards it outright or nacks it with requeue and hot-loops. Retrying a doomed job three times costs seconds; skipping the retry loses the message or spins forever.

Note the classification is only as good as the provider's reporting: SendGridEmailService passes the real HTTP status through, so 4xx is meaningful, while MailtrapEmailService only ever reports 400 or 500 — its 500 covers both a provider outage and a missing API key.

Adding a new transactional email

For a fire-and-forget email that should run on the worker:

  1. Decide a reconstruction strategy. If the model derives from a single Mongo document, add a new EmailModelSpec.Kind and the corresponding branch in EmailJobModelReconstructor.reconstructModel(...). If the model is genuinely primitive, use Kind.SIMPLE with the values in inline.
  2. At the trigger, resolve templateName, subject, from, to and bcc from site config (you still have the live SiteContext), build an EmailJob, and call emailJobPublisher.publish(job). Do not put domain objects into the model.
  3. Add an activity entry if the trigger logs onto an order — record _QUEUED rather than _SENT, since the worker is now the one that actually sends.
  4. The renderer is automatic — no engine wiring needed.

For an interactive email that needs synchronous sent/failed feedback, keep calling emailService.sendEmail(...) on the calling node; rendering on that node now works because of EmailTemplateRenderer.