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:
- 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 unprefixedemail/orderPlaced(the name configured per site) tobootstrap/email/orderPlaced(ornova/email/...with abootstrapfallback for an override theme). That mapping was historically only configured inweb-mvc's view-resolver chain — therestandworkerSpring Boot apps depend oncommerce-coreonly and had no theme-aware resolver, sotemplateEngine.process("email/orderPlaced")simply could not find the template on those nodes. - 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,#datesand, since Thymeleaf 3.1 integrated it into core,#temporals. Nothymeleaf-extras-java8timedependency is needed. - The platform's site-aware
MessageSource(BadgerMessageSource) for#{...}lookups, set viasetTemplateEngineMessageSource(...). Because that message source reads the active site fromTenantContext, the renderer setsTenantContext(andLocaleContextHolder) around everyprocess()call and restores the previous values infinally, 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, formattedsubject,from,to,bcc,locale,siteId. - An
EmailModelSpecdescribing 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.
The email logo¶
Templates take the header logo from ${branding.logoUrl}, resolved by EmailBrandingService:
- the tenant's
email-brand-logo-urlconfig value (System > Site Config > email-branding), if set; - 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,submitOrderpipeline) - Order dispatch (
SendOrderDispatchEmailProcessor,dispatchOrderpipeline) - Order confirmation resend (
OrderEmailResendServiceImpl.resendConfirmationEmail) - Subscription dunning chaser (
SubscriptionDunningServiceImpl) - Password reset (
UserPasswordServiceImpl.sendPasswordResetEmail,SIMPLEkind) — 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 viaSIMPLEor a newKind, 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
RepublishMessageRecovererrepublishes the message toemail-jobs-dead-queue(AMQPConfig.EMAIL_JOBS_DEAD_QUEUE) with the failure reason in thex-exception-messageheader.
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:
- Decide a reconstruction strategy. If the model derives from a single Mongo document,
add a new
EmailModelSpec.Kindand the corresponding branch inEmailJobModelReconstructor.reconstructModel(...). If the model is genuinely primitive, useKind.SIMPLEwith the values ininline. - At the trigger, resolve
templateName,subject,from,toandbccfrom site config (you still have the liveSiteContext), build anEmailJob, and callemailJobPublisher.publish(job). Do not put domain objects into the model. - Add an activity entry if the trigger logs onto an order — record
_QUEUEDrather than_SENT, since the worker is now the one that actually sends. - 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.