Emails
@dotpress/mailer sends typed, multilingual emails: templates written in MJML or HTML, with shared layouts, data checked by a Zod schema, and interchangeable providers (SMTP, Amazon SES, Resend, SendGrid, Postmark…). It works in a route, a worker or a script.
npm install @dotpress/mailer zod mjml
yarn add @dotpress/mailer zod mjml
pnpm add @dotpress/mailer zod mjml
bun add @dotpress/mailer zod mjml
deno add npm:@dotpress/mailer npm:zod npm:mjml
mjml is only needed for MJML templates: with html templates, it is never loaded. @dotpress/core is only needed for the plugin.
A layout and a template
A layout holds what every email shares; a template, the content of one email, in each language. From the Mailer example:
import { defineLayout } from '@dotpress/mailer'
/**
* Shared by every email: the templates provide the `<mj-section>` elements,
* placed in `content`. `t()` reads the translations of the layout.
*/
export const baseLayout = defineLayout({
translations: {
footer: {
en: 'Example Inc. · 1 Example Street, Example City',
fr: 'Example SAS · 1 rue de l’Exemple, Exempleville',
},
},
mjml: ({ t, content, locale }) => `
<mjml lang="${locale}">
<mj-head>
<mj-attributes>
<mj-all font-family="Helvetica, Arial, sans-serif" />
<mj-text font-size="15px" line-height="1.5" color="#1f2933" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f5f7">
<mj-section>
<mj-column>
<mj-text font-size="20px" font-weight="bold">Example</mj-text>
</mj-column>
</mj-section>
${content}
<mj-section>
<mj-column>
<mj-text font-size="12px" color="#6b7280" align="center">${t('footer')}</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`,
})
import { defineTemplate } from '@dotpress/mailer'
import { z } from 'zod'
import { baseLayout } from './layout.ts'
/**
* Sent to the visitor, in their language. It does not repeat the message:
* otherwise, anyone could send any text to any address through the form.
*/
export const contactReceived = defineTemplate({
name: 'contactReceived',
layout: baseLayout,
schema: z.object({ name: z.string() }),
// {{name}} is inserted by Mustache, after the translations.
subject: {
en: 'We received your message, {{name}}',
fr: 'Nous avons bien reçu votre message, {{name}}',
},
translations: {
greeting: { en: 'Hello {{name}},', fr: 'Bonjour {{name}},' },
body: {
en: 'Thank you for contacting us. We will answer within two business days.',
fr: 'Merci de nous avoir contactés. Nous vous répondrons sous deux jours ouvrés.',
},
signature: { en: 'The Example team', fr: 'L’équipe Example' },
},
mjml: ({ t }) => `
<mj-section background-color="#ffffff">
<mj-column>
<mj-text>${t('greeting')}</mj-text>
<mj-text>${t('body')}</mj-text>
<mj-text>${t('signature')}</mj-text>
</mj-column>
</mj-section>`,
})
The data is checked by TypeScript when you send, and by the schema at runtime:
const invoiceReady = defineTemplate({
name: 'invoiceReady',
schema: z.object({ number: z.string(), amount: z.string(), url: z.url() }),
subject: 'Your invoice {{number}}',
html: '<p>Your invoice of {{amount}} is ready: <a href="{{{url}}}">download it</a>.</p>',
})
await mailer.send(invoiceReady, {
to: 'ada@example.com',
data: { number: 'F-2026-001', amount: '€42.00' },Property 'url' is missing in type '{ number: string; amount: string; }' but required in type '{ number: string; amount: string; url: string; }'.
})
How a template is rendered
- Translations: the template function runs with
t(), which returns the texts of the locale. The layout receives the result as content.
- MJML compiles it to HTML. This step only depends on the template and the locale: it is cached.
- Mustache inserts the data (
{{name}}) in the HTML, the subject and the text version.
Translations can use the data too ({ en: 'Hello {{name}}' }), and contain HTML. A layout and its templates use the same format, MJML or HTML; the t() of a layout only reads its own translations. Without a schema, a template accepts no data; with one, the parsed data is rendered.
Escaping
Use {{value}} for any data, and in particular for user input: it is HTML-escaped. Keep {{{url}}} for the URLs built by your app. /, = and ` are not escaped, so that links work in every email client.
Without a text source, the text version is converted from the HTML: lines wrapped at 78 characters, links followed by their URL, images and hidden elements left out. The subject is a single line: line breaks become spaces.
Locales
The locales of a template are those of its subject and translations: every text needs all of them. The rendered locale is the first available among the requested locale (fr-CA, in any case), its language (fr), then the defaultLocale of the mailer and its language. Otherwise, rendering fails. A template without translations takes the locales of its layout.
Sending
import { createMailer } from '@dotpress/mailer'
import { createSmtpProvider } from '@dotpress/mailer/smtp'
const mailer = createMailer({
provider: createSmtpProvider(env.SMTP_URL),
from: 'Example <noreply@example.com>',
defaultLocale: 'en',
})
await mailer.send(contactReceived, {
to: { name: 'Ada Lovelace', address: 'ada@example.com' },
locale: 'fr-CA',
data: { name: 'Ada' },
})
send() also takes cc, bcc, replyTo, from, subject, attachments (inline ones with contentId), headers, and tags and metadata for the statistics and webhooks of the provider. Addresses, headers and attachments are checked before rendering: a line break in a name or a header throws, so user input cannot inject headers.
send() returns the messageId given by the provider. createMailer() also takes a default replyTo, and a logger that logs each sent message, without the addresses.
mailer.sendMessage({ to, subject, html }) sends a message rendered elsewhere. mailer.render(template, { locale, data }) renders without sending: previews and tests.
Providers
The HTTP API providers have no dependency (fetch). Most email services also have an SMTP interface. A provider is an object with a name and a send(message) method: write your own for another service. The settings of each provider and their limits are in the Mailer reference.
Switching provider changes one line: templates and calls stay the same. A message that the API of a provider cannot accept (too many recipients, an attachment type it refuses) throws before any request.
Never fall back to the console provider
The console provider is for development. In production, a missing provider configuration must fail at startup: falling back to the console would lose every message silently. The Mailer example shows the pattern:
import {
createConsoleProvider,
createMailer,
type MailProvider,
} from '@dotpress/mailer'
import { createSmtpProvider } from '@dotpress/mailer/smtp'
type Env = Record<string, string | undefined>
/**
* With `SMTP_URL`, the messages are sent over SMTP: Mailpit in development
* (compose.yaml), your email service in production (most of them have an
* SMTP interface). Without it, they are printed, in development only: in
* production, a missing configuration must fail at startup, not lose the
* messages.
*/
export const createProvider = (
env: Env = process.env
): MailProvider & { close?(): void } => {
if (env.SMTP_URL) return createSmtpProvider(env.SMTP_URL)
if (env.NODE_ENV === 'production') {
throw new Error('SMTP_URL is required.')
}
return createConsoleProvider()
}
/** The mailer: `mailerPlugin` gives it the logger of each request. */
export const createAppMailer = (
provider: MailProvider,
from = 'Example <noreply@example.com>'
) => createMailer({ provider, from, defaultLocale: 'en' })
In a route
import { mailerPlugin } from '@dotpress/mailer/plugin'
const app = await createApp({ adapter, plugins: [mailerPlugin(mailer)] })
ctx.mailer is the mailer, logging with the request logger. Using it without the plugin throws an explicit error. From the Mailer example: a contact form that notifies the team, and acknowledges receipt to the visitor in their language, without repeating their message (otherwise, anyone could send any text to any address through the form):
handler: async ({ req, mailer }) => {
const { name, email, message } = req.body
const locale = negotiateLocale(req.header('accept-language'), contactReceived.locales)
await mailer.send(contactNotification, {
to: contactTo,
replyTo: { name, address: email }, // the team answers with the Reply button
data: { name, email, message, locale: locale ?? 'en' },
})
await mailer.send(contactReceived, { to: { name, address: email }, locale, data: { name } })
}
Sending is neither queued nor retried. For emails that must arrive despite a provider outage, send them from an event listener: the queue retries them, and the request does not wait for the provider. The Tasks API example does so.
Testing
The memory provider keeps the messages, and expectMailSent() of @dotpress/testing checks them:
const outbox = createMemoryProvider()
const mailer = createMailer({ provider: outbox, from: 'noreply@example.com' })
await signUp(mailer, 'ada@example.com')
const mail = expectMailSent(outbox, { to: 'ada@example.com', template: welcomeMail, locale: 'en' })
expect(mail.html).toContain('https://example.com/confirm')
Render each locale of a template in a test, to catch a missing translation or an invalid MJML before your users do. See Testing helpers.
Errors
The first render of an MJML template loads MJML (about 100 to 300 ms); later renders are cached by template and locale. See the Mailer reference for every option.