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
Section titled “Prerequisites”- A running dev environment (
pnpm dev) - Docker services started (
task docker:services). Mailpit runs onlocalhost:8025
1. Define the JSON Schema
Section titled “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
Section titled “2. Generate types”Register the schema URI in
backend/services/email-smtp/codegen.types.ts, then run codegen to
produce the Zod schema and TypeScript types from your new JSON Schema:
pnpm codegenThis generates a Zod validator and a TypeScript type (e.g.
UserShipmentDelayedNotification) into
email-smtp/src/generated/notifications.ts that are used for
compile-time and runtime validation.
3. Add a typed method to QueuedUserNotificationClient
Section titled “3. Add a typed method to QueuedUserNotificationClient”Open queued-user-notification-client.ts in the
@evolve-framework/user-notifications package (in the framework
repository, packages/user-notifications/src/) 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
Section titled “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
Section titled “5. Create the React Email template”Add a new template in backend/services/email-smtp/src/emails/:
import type { UserShipmentDelayedNotification } from "#src/generated/notifications.ts";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
Section titled “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
Section titled “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 "#src/generated/notifications.ts";
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)
Section titled “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
Section titled “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", },});
