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/examples/mailer.md.

Mailer

A contact form with @dotpress/mailer, on Hono. Each message sends two emails, written in MJML:

  • 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 to see the delivered emails in development.

Source: examples/mailer.

Run it

npx degit dotpressjs/dotpress/examples/mailer my-api
cd my-api
npm install
npm run dev
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

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.

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.

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

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