> 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.

# Emails

`@dotpress/mailer` sends typed, multilingual emails: templates written in [MJML](https://mjml.io) 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.


```sh [npm]
npm install @dotpress/mailer zod mjml
```

```sh [yarn]
yarn add @dotpress/mailer zod mjml
```

```sh [pnpm]
pnpm add @dotpress/mailer zod mjml
```

```sh [bun]
bun add @dotpress/mailer zod mjml
```

```sh [deno]
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](https://dotpress.dev/examples/mailer.md):

```ts file="<root>/../examples/mailer/src/mails/layout.ts"
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>`,
})

```

```ts file="<root>/../examples/mailer/src/mails/contact-received.ts"
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:

```ts twoslash
// @errors: 2741
import { createMailer, createMemoryProvider, defineTemplate } from '@dotpress/mailer'
import { z } from 'zod'

const mailer = createMailer({ provider: createMemoryProvider(), from: 'noreply@example.com' })
// ---cut---
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' },
})
```

## 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.

| Syntax        | HTML version | Subject and text version |
| ------------- | ------------ | ------------------------ |
| `{{value}}`   | escaped      | as is                    |
| `{{{value}}}` | as is        | as is                    |

:::warning 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

```ts
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

| Provider                   | Import                      | Use                                                |
| -------------------------- | --------------------------- | -------------------------------------------------- |
| `createMemoryProvider()`   | `@dotpress/mailer`          | tests: keeps the messages                          |
| `createConsoleProvider()`  | `@dotpress/mailer`          | development: prints the messages                   |
| `createSmtpProvider()`     | `@dotpress/mailer/smtp`     | any SMTP server, through nodemailer (Node.js only) |
| `createSesProvider()`      | `@dotpress/mailer/aws-ses`  | Amazon SES, through the AWS SDK                    |
| `createResendProvider()`   | `@dotpress/mailer/resend`   | Resend                                             |
| `createSendgridProvider()` | `@dotpress/mailer/sendgrid` | SendGrid                                           |
| `createPostmarkProvider()` | `@dotpress/mailer/postmark` | Postmark                                           |
| `createBrevoProvider()`    | `@dotpress/mailer/brevo`    | Brevo                                              |
| `createMailjetProvider()`  | `@dotpress/mailer/mailjet`  | Mailjet                                            |
| `createMailgunProvider()`  | `@dotpress/mailer/mailgun`  | Mailgun                                            |
| `createScalewayProvider()` | `@dotpress/mailer/scaleway` | Scaleway 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](https://dotpress.dev/reference/mailer.md#providers).

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.

:::warning 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](https://dotpress.dev/examples/mailer.md) shows the pattern:
:::

```ts file="<root>/../examples/mailer/src/mailer.ts"
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

```ts
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](https://dotpress.dev/examples/mailer.md): 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):

```ts
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](https://dotpress.dev/services/events.md): the queue retries them, and the request does not wait for the provider. The [Tasks API example](https://dotpress.dev/examples/tasks-api.md) does so.

## Testing

The memory provider keeps the messages, and `expectMailSent()` of `@dotpress/testing` checks them:

```ts
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](https://dotpress.dev/services/testing.md).

## Errors

| Error              | When                                                              |
| ------------------ | ----------------------------------------------------------------- |
| `MailDataError`    | the data does not match the schema                                |
| `MailRenderError`  | invalid MJML, unknown translation key, no available locale        |
| `MailMessageError` | invalid address, header or attachment, or a limit of the provider |
| `MailSendError`    | the 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](https://dotpress.dev/reference/mailer.md) for every option.
