Customizing email templates
This guide walks through adding a new transactional email notification to Evolve. You will define the data schema, generate types, wire the notification into a domain service, create the email template, and test locally.
See Email & notifications for the architecture overview.
Prerequisites
- A running dev environment (
pnpm dev) - Docker services started (
task docker:services). Mailpit runs onlocalhost:8025
1. Define the JSON Schema
Create a new schema file in schemas/src/notifications/. This defines
the notification type and the variables available to the email
template.
For example, a shipment-delayed notification:
{
"$id": "user-shipment-delayed",
"type": "object",
"allOf": [{ "$ref": "https://schemas.evolve.labdigital.nl/notifications/base.schema.json" }],
"properties": {
"type": { "type": "string", "const": "shipment-delayed" },
"variables": {
"type": "object",
"properties": {
"orderNumber": { "type": "string" },
"expectedDate": { "type": "string" },
"reason": { "type": "string" }
},
"required": ["orderNumber", "expectedDate"]
}
},
"required": ["type", "variables"]
}
The base.schema.json provides the shared envelope fields:
recipients and storeContext.
2. Generate types
Run codegen to produce the Zod schema and TypeScript types from your new JSON Schema:
pnpm codegen
This generates a Zod validator and a TypeScript type (e.g.
UserShipmentDelayedNotification) that are used for compile-time and
runtime validation.
3. Add a typed method to QueuedUserNotificationClient
Open
backend/packages/user-notifications/src/queued-user-notification-client.ts
and add a convenience method:
async shipmentDelayed(
params: UserShipmentDelayedNotification
): Promise<void> {
await this.queueMessage(
"shipment-delayed",
params.recipients,
params.storeContext,
params.variables,
);
}
This ensures callers get full type checking for the notification payload.
4. Publish from the domain service
In the service where the business event happens, call the new method:
await notificationClient.shipmentDelayed({
recipients: [{ email: customer.email, name: customer.name }],
storeContext: { storeKey, locale },
variables: {
orderNumber: order.orderNumber,
expectedDate: "2025-02-15",
reason: "Weather delay",
},
});
The QueuedUserNotificationClient validates the payload against the
Zod schema, generates a deterministic messageGroupId from the
recipient list, and publishes to the notification queue.
5. Create the React Email template
Add a new template in backend/services/email-smtp/src/emails/:
import type { UserShipmentDelayedNotification } from "@evolve-schemas/notifications";
import { Base, H1 } from "../components";
import { useTranslations } from "../i18n";
type Props = {
locale: string;
variables: UserShipmentDelayedNotification["variables"];
};
const ShipmentDelayed = ({ locale, variables }: Props) => {
const t = useTranslations("ShipmentDelayed", locale);
return (
<Base locale={locale} preview={t("preview", { orderNumber: variables.orderNumber })}>
<H1>{t("title")}</H1>
{/* Template body */}
</Base>
);
};
ShipmentDelayed.PreviewProps = {
locale: "nl-NL",
variables: {
orderNumber: "ORD-2025-001",
expectedDate: "2025-02-15",
reason: "Weather delay",
},
};
export default ShipmentDelayed;
Templates use @react-email/components for cross-client HTML email
rendering. The render() function produces both HTML and plain-text
versions in parallel.
6. Add translations
Add translation keys for every supported locale. Messages use ICU MessageFormat syntax for interpolation and plurals.
{
"ShipmentDelayed": {
"preview": "Shipment delayed for order #{orderNumber}",
"subject": "Your shipment has been delayed",
"title": "Shipment Delayed",
"body": "Your order {orderNumber} is now expected on {expectedDate}."
}
}
Repeat for all supported locales: de-DE, en-GB, es-ES, fr-BE,
fr-FR, it-IT, nl-BE, nl-NL.
7. Register the handler
Add a case to the switch in
backend/services/email-smtp/src/handlers/index.ts:
case "shipment-delayed":
await handleShipmentDelayed(event);
break;
Then create the handler function:
import ShipmentDelayed from "../emails/shipment-delayed";
import { useTranslations } from "../i18n";
import { render } from "#src/render.ts";
import { mapRecipients } from "#src/utils.ts";
import { sendMail } from "#src/mailer.ts";
import type { UserShipmentDelayedNotification } from "@evolve-schemas/notifications";
export const handleShipmentDelayed = async (
data: UserShipmentDelayedNotification,
): Promise<void> => {
const t = useTranslations("ShipmentDelayed", data.storeContext.locale);
const { text, html } = await render(
ShipmentDelayed({
locale: data.storeContext.locale,
variables: data.variables,
}),
);
await sendMail({
recipients: mapRecipients(data.recipients),
subject: t("subject"),
html,
text,
});
};
8. Subscribe the queue (per cloud)
If your notification type uses a new event name, you need to add a subscription in the cloud function handler. The email service has separate entry points per cloud provider:
| Cloud | Handler location |
|---|---|
| AWS | email-smtp/src/functions/aws/event-handler.ts |
| GCP | email-smtp/src/functions/gcp/event-handler.ts |
| Azure | email-smtp/src/functions/azure/internal-events/index.ts |
All handlers call processNotificationEvent(data). The subscription
routing happens at the infrastructure level (EventBridge rules, Pub/Sub
subscriptions, or Service Bus topics).
9. Test locally with Mailpit
With task docker:services running, Mailpit captures all SMTP traffic
on localhost:1025. Open the Mailpit web UI at
localhost:8025 to inspect rendered emails.
The EMAIL_ALLOWLIST variable filters recipients in non-production
environments. Set it to your domain (e.g. @labdigital.nl) to prevent
accidental delivery to real customers.
To trigger the notification locally, publish a test event through the notification client or call the handler directly in a test:
await processNotificationEvent({
type: "shipment-delayed",
recipients: [{ email: "test@labdigital.nl", name: "Test User" }],
storeContext: { storeKey: "main-store", locale: "en-GB" },
variables: {
orderNumber: "ORD-2025-001",
expectedDate: "2025-02-15",
},
});