mail

Write emails as React components, send them through your provider, and preview them in development.

BackendServerBuilt on React Email

Sign-up confirmations, password resets, invoices, invites: almost every app sends email, and almost every app ends up with a folder of HTML strings nobody wants to touch. In kitr an email is a React component with a subject. kitr/mail renders it to HTML and plain text, sends it through Resend, SMTP or your own transport, and shows you a preview of every template while you work on it.

It runs on the server only. To send email without making the request wait, send it from a job.

Install

kitr add mail
kitr make:mail welcome

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

// server/mail/welcome.tsx
import { defineMail } from 'kitr/mail'

export const welcome = defineMail<{ name: string }>('welcome', {
  subject: ({ name }) => `Welcome, ${name}`,
  preview: { name: 'Ada' },
  render: ({ name }) => (
    <html>
      <body>
        <h1>Hello {name}</h1>
        <a href="https://app.example.com/start">Get started</a>
      </body>
    </html>
  ),
})

React Email's components (<Html>, <Button>, <Tailwind>) work inside render; so does plain JSX.

Template field Description
subject A string, or a function of the props.
render(props) The body, as React.
text(props) The plain-text version. Left out, it's derived from the HTML, with links written as text (url).
preview Props the development preview renders with.

Send

import { useMailer } from 'kitr/mail'

await useMailer(app).send(welcome, { name: user.name }, { to: user.email })

The props are typed from the template, so a missing name is a type error. send() resolves with { id, transport }.

Send option Description
to, cc, bcc One recipient or a list: '[email protected]', 'Ada <[email protected]>' or { email, name }.
from Overrides the default sender from config/mail.ts.
replyTo Where replies go.
headers Extra headers, such as List-Unsubscribe.
attachments [{ filename, content, contentType }], where content is a string or bytes.

Addresses, the subject and headers are checked before anything is sent. A line break in any of them throws instead of letting a value smuggle in another header.

From a job

export const sendWelcome = defineJob('send-welcome', {
  schema: z.object({ name: z.string(), email: z.string().email() }),
  run: ({ name, email }, { app }) => useMailer(app).send(welcome, { name }, { to: email }),
})

The request returns at once, and if the mail provider is down the job retries with backoff.

Transports

// config/mail.ts
import { createResendTransport, defineConfig } from 'kitr/mail'

export default defineConfig({
  from: 'Acme <[email protected]>',
  transport: createResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
})

createSmtpTransport(nodemailer, { host, port, auth }) takes the nodemailer module (pnpm add nodemailer) so the bundler sees a real import. Without a transport the log transport prints every message and keeps it in mailer.sent, which is what tests read; production refuses to boot without a real one.

Option Default Description
transport log Where messages go. Production refuses to start without one.
from none The default sender.
previewPath /_kitr/mail Where previews are served in development. false turns them off.

Previews

mailPreview(server, [welcome, ...]) in server/main.ts serves /_kitr/mail with a list of templates and /_kitr/mail/welcome?name=Grace rendered with the given props (?text for the text version). The recipe adds it in development only.

Tests

Outside production, the log transport keeps every message it would have sent. Read them from mailer.sent:

await signUp({ name: 'Ada', email: '[email protected]' })

const [message] = useMailer(app).sent
expect(message.to[0].email).toBe('[email protected]')
expect(message.subject).toBe('Welcome, Ada')

mailer.render(template, props) returns the finished message without sending it, which is handy for snapshot tests.