Send emails
Evolve sends transactional email in two steps. A service publishes a notification, such as “this account was created”, onto the internal bus. A mail service in your project reads it, renders a template, and sends the mail over SMTP.
| Part | Where it lives | Who owns it |
|---|---|---|
| The notification types, their validation, and the client that publishes them | @evolve-framework/user-notifications |
Evolve |
| The code that publishes them | The commercetools package, and your own services | Evolve and you |
| The templates, the rendering, and the SMTP transport | backend/notify in the reference sites |
You |
The publishing side knows nothing about mail. A project that runs no mail service sends no mail, and nothing else changes.
Which mails exist
Section titled “Which mails exist”Each notification has a type, an origin (the service that published it), a
timestamp, the recipients, and a storeContext with the store key and the
locale. Most types also carry variables. The schemas are in
@evolve-framework/schemas, under messages/notifications/.
| Type | Variables | Published by | Template in the reference notify |
|---|---|---|---|
account-created |
None | The commercetools package, in customerCreate |
B2C and B2B |
password-reset-request |
token, expiresAt |
The commercetools package, in customerPasswordResetRequest |
B2C and B2B |
password-reset-complete |
None | The commercetools package, in customerPasswordReset |
None |
associate-invited |
businessUnit, invitedBy, and a token when the invitation created the account |
The commercetools package, in businessUnitAssociateInvite |
B2B |
order-confirmed |
orderNumber, customer, billingAddress, shippingAddress |
The reference purchase service, when a commercetools order becomes Confirmed |
None |
order-shipped |
Any | The reference purchase service, when an order’s shipment state becomes Shipped |
None |
A type without a template is published but sends no mail: notify logs it and
acknowledges it. So out of the box, a shopper gets a welcome mail and a password
reset mail, and a B2B associate gets an invitation. Order confirmation is the
first mail most projects add; see Add a mail.
Only the commercetools package publishes notifications. The Medusa and SFCC packages publish none.
How notifications travel
Section titled “How notifications travel”Notifications share the internal bus with
internal events: the same
INTERNAL_EVENTS_TARGET, the same publishers. Two things set them apart:
- They are not dispatched to modules. No
getInternalEventConfig()handler receives them; the mail service reads them. QueuedUserNotificationClientvalidates each one before it publishes, and throws on one that does not match its schema. It passestypeandoriginto the publisher, which on Pub/Sub become message attributes, and a message group id derived from the recipients, so one recipient’s mails stay in order where the transport supports it.
Configure the publisher
Section titled “Configure the publisher”The commercetools package publishes through a client it keeps internally. You
reach it through
configureUserNotificationClient.
The reference sites configure it together with a client of their own, which
their order handlers use:
import { configureUserNotificationClient as configureFrameworkClient } from "@evolve-framework/commercetools";import { QueuedUserNotificationClient } from "@evolve-framework/user-notifications";import { resolvePublisher } from "#src/cloud.ts";import { config } from "#src/config.ts";
export const userNotificationClient = new QueuedUserNotificationClient();
export const configureUserNotificationClient = async (): Promise<void> => { const origin = config.COMPONENT_NAME; const publisher = resolvePublisher(config.INTERNAL_EVENTS_TARGET);
userNotificationClient.configure(origin, publisher); await configureFrameworkClient(origin, publisher);};Call it at startup, in every entry point of the service: the server, and the event handler if it runs as its own process.
- Left unconfigured, the framework’s client has no origin. Every
notification fails validation, and
customerCreateanswers the storefront with an internal error, so nobody can register. - Without
INTERNAL_EVENTS_TARGET, the reference’sresolvePublisherreturns aNoopPublisher, which logs the notification and discards it. Registration works, and no mail is sent.
The mail service
Section titled “The mail service”backend/notify is a service of its own. It serves no GraphQL and is not part
of the supergraph.
| Path | What it holds |
|---|---|
src/handler.ts |
Parses a notification with parseNotification, renders it, and sends it |
src/render.ts |
Which types have a template, the subject of each, and the links into the storefront |
src/emails/*.tsx |
One React Email template per type |
src/components/layout.tsx |
The frame every mail shares |
src/send.ts |
The SMTP transport, and which recipients this environment may write to |
src/config.ts |
The mail settings |
render.ts produces an HTML body and a plain-text body from the same template,
so the two cannot drift apart.
How it receives notifications depends on where it runs:
| Where | How |
|---|---|
| Locally, both reference sites | pnpm dev runs events.ts, which reads the Redis list INTERNAL_EVENTS_TARGET names with startDevelopmentListener. A message that fails goes to the notify-dlq list. |
| Google Cloud (B2C) | notify serve serves a Pub/Sub push endpoint at /events with createPubSubHandler. A handler that throws answers 500, so Pub/Sub delivers again. |
| Azure (B2B) | No cloud entry point yet. notify listen reads Redis only. Add a Service Bus entry point as shown in Deliver events to your handler. |
Neither reference site deploys notify to its test environment yet. When you
deploy it on Google Cloud, give it a push subscription on the internal events
topic and filter on attributes.type, so it receives only the types it has a
template for. The B2C reference’s configuration/discover/events.tf has a
subscription of that shape for the discover service.
Change a template
Section titled “Change a template”-
Edit the template in
backend/notify/src/emails/, such asaccount-created.tsx. Use the components from@react-email/componentsand inline styles: mail clients support no stylesheets and no modern layout. The header and footer every mail shares are insrc/components/layout.tsx. -
Change the subject, if you need to, in
src/render.ts. -
Compile the templates:
pnpm --filter @evolve-platform/notify codegenNode does not run JSX, so
codegenbundles each template intosrc/emails/generated/, whichrender.tsimports. That folder is gitignored. The rootpnpm codegenruns this step too, and the reference CI builds it before the Docker image.
The templates in the reference sites are placeholders in Dutch, with “Evolve” as the brand. Replace the copy, the colors, and the footer with your own.
Languages and links
Section titled “Languages and links”Every notification carries the shopper’s locale in storeContext.locale. The
reference templates use it for two things:
- the
langattribute of the mail, and - the market prefix of every link into the storefront, such as
/nlor/be/fr.render.tsreads the prefix fromlocalePrefixesin@evolve-packages/site-config, the same configuration the storefront routes with, and prefixesSTOREFRONT_URLwith it. A locale you add to the storefront gets correct links in mail without a change innotify.
The copy itself is in one language: the reference has no translation catalog
for mail. To send in several languages, choose the copy by locale in the
template, and the subject in render.ts:
const copy = { "en-GB": { preview: "Your account was created", heading: "Welcome" }, "nl-NL": { preview: "Je account is aangemaakt", heading: "Welkom" },};
export default function AccountCreated({ locale, name, url }: Props) { const text = copy[locale as keyof typeof copy] ?? copy["en-GB"];
return ( <Layout locale={locale} preview={text.preview}> <Heading as="h1">{text.heading}{name ? ` ${name}` : ""}</Heading> {/* ... */} </Layout> );}Add a mail
Section titled “Add a mail”How much you add depends on whether the notification is already published.
For a notification that is already published
Section titled “For a notification that is already published”order-confirmed, order-shipped, and password-reset-complete reach
notify today and are dropped for lack of a template. Order confirmation as the
example:
-
Add the template:
import { Button, Heading, Text } from "@react-email/components";import { Layout } from "../components/layout.tsx";type Props = {locale: string;name?: string;orderNumber: string;url: string;};export default function OrderConfirmed({ locale, name, orderNumber, url }: Props) {return (<Layout locale={locale} preview={`Order ${orderNumber} is confirmed`}><Heading as="h1">Thank you for your order</Heading><Text>{name ? `Hi ${name},` : "Hi,"} we have received order {orderNumber}.</Text><Button href={url}>Go to your account</Button></Layout>);} -
Give the type a subject and a template in
render.ts:Add a case to
compose:import OrderConfirmed from "#src/emails/generated/order-confirmed.js";// ...in compose()switch (notification.type) {// ...the cases already therecase "order-confirmed":return {subject: `Order ${notification.variables.orderNumber} is confirmed`,element: createElement(OrderConfirmed, {locale,name,orderNumber: notification.variables.orderNumber,url: storefrontUrl(locale, "/account"),}),};Add
"order-confirmed"to theTemplatedNotificationtype and to thesubjectsrecord.hasTemplatereads that record, so the handler stops dropping the type. Then build the element for it inrenderNotification, next to the two branches already there. -
Run
codegenas in Change a template, and add the type to the subscription filter of every deployed environment.
For an event that sends no notification yet
Section titled “For an event that sends no notification yet”When the mail belongs to an existing type but a different moment, publish that
type yourself. The reference purchase service shows the pattern for
order-confirmed. A commercetools subscription delivers OrderStateChanged and
OrderShipmentStateChanged messages to its event handler, which reads the
order and calls userNotificationClient.orderConfirmed() or orderShipped().
To send on another commercetools message:
- Add the message type to the commercetools subscription, in the reference
configuration/purchase/events.tf. - Add a case for it to
handleEventinbackend/purchase/src/events/index.ts. - Call the method of
UserNotificationClientfor the type you want to send.
For a mail that has no notification type
Section titled “For a mail that has no notification type”QueuedUserNotificationClient only publishes the six types above. It refuses
any other type, and parseNotification in notify refuses it as well. For a
mail of your own, such as a back-in-stock alert, publish the message with a
MessagePublisher and keep
the same envelope:
import { resolvePublisher } from "#src/cloud.ts";import { config } from "#src/config.ts";
const publisher = resolvePublisher(config.INTERNAL_EVENTS_TARGET);
type BackInStock = { recipient: { email: string; name: string }; storeContext: { storeKey: string; locale: string }; sku: string;};
export const publishBackInStock = async ({ recipient, storeContext, sku }: BackInStock) => { const type = "back-in-stock"; const origin = config.COMPONENT_NAME;
await publisher.publish( { type, origin, timestamp: new Date().toISOString(), recipients: [recipient], storeContext, variables: { sku }, }, { type, origin }, );};In notify, handle the type in handleNotification before it calls
parseNotification, and validate the message with a schema of your own.
Choose an email provider
Section titled “Choose an email provider”Evolve has no adapter per mail provider. The reference notify sends with
Nodemailer over SMTP, so any provider with an SMTP
relay works without a code change: point the settings at it.
notify reads its settings with the NOTIFY_ prefix first and falls back to
the bare name, so NOTIFY_MAIL_HOST wins over MAIL_HOST.
| Setting | Default | What it does |
|---|---|---|
MAIL_HOST |
localhost |
The SMTP server. |
MAIL_PORT |
1025 |
Its port. |
MAIL_SECURE |
false |
true connects over TLS from the start, as on port 465. With false, Nodemailer upgrades with STARTTLS when the server offers it, as on port 587. |
MAIL_USER, MAIL_PASSWORD |
None | The SMTP credentials. Both are secrets; without a user, notify sends without authentication. |
MAIL_FROM |
no-reply@example.test |
The sender address. Use one on a domain the provider has verified. |
MAIL_ALLOWED_DOMAINS |
@example.test,@spamok.com |
Outside production, the only recipient domains mail goes to. Empty means every domain. |
ENVIRONMENT |
None | production turns the domain filter off. |
STOREFRONT_URL |
http://localhost:3000 |
Where links in the mails point. |
INTERNAL_EVENTS_TARGET |
None | The bus notify reads from. |
The domain filter protects your shoppers from a test environment: a
notify pointed at a real provider by mistake still mails only test addresses.
It also means that production must set ENVIRONMENT to exactly production,
or MAIL_ALLOWED_DOMAINS to an empty value. Otherwise every shopper’s mail is
skipped, with the log line No permitted recipients, skipping email.
To send through a provider’s HTTP API instead of SMTP, replace the transport in
src/send.ts and keep allowedRecipients in front of it.
Test mails locally
Section titled “Test mails locally”The reference sites run Mailpit in Docker
Compose. It accepts every mail on port 1025 and delivers none, so a local
notify cannot reach a real person whatever its settings say. Its inbox is on
port 8025.
-
Start the local services, Mailpit included:
task docker:services -
Build the templates and the rest of the generated code:
pnpm codegen -
Start the services and the storefront.
pnpm devincludesnotify:pnpm dev -
Register an account in the storefront on localhost:3000, with an address at
example.test. An address on another domain is filtered out byMAIL_ALLOWED_DOMAINS. -
Open the inbox at localhost:8025. The welcome mail is there, with its HTML and its plain text.
When no mail arrives, check notify first: it listens on no port, so a stack
without it looks complete. It reads from a durable Redis list, so a
notification waits until notify runs. A growing count from this command means
notify is not running:
redis-cli -n 0 llen 1The list is named 1 because INTERNAL_EVENTS_TARGET ends in /1: the
framework reads the URL’s path as the queue name, not as a Redis database.
In tests
Section titled “In tests”Test a template through renderNotification, which needs neither a bus nor an
SMTP server. Build the input with parseNotification, so the test fails when
the schema changes. Assert on the plain-text body: it is what a mailbox shows
first, and a link that is missing from it is missing for some readers. This
test is from the B2B reference; in B2C, renderNotification accepts only the
types it has a template for, so narrow the parsed notification first.
import { parseNotification } from "@evolve-framework/user-notifications";import { expect, it } from "vitest";import { renderNotification } from "#src/render.ts";
it("links a password reset to the page that sets one", async () => { const email = await renderNotification( parseNotification({ type: "password-reset-request", origin: "purchase", timestamp: "2026-10-01T09:00:00.000Z", recipients: [{ email: "eva@example.test", name: "Eva" }], storeContext: { storeKey: "evolve", locale: "nl-NL" }, variables: { token: "xyz" }, }), );
expect(email?.text).toContain("/account/password-set-new?token=xyz");});End-to-end specs read mail through a mailbox fixture from
@evolve-storefront/testing, which reads
Mailpit locally. The reference sites tag those specs @mail. See
Testing for the suite.

