Skip to main content

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 on localhost: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:

schemas/src/notifications/user-shipment-delayed.schema.json
{
"$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/:

backend/services/email-smtp/src/emails/shipment-delayed.tsx
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.

backend/services/email-smtp/src/messages/en-GB.json
{
"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:

backend/services/email-smtp/src/handlers/shipment-delayed.ts
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:

CloudHandler location
AWSemail-smtp/src/functions/aws/event-handler.ts
GCPemail-smtp/src/functions/gcp/event-handler.ts
Azureemail-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",
},
});