Guide

mail

Order confirmations, password resets, invoices, team invites: almost every app sends email, and most end 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, checks every address and header, sends it through Resend, SMTP or your own transport, and shows you a live preview of every template while you work.

The examples on this page follow one scenario: Acme's app at app.acme.com emails Ada Lovelace when her order ships, sends Grace an invoice as a PDF, and copies Linus on the ones he cares about.

@kitr/mail runs on the server only. The provider binds nothing in the browser. To send without making a request wait for the mail provider, send from a job.

Install

kitr add mail

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

What kitr add does:

  • Installs @kitr/mail. Mail requires the server package, so kitr add also adds server (and its logger) when the project does not have them yet.
  • Adds '@kitr/mail' to packages and () => import('@kitr/mail/provider') to providers in kitrrc.ts.
  • Adds RESEND_API_KEY= and MAIL_FROM=App <[email protected]> under a # Mail comment to .env.example, and to .env when that file exists.
  • Writes config/mail.ts (shown in Configure) and regenerates config/index.ts.
  • Writes an example template, server/mail/welcome.tsx, exporting welcomeMail.
  • Adds mailPreview(server, [welcomeMail]) to server/main.ts, inside if (server.app.isDevelopment), with its imports. This step is skipped when the project has no server/main.ts.
  • Adds a ## kitr: mail section to AGENTS.md telling coding agents where templates live and how to send them.

kitr add mail asks no questions. To edit package.json without running the package manager, pass the global flag:

kitr add mail --no-install

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

No kit includes mail; add it to any kit with a server platform (fullstack, server, saas, site, realtime).

kitr remove mail reverses the provider, packages entry, dependency and AGENTS.md steps, and deletes config/mail.ts. It leaves your templates in server/mail/, the env variables, and the mailPreview lines in server/main.ts, which you delete by hand.

Configure

kitr add mail writes this file:

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

/**
 * Without a transport, development logs every message and production refuses to boot, so a
 * missing RESEND_API_KEY can never print password reset links to production logs.
 * For SMTP: createSmtpTransport({ host, auth }).
 */
export default defineConfig({
  from: process.env.MAIL_FROM ?? 'App <[email protected]>',
  transport: process.env.RESEND_API_KEY
    ? createResendTransport({ apiKey: process.env.RESEND_API_KEY })
    : undefined,
})

With RESEND_API_KEY set, messages go to Resend. Without it transport is unset: in development the provider uses the log transport, which prints each message to the console and keeps it in memory, and in production the provider refuses to boot. The comment's createSmtpTransport({ host, auth }) is shorthand: the real call takes the nodemailer module first, as shown in SMTP.

Option Default Description
from none The default sender for every message, as 'Acme <[email protected]>', '[email protected]' or { email, name }. send() throws when neither this nor the from send option is set.
transport the log transport, outside production Where messages go. In production the provider refuses to boot when this is unset or is the log transport.
previewPath /_kitr/mail Where mailPreview() serves rendered templates in development. false turns the preview pages off; { path } passed to mailPreview() wins over it.
Variable Used for
RESEND_API_KEY The Resend API key. When set, the generated config sends through Resend.
MAIL_FROM The default sender, such as Acme <[email protected]>.

Set MAIL_FROM to an address on a domain you have verified with your provider. Resend and most SMTP services reject a sender on an unverified domain.

Send your first email

Generate a template:

kitr make:mail order-shipped

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

That writes server/mail/order-shipped.tsx. Edit it to describe the email Ada gets when her order leaves the warehouse:

// server/mail/order-shipped.tsx
import { defineMail } from '@kitr/mail'

export interface OrderShippedProps {
  firstName: string
  orderId: string
}

export const orderShippedMail = defineMail<OrderShippedProps>('order_shipped', {
  subject: ({ orderId }) => `Order ${orderId} has shipped`,
  preview: { firstName: 'Ada', orderId: '1042' },
  render: ({ firstName, orderId }) => (
    <html lang="en">
      <body style={{ fontFamily: 'system-ui, sans-serif', lineHeight: 1.5 }}>
        <h1>Your order is on its way</h1>
        <p>Hi {firstName}, order {orderId} left our warehouse today.</p>
        <a href={`https://app.acme.com/orders/${orderId}`}>Track your order</a>
      </body>
    </html>
  ),
})

Send it from a controller. ctx.app is the request's app, and useMailer(app) returns the mailer the provider bound:

// server/controllers/orders-controller.ts
import type { HttpContext } from '@kitr/api'
import { useMailer } from '@kitr/mail'
import { orderShippedMail } from '#server/mail/order_shipped'

export default class OrdersController {
  async ship({ app, params }: HttpContext<{ id: string }>) {
    const sent = await useMailer(app).send(
      orderShippedMail,
      { firstName: 'Ada', orderId: params.id },
      { to: 'Ada Lovelace <[email protected]>' },
    )
    return sent
    // => { id: 'log-1', transport: 'log' }
  }
}

Register the route with api.post('/orders/:id/ship', [OrdersController, 'ship']) in server/routes.ts and call it. In development, with no RESEND_API_KEY, the server prints the message instead of sending it:

[@kitr/mail] to Ada Lovelace <[email protected]>: Order 1042 has shipped
Your order is on its way
Hi Ada, order 1042 left our warehouse today.
Track your order (https://app.acme.com/orders/1042)

The props are typed from the template, so leaving out orderId is a type error. send() renders the component, builds the text version, checks every address and header, hands the message to the transport and resolves with { id, transport }. Set RESEND_API_KEY and the same call sends a real email.

Write a template

defineMail(name, template) returns a template object. The name identifies it in previews (/_kitr/mail/order_shipped); the type parameter is its props.

Field Required Description
subject yes A string, or a function of the props that returns one.
render(props) yes The body as a React element. Rendered to static HTML with react-dom/server.
text(props) no The plain-text version. Left out, it is derived from the HTML. See Write the plain-text version.
preview no Props the development preview renders with.

Templates are plain React with no hooks or state: they render once, on the server, to a string. Put shared layout in a component and reuse it across templates:

// server/mail/layout.tsx
import type { ReactNode } from 'react'

export function AcmeLayout({ title, children }: { title: string; children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <title>{title}</title>
      </head>
      <body style={{ fontFamily: 'system-ui, sans-serif', lineHeight: 1.5, color: '#111' }}>
        <table width="100%" cellPadding={0} cellSpacing={0} role="presentation">
          <tbody>
            <tr>
              <td style={{ maxWidth: 560, margin: '0 auto', padding: 24 }}>{children}</td>
            </tr>
          </tbody>
        </table>
        <p style={{ fontSize: 12, color: '#666' }}>Acme Inc. · app.acme.com</p>
      </body>
    </html>
  )
}
// server/mail/invoice-ready.tsx
import { defineMail } from '@kitr/mail'
import { AcmeLayout } from './layout'

export interface InvoiceReadyProps {
  teamName: string
  invoiceNumber: string
  total: string
}

export const invoiceReadyMail = defineMail<InvoiceReadyProps>('invoice_ready', {
  subject: ({ invoiceNumber }) => `Invoice ${invoiceNumber} from Acme`,
  preview: { teamName: 'Analytical Engines', invoiceNumber: 'INV-2026-007', total: '$480.00' },
  render: ({ teamName, invoiceNumber, total }) => (
    <AcmeLayout title={`Invoice ${invoiceNumber}`}>
      <h1>Invoice {invoiceNumber}</h1>
      <p>
        The invoice for {teamName} is attached. The total is {total}.
      </p>
      <a href={`https://app.acme.com/invoices/${invoiceNumber}`}>View it online</a>
    </AcmeLayout>
  ),
})

Props are escaped like any React text, so a project name of <script> arrives as &lt;script&gt;. Mail clients do not load your stylesheets, so use inline style props. Images must be absolute URLs (https://app.acme.com/logo.png); @kitr/mail has no inline (CID) attachments. When the rendered HTML does not start with <!doctype or <html, @kitr/mail prepends <!DOCTYPE html>.

Use React Email components

React Email publishes components such as <Html>, <Button>, <Container> and <Text> that render to markup mail clients handle well. They are React components, so they work inside render:

pnpm add @react-email/components
// server/mail/team-invite.tsx
import { Body, Button, Container, Html, Text } from '@react-email/components'
import { defineMail } from '@kitr/mail'

export interface TeamInviteProps {
  inviter: string
  teamName: string
  token: string
}

export const teamInviteMail = defineMail<TeamInviteProps>('team_invite', {
  subject: ({ inviter, teamName }) => `${inviter} invited you to ${teamName}`,
  preview: { inviter: 'Grace', teamName: 'Analytical Engines', token: 'tok_preview' },
  render: ({ inviter, teamName, token }) => (
    <Html lang="en">
      <Body style={{ fontFamily: 'system-ui, sans-serif' }}>
        <Container>
          <Text>
            {inviter} invited you to join {teamName} on Acme.
          </Text>
          <Button href={`https://app.acme.com/invites/${token}`}>Accept the invite</Button>
        </Container>
      </Body>
    </Html>
  ),
})

@kitr/mail renders with renderToStaticMarkup from react-dom/server, not React Email's own render(). A component that only works through React Email's async render() needs a mailer you create yourself with createMailer({ renderHtml }); see Create a mailer without the provider.

Write the plain-text version

Every message has a text part, for clients that show no HTML and for spam filters that score its absence. Left out, @kitr/mail derives it from the HTML with htmlToText():

// scripts/text_preview.ts
import { htmlToText } from '@kitr/mail'

htmlToText(
  '<h1>Invoice INV-7</h1><p>Total: $40 &amp; tax</p><ul><li>Hosting</li><li>Support</li></ul><a href="https://app.acme.com/invoices/7">View invoice</a>',
)
// => 'Invoice INV-7\nTotal: $40 & tax\nHosting\nSupport\nView invoice (https://app.acme.com/invoices/7)'
  • <head>, <style> and <script> are dropped with their contents.
  • Links become text (url).
  • Closing p, div, h1–h6, li, tr and br become line breaks; three or more in a row collapse to one blank line.
  • &nbsp;, &lt;, &gt;, &quot;, &#39; and &amp; are decoded. Other named entities (&pound;) stay as written.

When the derived text reads badly, usually because of layout tables, write it yourself:

// server/mail/order-shipped.tsx
import { defineMail } from '@kitr/mail'

export const orderShippedMail = defineMail<{ firstName: string; orderId: string }>('order_shipped', {
  subject: ({ orderId }) => `Order ${orderId} has shipped`,
  text: ({ firstName, orderId }) =>
    `Hi ${firstName},\n\nOrder ${orderId} left our warehouse today.\nTrack it: https://app.acme.com/orders/${orderId}`,
  render: ({ firstName, orderId }) => (
    <p>
      Hi {firstName}, order {orderId} left our warehouse today.
    </p>
  ),
})

Send an email

On the server, useMailer(app).send(template, props, options) sends one message. options.to is required; everything else is optional.

Send option Type Description
to Recipient | Recipient[] Who gets it. Required; an empty list throws No recipient.
cc, bcc Recipient | Recipient[] Copies.
from Recipient Overrides from in config/mail.ts for this message.
replyTo Recipient Where replies go.
headers Record<string, string> Extra headers, such as List-Unsubscribe.
attachments { filename, content, contentType? }[] Files to attach. content is a string or a Uint8Array.

A Recipient is '[email protected]', 'Ada Lovelace <[email protected]>' or { email: '[email protected]', name: 'Ada Lovelace' }.

send() resolves with a SentMail:

Field Description
id The id the transport returned (Resend's email id, SMTP's messageId, log-<n> for the log transport), or a random UUID when it returned none.
transport The transport's name: log, resend, smtp or yours.

Store id next to the order when you want to match it against the provider's delivery webhooks later.

Address several recipients

Every address field takes one recipient or a list, in any of the three forms:

// server/services/invoice-service.ts
import type { KitrApp } from 'kitr/app'
import { useMailer } from '@kitr/mail'
import { invoiceReadyMail } from '#server/mail/invoice_ready'

export async function sendInvoice(app: KitrApp) {
  return useMailer(app).send(
    invoiceReadyMail,
    { teamName: 'Analytical Engines', invoiceNumber: 'INV-2026-007', total: '$480.00' },
    {
      to: ['Grace Hopper <[email protected]>', { email: '[email protected]', name: 'Ada Lovelace' }],
      cc: '[email protected]',
      bcc: '[email protected]',
    },
  )
}

Before anything is sent, each address is parsed with address() and checked. You can call the same helpers yourself:

// scripts/addresses.ts
import { address, formatAddress } from '@kitr/mail'

address('Ada Lovelace <[email protected]>')
// => { name: 'Ada Lovelace', email: '[email protected]' }
address('[email protected]')
// => { email: '[email protected]' }
formatAddress({ name: 'Lovelace, Ada', email: '[email protected]' })
// => '"Lovelace, Ada" <[email protected]>'

formatAddress() quotes display names that contain commas or quotes. Without the quotes, Lovelace, Ada <[email protected]> reads as two recipients. The check rejects whitespace, <, >, ,, ; and " in the address part and requires an @. It does not check that the domain exists: ada@acme passes, and your provider bounces it later.

Don't join several people into one string ('[email protected], [email protected]'); it throws is not an email address. Pass an array.

Set the sender and reply-to

The sender comes from from in config/mail.ts. Override it per message, and point replies at a person or a support inbox:

// server/services/support-service.ts
import type { KitrApp } from 'kitr/app'
import { useMailer } from '@kitr/mail'
import { orderShippedMail } from '#server/mail/order_shipped'

export async function sendFromSupport(app: KitrApp) {
  return useMailer(app).send(
    orderShippedMail,
    { firstName: 'Ada', orderId: '1042' },
    {
      to: '[email protected]',
      from: 'Acme Support <[email protected]>',
      replyTo: 'Linus <[email protected]>',
    },
  )
}

Without a from anywhere, send() and render() throw [@kitr/mail] No sender: set `from` in config/mail.ts or in send().

Add headers

Pass custom headers in headers. The common one is List-Unsubscribe, which Gmail and Yahoo require for bulk mail:

// server/services/digest-service.ts
import type { KitrApp } from 'kitr/app'
import { useMailer } from '@kitr/mail'
import { invoiceReadyMail } from '#server/mail/invoice_ready'

export async function sendWithUnsubscribe(app: KitrApp, token: string) {
  return useMailer(app).send(
    invoiceReadyMail,
    { teamName: 'Analytical Engines', invoiceNumber: 'INV-2026-007', total: '$480.00' },
    {
      to: '[email protected]',
      headers: {
        'List-Unsubscribe': `<https://app.acme.com/unsubscribe/${token}>`,
        'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
        'X-Acme-Invoice': 'INV-2026-007',
      },
    },
  )
}

Header names must be tokens (letters, digits and !#$%&'*+.^_`|~-), so X Acme throws Bad header name "X Acme".

Header injection

A line break in an address, a display name, the subject or a header value would let a value start a new header (Bcc: [email protected]) at the SMTP layer. @kitr/mail throws before sending instead, including when the subject is built from props a user typed:

// scripts/injection.ts
import { createLogTransport, createMailer } from '@kitr/mail'
import { orderShippedMail } from '#server/mail/order_shipped'

const mailer = createMailer({ from: '[email protected]', transport: createLogTransport({ print: false }) })

await mailer.send(orderShippedMail, { firstName: 'Ada', orderId: '1042\r\nBcc: [email protected]' }, { to: '[email protected]' })
// throws Error: [@kitr/mail] Line break in the subject.

Nothing reaches the transport when a check fails.

Attach a file

Attachments are { filename, content, contentType? }. content is a string or bytes, so a file read with node:fs goes straight in:

// server/services/invoice-service.ts
import { readFile } from 'node:fs/promises'
import type { KitrApp } from 'kitr/app'
import { useMailer } from '@kitr/mail'
import { invoiceReadyMail } from '#server/mail/invoice_ready'

export async function sendInvoicePdf(app: KitrApp, invoiceNumber: string) {
  const pdf = await readFile(`storage/invoices/${invoiceNumber}.pdf`)
  return useMailer(app).send(
    invoiceReadyMail,
    { teamName: 'Analytical Engines', invoiceNumber, total: '$480.00' },
    {
      to: 'Grace Hopper <[email protected]>',
      attachments: [
        { filename: `${invoiceNumber}.pdf`, content: pdf, contentType: 'application/pdf' },
        { filename: 'usage.csv', content: 'project,hours\nEngines,42\n', contentType: 'text/csv' },
      ],
    },
  )
}

The Resend transport base64-encodes each attachment, encoding string content as UTF-8 first; large files are encoded in chunks. The SMTP transport passes attachments to nodemailer unchanged. Providers cap message size, so link to large files instead of attaching them.

Send from a job

Sending inside a request makes the response wait for the mail provider, and a provider outage fails the request. Dispatch a job instead. The request returns at once, and the job retries with backoff when the provider is down:

// server/jobs/send-order-shipped.ts
import { defineJob } from '@kitr/jobs'
import { useMailer } from '@kitr/mail'
import { z } from 'zod'
import { orderShippedMail } from '#server/mail/order_shipped'

export const sendOrderShipped = defineJob('send_order_shipped', {
  schema: z.object({ orderId: z.string(), firstName: z.string(), email: z.string() }),
  retries: 5,
  backoff: 5_000,
  async run({ orderId, firstName, email }, { app }) {
    const sent = await useMailer(app).send(orderShippedMail, { firstName, orderId }, { to: email })
    return sent.id
  },
})

List the job in config/jobs.ts, then dispatch it from the controller:

// server/controllers/orders-controller.ts
import type { HttpContext } from '@kitr/api'
import { useJobs } from '@kitr/jobs'
import { sendOrderShipped } from '#server/jobs/send_order_shipped'

export default class OrdersController {
  async ship({ app, params, response }: HttpContext<{ id: string }>) {
    await useJobs(app).dispatch(sendOrderShipped, {
      orderId: params.id,
      firstName: 'Ada',
      email: '[email protected]',
    })
    return response.json({ queued: true }, { status: 202 })
  }
}

Pass ids and plain values in the payload, not rendered HTML: the job renders the template when it runs. A message that fails a check (a bad address, a line break) throws on every attempt, so validate addresses in the job's schema, where a bad payload is rejected at dispatch.

Render without sending

mailer.render(template, props, options?) builds the finished Message and sends nothing. Use it for snapshot tests, for storing a copy of what you sent, or to hand the message to code outside kitr:

// server/services/archive-service.ts
import type { KitrApp } from 'kitr/app'
import { useMailer } from '@kitr/mail'
import { orderShippedMail } from '#server/mail/order_shipped'

export async function renderShipped(app: KitrApp) {
  const message = await useMailer(app).render(
    orderShippedMail,
    { firstName: 'Ada', orderId: '1042' },
    { to: '[email protected]' },
  )
  return message
  // => {
  //   from: { name: 'App', email: '[email protected]' },
  //   to: [{ email: '[email protected]' }],
  //   subject: 'Order 1042 has shipped',
  //   html: '<html lang="en"><head></head><body ...',
  //   text: 'Your order is on its way\nHi Ada, order 1042 left our warehouse today.\nTrack your order (https://app.acme.com/orders/1042)',
  //   ...
  // }
}

render() runs the sender, subject, header and address checks too, but does not require a recipient: without to it returns to: [].

Preview templates in development

mailPreview(server, templates, options?) serves every template in the list as a web page. The recipe adds it to server/main.ts for the example template. Add each new template to the list:

// server/main.ts
import { mountRoutes } from '@kitr/api'
import { mailPreview } from '@kitr/mail'
import { createServer } from '@kitr/server'
import { config } from '../config'
import rc from '../kitrrc'
import { invoiceReadyMail } from './mail/invoice_ready'
import { orderShippedMail } from './mail/order_shipped'
import { welcomeMail } from './mail/welcome'
import { routes } from './routes'

export const server = createServer({ providers: rc.providers, config })
mountRoutes(server, routes)
// Rendered templates at /_kitr/mail in development.
if (server.app.isDevelopment) mailPreview(server, [welcomeMail, orderShippedMail, invoiceReadyMail])
URL What it shows
/_kitr/mail A list of the templates, linked by name.
/_kitr/mail/order_shipped The HTML, rendered with the template's preview props.
/_kitr/mail/order_shipped?orderId=2001&firstName=Grace The HTML, with query values replacing those props.
/_kitr/mail/order_shipped?text The plain-text version.
/_kitr/mail/missing 404 No such template.

Query values are always strings, so a template whose props are numbers or arrays only gets the right types from preview. Query values are escaped like any other prop.

Option Default Description
path /_kitr/mail Where the pages are served.
mailer a mailer with the log transport, sender preview@localhost The mailer used to render. Pass useMailer(server.app) to render with your real from.

Keep the isDevelopment guard. Previews render any template with any props a visitor types into the URL.

Use the mailer outside a request

The provider binds the mailer as mailer in the container, so anything with the app can reach it:

Where How
A controller useMailer(ctx.app)
A job useMailer(ctx.app) in run(payload, ctx)
An action or service during a request useMailer(useRequestApp()), with useRequestApp from @kitr/server
A script or the server file useMailer(server.app) after await server.boot()

useMailer(app) is app.container.use('mailer'). Called in the browser, or before the app has booted, it throws because nothing is bound.

Transports

A transport takes a finished message and delivers it. Three are built in; each follows the same setup.

Log

The default outside production. It prints each message's recipients, subject and text with console.info and keeps the message in memory, where mailer.sent reads it.

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

export default defineConfig({
  from: 'Acme <[email protected]>',
  transport: createLogTransport({ print: false }),
})
Option Default Description
print true false keeps messages without printing them.

No env variables. The kept messages are never cleared, so the list grows for as long as the process runs. The provider refuses to boot in production with the log transport, set or defaulted, so emails never land in production logs.

Resend

Sends through Resend's HTTP API with fetch. No package to install.

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

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

In .env:

RESEND_API_KEY=re_...
Option Default Description
apiKey required Your Resend API key.
fetch global fetch The fetch to call, for proxies or tests.

A response that is not 2xx throws [@kitr/mail] Resend <status>: <body>, such as Resend 403: The acme.com domain is not verified. The id Resend returns becomes SentMail.id.

SMTP

Sends through nodemailer to any SMTP server: Postmark, Amazon SES, Mailgun, Gmail or your own. Install nodemailer and pass the module in, so the bundler sees a real import:

pnpm add nodemailer
// config/mail.ts
import { createSmtpTransport, defineConfig } from '@kitr/mail'
import nodemailer from 'nodemailer'

export default defineConfig({
  from: process.env.MAIL_FROM ?? 'Acme <[email protected]>',
  transport: createSmtpTransport(nodemailer, {
    host: process.env.SMTP_HOST ?? 'smtp.acme.com',
    port: 587,
    auth: { user: process.env.SMTP_USER ?? '', pass: process.env.SMTP_PASS ?? '' },
  }),
})

In .env:

SMTP_HOST=smtp.postmarkapp.com
SMTP_USER=...
SMTP_PASS=...
Option Default Description
host required The SMTP server.
port 587 The port.
secure nodemailer's default true for implicit TLS, usually on port 465.
auth.user, auth.pass none Credentials.

The connection is created on the first send(), not at boot, so a wrong password shows up as an error from the first email. SentMail.id is nodemailer's messageId. These env names are yours to choose; kitr add does not write them.

Pick a transport per environment

Choose in config/mail.ts with plain conditions. This one sends through Resend when its key is set, SMTP when a host is set, and leaves transport unset otherwise, so development logs and production refuses to boot:

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

const transport = process.env.RESEND_API_KEY
  ? createResendTransport({ apiKey: process.env.RESEND_API_KEY })
  : process.env.SMTP_HOST
    ? createSmtpTransport(nodemailer, { host: process.env.SMTP_HOST, port: 2525 })
    : undefined

export default defineConfig({ from: 'Acme <[email protected]>', transport })

Point SMTP_HOST at Mailpit or a similar local inbox to see real rendered emails without sending any.

Send in production

The provider checks transport at boot when the app runs in production. With transport unset it throws [@kitr/mail] Set a transport in config/mail.ts (Resend, SMTP or your own).; with the log transport it throws [@kitr/mail] The log transport prints every email (reset links included) and is not allowed in production; set Resend, SMTP or your own transport in config/mail.ts. The generated config leaves transport unset when RESEND_API_KEY is empty, so a deploy with a missing key fails at boot:

RESEND_API_KEY= NODE_ENV=production kitr start
# fails at boot: [@kitr/mail] Set a transport in config/mail.ts (Resend, SMTP or your own).

Set the key on the host and the same deploy boots and sends. Before you go live:

  • Verify the sending domain with your provider and set SPF, DKIM and DMARC records for it.
  • Set MAIL_FROM to an address on that domain.
  • Send from jobs, so a provider outage retries instead of failing requests.
  • Keep the mailPreview call behind server.app.isDevelopment.

Create a mailer without the provider

createMailer(options) returns a mailer with no app or container. Use it in scripts, in tests of a single template, or to change how React is rendered:

// scripts/send_digest.ts
import { render } from '@react-email/render'
import { createMailer, createResendTransport } from '@kitr/mail'
import { teamInviteMail } from '#server/mail/team_invite'

const mailer = createMailer({
  from: 'Acme <[email protected]>',
  transport: createResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
  renderHtml: (element) => render(element),
})

await mailer.send(teamInviteMail, { inviter: 'Grace', teamName: 'Analytical Engines', token: 'tok_1' }, { to: '[email protected]' })

createMailer takes the same from and transport as config/mail.ts, plus renderHtml(element), which returns HTML or a promise of it. The default is renderToStaticMarkup from react-dom/server. Without a transport, it uses the log transport.

Commands

Command What it does
kitr make:mail <name> Writes a template to server/mail/<name>.tsx (kebab-case, nested names allowed). --force overwrites an existing file.
kitr eject stub mail Copies the generator's template to stubs/mail.ts, so make:mail writes your own starting point.

kitr make:mail order-shipped writes server/mail/order-shipped.tsx:

// server/mail/order-shipped.tsx
import { defineMail } from '@kitr/mail'

export interface OrderShippedProps {
  firstName: string
}

/** Send with `mailer.send(orderShippedMail, props, { to })`; preview at /_kitr/mail/order_shipped in development. */
export const orderShippedMail = defineMail<OrderShippedProps>('order_shipped', {
  subject: ({ firstName }) => `OrderShipped for ${firstName}`,
  preview: { firstName: 'Ada' },
  render: ({ firstName }) => (
    <html lang="en">
      <body style={{ fontFamily: 'system-ui, sans-serif', lineHeight: 1.5 }}>
        <h1>Hello {firstName}</h1>
        <p>Replace this with the message.</p>
      </body>
    </html>
  ),
})

The generator does not add the template to mailPreview(); add it to the list in server/main.ts.

Errors

Every error is a plain Error whose message starts with [@kitr/mail]. Nothing is sent when one is thrown.

Message When What to do
No sender: set `from` in config/mail.ts or in send(). No from in config or send options. Set from or MAIL_FROM.
No recipient. to is an empty list. Pass at least one recipient.
"<value>" in <field> is not an email address. An address is malformed, or several are joined in one string. Pass one address per entry.
Line break in <field> address. An address or display name contains CR or LF. Strip line breaks from user input.
Line break in the subject. The subject, often built from props, contains CR or LF. Strip line breaks from the props.
Bad header name "<name>". A header name has a space or another character not allowed in a token. Use names like X-Acme-Invoice.
Line break in header "<name>". A header value contains CR or LF. Strip line breaks from the value.
Resend <status>: <body> Resend answered with an error status. Read the body: usually an unverified domain or a bad key.
Set a transport in config/mail.ts (Resend, SMTP or your own). Boot in production with transport unset. Set a transport, or the env variable that selects one.
The log transport prints every email (reset links included) and is not allowed in production; ... Boot in production with the log transport. Use Resend, SMTP or your own transport in production.

SMTP errors come from nodemailer unchanged.

Testing

Outside production the provider uses the log transport, which keeps every message. In a test, boot an app with the mail provider and a quiet log transport, run the code that sends, and read mailer.sent:

// test/order-shipped.test.ts
import { createLogTransport, useMailer } from '@kitr/mail'
import mailProvider from '@kitr/mail/provider'
import { createTestApp } from '@kitr/testing'
import { describe, expect, it } from 'vitest'
import { orderShippedMail } from '#server/mail/order_shipped'

describe('order shipped email', () => {
  it('sends Ada her tracking link', async () => {
    const app = await createTestApp({
      providers: [mailProvider],
      config: {
        mail: { from: 'Acme <[email protected]>', transport: createLogTransport({ print: false }) },
      },
    })
    const mailer = useMailer(app)

    const sent = await mailer.send(
      orderShippedMail,
      { firstName: 'Ada', orderId: '1042' },
      { to: 'Ada Lovelace <[email protected]>' },
    )

    expect(sent).toEqual({ id: 'log-1', transport: 'log' })
    expect(mailer.sent).toHaveLength(1)
    const message = mailer.sent[0]!
    expect(message.to).toEqual([{ name: 'Ada Lovelace', email: '[email protected]' }])
    expect(message.subject).toBe('Order 1042 has shipped')
    expect(message.html).toContain('Hi Ada, order 1042 left our warehouse today.')
    expect(message.text).toContain('Track your order (https://app.acme.com/orders/1042)')
  })
})

mailer.sent is a list of Message objects, newest last, so the assertions you write are on plain fields:

Field Example assertion
to, cc, bcc expect(message.cc).toEqual([{ email: '[email protected]' }])
from, replyTo expect(message.from.email).toBe('[email protected]')
subject expect(message.subject).toMatch(/1042/)
html, text expect(message.text).toContain('https://app.acme.com/orders/1042')
headers expect(message.headers?.['List-Unsubscribe']).toBeDefined()
attachments expect(message.attachments?.[0]?.filename).toBe('INV-2026-007.pdf')

To test that nothing was sent, assert expect(mailer.sent).toHaveLength(0). Each createTestApp() gets its own mailer, so messages do not leak between tests that boot their own app.

To test through an HTTP route, send the request with createTestClient(server) from @kitr/testing and read useMailer(server.app).sent afterwards. The server uses your config/mail.ts, so its log transport prints unless you pass print: false.

To check a template's markup without a container, use createMailer() and render(), and snapshot the result:

// test/invoice-ready.test.ts
import { createMailer } from '@kitr/mail'
import { expect, it } from 'vitest'
import { invoiceReadyMail } from '#server/mail/invoice_ready'

it('renders the invoice email', async () => {
  const mailer = createMailer({ from: 'Acme <[email protected]>' })
  const message = await mailer.render(invoiceReadyMail, invoiceReadyMail.preview!, {
    to: '[email protected]',
  })
  expect(message.subject).toBe('Invoice INV-2026-007 from Acme')
  expect(message.text).toMatchSnapshot()
})

To test your code's handling of a transport failure, pass a transport that throws:

// test/transport-failure.test.ts
import { createMailer, type MailTransport } from '@kitr/mail'
import { expect, it } from 'vitest'
import { orderShippedMail } from '#server/mail/order_shipped'

it('surfaces a provider outage', async () => {
  const down: MailTransport = {
    name: 'down',
    send: async () => {
      throw new Error('503 Service Unavailable')
    },
  }
  const mailer = createMailer({ from: '[email protected]', transport: down })
  await expect(
    mailer.send(orderShippedMail, { firstName: 'Ada', orderId: '1042' }, { to: '[email protected]' }),
  ).rejects.toThrow('503')
})

Extend

A transport is an object with a name and a send(message) method that resolves with { id? }. The Message it receives is final: addresses are parsed and checked, the HTML and text are rendered. See MailTransport and Message.

// server/mail/postmark-transport.ts
import { formatAddress, type MailTransport } from '@kitr/mail'

/** Sends through Postmark's HTTP API. Attachments are left out to keep the example short. */
export function createPostmarkTransport(options: { serverToken: string }): MailTransport {
  return {
    name: 'postmark',
    async send(message) {
      const res = await fetch('https://api.postmarkapp.com/email', {
        method: 'POST',
        headers: {
          accept: 'application/json',
          'content-type': 'application/json',
          'x-postmark-server-token': options.serverToken,
        },
        body: JSON.stringify({
          From: formatAddress(message.from),
          To: message.to.map(formatAddress).join(', '),
          Cc: message.cc?.map(formatAddress).join(', '),
          Bcc: message.bcc?.map(formatAddress).join(', '),
          ReplyTo: message.replyTo ? formatAddress(message.replyTo) : undefined,
          Subject: message.subject,
          HtmlBody: message.html,
          TextBody: message.text,
          Headers: Object.entries(message.headers ?? {}).map(([Name, Value]) => ({ Name, Value })),
        }),
      })
      if (!res.ok) throw new Error(`Postmark ${res.status}: ${await res.text()}`)
      const data = (await res.json()) as { MessageID?: string }
      return { id: data.MessageID }
    },
  }
}

Register it in config/mail.ts like a built-in one:

// config/mail.ts
import { defineConfig } from '@kitr/mail'
import { createPostmarkTransport } from '#server/mail/postmark_transport'

export default defineConfig({
  from: 'Acme <[email protected]>',
  transport: createPostmarkTransport({ serverToken: process.env.POSTMARK_TOKEN! }),
})

Use formatAddress() whenever your transport writes an address into a string, so display names with commas stay one recipient. Throw on failure: the error reaches the caller of send(), and a job retries it.

Transports compose. This one falls back to a second provider when the first throws:

// server/mail/fallback-transport.ts
import type { MailTransport } from '@kitr/mail'

export function withFallback(primary: MailTransport, backup: MailTransport): MailTransport {
  return {
    name: `${primary.name}+${backup.name}`,
    async send(message) {
      try {
        return await primary.send(message)
      } catch {
        return backup.send(message)
      }
    },
  }
}

Next steps