For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/services/mailer.md.

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
yarn
pnpm
bun
deno
npm install @dotpress/mailer zod 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 
const invoiceReady: MailTemplate<{
    number: string;
    amount: string;
    url: string;
}>
invoiceReady
=
defineTemplate<z.ZodObject<{
    number: z.ZodString;
    amount: z.ZodString;
    url: z.ZodURL;
}, z.core.$strip>, string>(options: TemplateOptions<z.ZodObject<{
    number: z.ZodString;
    amount: z.ZodString;
    url: z.ZodURL;
}, z.core.$strip>, string>): MailTemplate<{
    number: string;
    amount: string;
    url: string;
}>

Defines an email template. The source (mjml or html) is rendered in three steps: t() inserts the translations, MJML compiles the result (cached by locale), then Mustache inserts the data.

const welcome = defineTemplate({
  name: 'welcome',
  layout: baseLayout,
  schema: z.object({ firstName: z.string(), url: z.url() }),
  subject: { en: 'Welcome {{firstName}}', fr: 'Bienvenue {{firstName}}' },
  translations: { cta: { en: 'Get started', fr: 'Commencer' } },
  mjml: ({ t }) => `<mj-section>…<mj-button href="{{{ url }}}">${t('cta')}</mj-button>…</mj-section>`,
})
defineTemplate
({
name: string

Name of the template, used in errors and logs.

name
: 'invoiceReady',
schema?: z.ZodObject<{
    number: z.ZodString;
    amount: z.ZodString;
    url: z.ZodURL;
}, z.core.$strip> | undefined

Schema of the data. Without it, the template accepts no data.

schema
:
import z
z
.
function object<{
    number: z.ZodString;
    amount: z.ZodString;
    url: z.ZodURL;
}>(shape?: {
    number: z.ZodString;
    amount: z.ZodString;
    url: z.ZodURL;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    number: z.ZodString;
    amount: z.ZodString;
    url: z.ZodURL;
}, z.core.$strip>
object
({
number: z.ZodString
number
:
import z
z
.
function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)
string
(),
amount: z.ZodString
amount
:
import z
z
.
function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)
string
(),
url: z.ZodURL
url
:
import z
z
.
function url(params?: string | z.core.$ZodURLParams): z.ZodURL
url
() }),
subject: string | Readonly<Record<string, string>>

Subject, by locale or the same for all. Rendered by Mustache without HTML escaping: 'Welcome {{firstName}}'.

subject
: 'Your invoice {{number}}',
html: Source
html
: '<p>Your invoice of {{amount}} is ready: <a href="{{{url}}}">download it</a>.</p>',
}) await
const mailer: Mailer
mailer
.
Mailer.send<{
    number: string;
    amount: string;
    url: string;
}>(template: MailTemplate<{
    number: string;
    amount: string;
    url: string;
}>, options: SendMailOptions<NoInfer<{
    number: string;
    amount: string;
    url: string;
}>>): Promise<SendResult>

Renders a template, then sends it.

send
(
const invoiceReady: MailTemplate<{
    number: string;
    amount: string;
    url: string;
}>
invoiceReady
, {
EnvelopeOptions.to: MailRecipients
to
: 'ada@example.com',
data: {
number: string
number
: 'F-2026-001',
amount: string
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

  1. Translations: the template function runs with t(), which returns the texts of the locale. The layout receives the result as content.
  2. MJML compiles it to HTML. This step only depends on the template and the locale: it is cached.
  3. 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.

SyntaxHTML versionSubject and text version
{{value}}escapedas is
{{{value}}}as isas is
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

ProviderImportUse
createMemoryProvider()@dotpress/mailertests: keeps the messages
createConsoleProvider()@dotpress/mailerdevelopment: prints the messages
createSmtpProvider()@dotpress/mailer/smtpany SMTP server, through nodemailer (Node.js only)
createSesProvider()@dotpress/mailer/aws-sesAmazon SES, through the AWS SDK
createResendProvider()@dotpress/mailer/resendResend
createSendgridProvider()@dotpress/mailer/sendgridSendGrid
createPostmarkProvider()@dotpress/mailer/postmarkPostmark
createBrevoProvider()@dotpress/mailer/brevoBrevo
createMailjetProvider()@dotpress/mailer/mailjetMailjet
createMailgunProvider()@dotpress/mailer/mailgunMailgun
createScalewayProvider()@dotpress/mailer/scalewayScaleway Transactional Email

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

ErrorWhen
MailDataErrorthe data does not match the schema
MailRenderErrorinvalid MJML, unknown translation key, no available locale
MailMessageErrorinvalid address, header or attachment, or a limit of the provider
MailSendErrorthe provider failed (status, cause)

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.