Templates
Email content lives next to the notifier consumer: folders under TEMPLATES_DIR (default /app/templates). Each template needs a template.yaml and a body file. Nested folders are fine. Path segments starting with _ are skipped (use _components for shared React pieces).
Every id must be unique across the tree. With email configured, all templates must load or the process will not start.
The example consumer ships welcome, login OTP, and order-receipt templates in MJML, HTML, and React Email (examples/notifier-service/templates). Copy one closest to your layout and change the id / schema.
template.yaml
id: mjml-user-welcome
renderer: mjml # react-email | mjml | html; else email.defaults.renderer
account: ses # optional email account id
from: ${TEMPLATE_FROM_EMAIL} # optional; also fromName, subject, replyTo
schema:
type: object
additionalProperties: false
required: [userName, appName]
properties:
userName: { type: string }
appName: { type: string }schema is required and must be type: object. Supported pieces include required, additionalProperties, enum, string length/pattern/format (email, uri), number min/max, and array items / minItems.
Omit additionalProperties or set it false for strict payloads (unknown keys → 400). Set true only when you intentionally allow extras.
Body files
| Renderer | Source | Production |
|---|---|---|
react-email | index.tsx (default export or Email) | index.mjs (compiled) |
mjml | index.mjml | copied; Handlebars then MJML at send |
html | index.html | copied; Handlebars at send |
React Email
import { Html, Head, Body, Container, Text } from '@react-email/components';
export default function WelcomeEmail({
userName,
appName,
}: {
userName: string;
appName: string;
}) {
return (
<Html>
<Head />
<Body>
<Container>
<Text>Hello {userName}!</Text>
<Text>Welcome to {appName}.</Text>
</Container>
</Body>
</Html>
);
}The runtime image has react and @react-email/render. It does not include tsx or @react-email/components. Compile at image build time.
MJML / HTML (Handlebars)
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>Hello {{userName}}!</mj-text>
<mj-text>{{#if isPremium}}Thanks for upgrading.{{/if}}</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>- Only escaped
{{…}}.{{{…}}},{{&…}}→ 400. - Missing variables → 400.
- Unsafe URL schemes in strings (
javascript:,data:,vbscript:) are blanked.
Bake into a consumer image
Volume mounts into Distroless are not supported. Rebuild when templates change.
ARG NOTIFIER_IMAGE=ghcr.io/blockqueue/notifier:latest
FROM ${NOTIFIER_IMAGE} AS notifier
FROM node:24-alpine AS compile
WORKDIR /work
COPY --from=notifier /app/dist/compile-templates.cjs ./compile-templates.cjs
RUN npm init -y && npm install esbuild @react-email/components react react-dom
COPY ./templates /templates-src
RUN node ./compile-templates.cjs /templates-src /app/templates
FROM ${NOTIFIER_IMAGE}
COPY --from=compile /app/templates /app/templates
COPY ./config/config.yaml /config/config.yamlCanonical example: examples/notifier-service/Dockerfile.
Offline compile from the monorepo:
npm run compile-templates --workspace=notifier -- <input-dir> <output-dir>Local vs production load
| Mode | How | Loads |
|---|---|---|
Local API (npm run dev) | TEMPLATES_DIR / CONFIG_PATH | Source .tsx / .mjml / .html |
| Preview | example npm run dev | Ports 10001 / 10002; not the send API |
| Production image | baked paths | index.mjs + source MJML/HTML |
Add a template
- New folder under
templates/withtemplate.yamland body file. - Preview (
examples/notifier-servicescripts) or hit local API. - Rebuild the consumer image and redeploy notifier.
- Call
/email/sendwith the newtemplateIdfrom any app that has credentials.