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

# Mailer

A contact form with `@dotpress/mailer`, on Hono. Each message sends two emails, written in [MJML](https://mjml.io):

- a notification to the team, with the visitor as reply-to address: the team answers with the Reply button;
- an acknowledgement to the visitor, in their language (`Accept-Language: fr-CA` gets the French version);
- a shared layout, translated texts, and a text version converted from the HTML one;
- the message of the visitor escaped in the HTML version;
- previews of the templates in the browser, in development;
- SMTP with `SMTP_URL`, and [Mailpit](https://mailpit.axllent.org) to see the delivered emails in development.

Source: [`examples/mailer`](https://github.com/dotpressjs/dotpress/tree/main/examples/mailer).

## Run it

```sh
npx degit dotpressjs/dotpress/examples/mailer my-api
cd my-api
npm install
npm run dev
```

```sh
curl -X POST localhost:3000/contact -H 'content-type: application/json' \
  -H 'accept-language: fr' \
  -d '{"name":"Alex","email":"alex@example.com","message":"Hello"}'
```

Without `SMTP_URL`, the emails are printed in the terminal. `http://localhost:3000/previews` shows each template in each language, with sample data.

### With Mailpit

```sh
docker compose up -d # Mailpit
SMTP_URL=smtp://localhost:1025 npm run dev
```

The emails appear at `http://localhost:8025`, as an email client would show them.

## A template

The schema types the data of the email: a missing field is a type error where the email is sent.

```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 acknowledgement does not repeat the message: otherwise, anyone could send any text to any address through the form. In production, protect the form against abuse as well (rate limit, CAPTCHA).

## The provider

SMTP with `SMTP_URL`, the console in development only: in production, a missing configuration fails at startup instead of losing the emails.

```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' })

```

To use the HTTP API of your email service instead of SMTP, replace `createSmtpProvider()` with its provider, for example `createPostmarkProvider()` of `@dotpress/mailer/postmark`: the templates and the routes do not change.

## The routes

```ts file="<root>/../examples/mailer/src/routes.ts"
import { createRouter, empty, notFoundError, text } from '@dotpress/core'
import type { Mailer } from '@dotpress/mailer'
import { negotiateLocale } from './locale.ts'
import {
  contactNotification,
  contactReceived,
  previews,
  type PreviewName,
} from './mails/index.ts'

/** The contact form: one email to the team, one to the visitor. */
export const contactRouter = (contactTo: string) => {
  const router = createRouter()

  router.defineRoute({
    method: 'post',
    path: '/contact',
    status: 204,
    schema: (z) => ({
      body: z.object({
        name: z.string().trim().min(1).max(100),
        email: z.email(),
        message: z.string().trim().min(1).max(5000),
      }),
    }),
    meta: { summary: 'Send a message to the team', tags: ['contact'] },
    // ctx.mailer comes from mailerPlugin, and logs with the request logger.
    handler: async ({ req, mailer }) => {
      const { name, email, message } = req.body
      // fr-CA,fr;q=0.9 → fr; unsupported languages → the default locale.
      const locale = negotiateLocale(
        req.header('accept-language'),
        contactReceived.locales
      )

      await mailer.send(contactNotification, {
        to: contactTo,
        // The team answers the visitor with the Reply button.
        replyTo: { name, address: email },
        data: { name, email, message, locale: locale ?? 'en' },
      })
      await mailer.send(contactReceived, {
        to: { name, address: email },
        locale,
        data: { name },
      })
      return empty()
    },
  })

  return router
}

const previewNames = Object.keys(previews) as PreviewName[]

const page = (html: string) =>
  text(html, { headers: { 'content-type': 'text/html; charset=utf-8' } })

/**
 * Previews of the templates in the browser, with sample data: for
 * development only.
 */
export const previewsRouter = (mailer: Mailer) => {
  const router = createRouter()

  router.defineRoute({
    path: '/previews',
    handler: () =>
      page(
        `<!doctype html><title>Email previews</title><ul>${previewNames
          .flatMap((name) =>
            previews[name].locales.map(
              (locale) =>
                `<li><a href="/previews/${name}?locale=${locale}">${name} (${locale})</a></li>`
            )
          )
          .join('')}</ul>`
      ),
  })

  router.defineRoute({
    path: '/previews/:template',
    schema: (z) => ({
      query: z.object({ locale: z.string().optional() }),
    }),
    handler: async ({ req }) => {
      const name = req.params.template
      if (!(previewNames as string[]).includes(name)) {
        return notFoundError('Unknown template')
      }
      const mail = await previews[name as PreviewName].render(
        mailer,
        req.query.locale
      )
      return page(mail.html)
    },
  })

  return router
}

```

The previews are not served when `NODE_ENV` is `production`.

## Learn more

- [Emails](https://dotpress.dev/services/mailer.md)
- [Testing helpers](https://dotpress.dev/services/testing.md#emails)
