Skip to content

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.

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.

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.
  • QueuedUserNotificationClient validates each one before it publishes, and throws on one that does not match its schema. It passes type and origin to 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.

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 customerCreate answers the storefront with an internal error, so nobody can register.
  • Without INTERNAL_EVENTS_TARGET, the reference’s resolvePublisher returns a NoopPublisher, which logs the notification and discards it. Registration works, and no mail is sent.

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.

  1. Edit the template in backend/notify/src/emails/, such as account-created.tsx. Use the components from @react-email/components and inline styles: mail clients support no stylesheets and no modern layout. The header and footer every mail shares are in src/components/layout.tsx.

  2. Change the subject, if you need to, in src/render.ts.

  3. Compile the templates:

    pnpm --filter @evolve-platform/notify codegen

    Node does not run JSX, so codegen bundles each template into src/emails/generated/, which render.ts imports. That folder is gitignored. The root pnpm codegen runs 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.

Every notification carries the shopper’s locale in storeContext.locale. The reference templates use it for two things:

  • the lang attribute of the mail, and
  • the market prefix of every link into the storefront, such as /nl or /be/fr. render.ts reads the prefix from localePrefixes in @evolve-packages/site-config, the same configuration the storefront routes with, and prefixes STOREFRONT_URL with it. A locale you add to the storefront gets correct links in mail without a change in notify.

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>
);
}

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:

  1. 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>
    );
    }
  2. 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 there
    case "order-confirmed":
    return {
    subject: `Order ${notification.variables.orderNumber} is confirmed`,
    element: createElement(OrderConfirmed, {
    locale,
    name,
    orderNumber: notification.variables.orderNumber,
    url: storefrontUrl(locale, "/account"),
    }),
    };
  3. Run codegen as 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:

  1. Add the message type to the commercetools subscription, in the reference configuration/purchase/events.tf.
  2. Add a case for it to handleEvent in backend/purchase/src/events/index.ts.
  3. Call the method of UserNotificationClient for the type you want to send.

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.

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.

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.

  1. Start the local services, Mailpit included:

    task docker:services
  2. Build the templates and the rest of the generated code:

    pnpm codegen
  3. Start the services and the storefront. pnpm dev includes notify:

    pnpm dev
  4. Register an account in the storefront on localhost:3000, with an address at example.test. An address on another domain is filtered out by MAIL_ALLOWED_DOMAINS.

  5. 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 1

The 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.

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.