Packages

Mail

ajo-kit-mail: validated messages, transports and delivery outcomes.

ajo-kit-mail gives an Ajo Kit application one careful path for email. Every message is validated and frozen before a transport sees it, delivery makes a single attempt under a hard deadline, and failures come back as a small set of codes with a retry verdict instead of provider text. It is meant for the mail an application sends on its own behalf: verification links, password resets, invitations, a copy of someone’s notes.

Install

Terminal
pnpm add ajo-kit-mail@0.3.0

ajo-kit is a peer dependency. nodemailer (^7.0.0) is an optional peer used only by the SMTP transport; applications that use the HTTP or capture transports do not install it.

Terminal
pnpm add nodemailer # only when using smtp()

Configure a transport

Call configure() once while the server boots. The official starter keeps this in src/mail.ts and imports it from the root wares.ts. It captures mail in memory during development and posts to an HTTP provider in production:

src/mail.ts
import { configure } from 'ajo-kit-mail'
import { capture } from 'ajo-kit-mail/capture'
import { http } from 'ajo-kit-mail/http'
import { env } from 'ajo-kit/platform'

export const mailbox = env('NODE_ENV') === 'production' ? null : capture({ keep: 20 })

configure({
  from: { address: 'notes@example.com', name: 'Ajo Notes' },
  transport: mailbox ?? http({
    url: 'https://api.provider.example/send',
    headers: () => ({ Authorization: `Bearer ${env('MAIL_TOKEN')}` }),
    body: mail => ({
      from: mail.from.address,
      to: mail.to.address,
      subject: mail.subject,
      text: mail.text,
    }),
  }),
})
src/wares.ts
import './mail'

configure() is plain assignment: it opens no socket and starts no timer, so it is safe to run again on every development reload. It throws a Refused error with code invalid-config for a missing transport or an invalid concurrency, label or observe, and for a development transport such as capture() when NODE_ENV is production. timeout and limit are checked each time a message is sealed.

OptionDefaultMeaning
transportrequiredThe delivery function.
fromrequiredDefault sender: a bare address, or { address, name }.
replyToDefault reply address.
timeout10,000Milliseconds allowed for one attempt.
limit262,144Maximum bytes of text plus HTML. This is also the hard maximum.
concurrency4Maximum sends in flight. It is backpressure, not throughput.
labeltransport labelName reported in delivery events; falls back to mail.
observeSynchronous observer for delivery events.

configure() also installs itself into ajo-kit’s mail seam. Code that already imports send from ajo-kit/mail now goes through the same validation, deadline and transport without being edited. Without any configuration, that seam throws in production and, in development, logs only the recipient and subject.

TypeScript
import { send } from 'ajo-kit/mail'

await send({ to: 'ana@example.com', subject: 'Welcome', text: 'Welcome to Ajo Notes.' })

The package is marked server-only, so Kit blocks importing it from client code. Keep your own src/mail.ts on the server too, for example by adding it to the guard patterns of kit() in vite.config.ts.

Send or deliver

Two functions send one message. They differ only in how they report failure.

TypeScript
import { deliver, send } from 'ajo-kit-mail'

// Resolves to the message id, or throws Refused or Undelivered.
const id = await send({ to: 'ana@example.com', subject: 'Your notes', text: summary })

// Never throws. Returns an Outcome to branch on.
const outcome = await deliver({
  to: { address: 'ana@example.com', name: 'Ana' },
  subject: 'Reset your password',
  text: `Choose a new password: ${link}`,
  kind: 'reset',
  key: resetId,
  expires: Date.now() + 60 * 60 * 1000,
})

if (!outcome.ok && outcome.kind === 'undelivered' && outcome.retryable) scheduleRetry()
Outcome
type Outcome =
  | { ok: true; id: string; transport: string }
  | { ok: false; kind: 'refused'; code: RefusalCode; error: Refused }
  | { ok: false; kind: 'undelivered'; code: DeliveryCode; retryable: boolean; error: Undelivered }

The id is the provider’s id when the transport returns one, otherwise the envelope’s own UUID. There are no automatic retries; the package makes one attempt and tells you whether another one makes sense. In a route action, turn a failed outcome into a message the person can act on:

src/notes/handler.ts
import { Failure, type Request } from 'ajo-kit'
import { authorize } from 'ajo-kit-auth'
import { deliver } from 'ajo-kit-mail'

export const actions = {
  email: async (req: Request) => {
    authorize(req)
    const outcome = await deliver({
      to: req.user!.email,
      subject: 'Your notes',
      text: await summary(req.user!.id),
    })
    if (!outcome.ok) throw new Failure(503, 'The message could not be delivered. Try again later.')
    return { message: 'Your notes were sent.' }
  },
}

Messages

FieldTypeRules
toaddressExactly one recipient. Credential mail must not fan out.
subjectstringRequired, at most 255 bytes.
textstringPlain-text body. text or html must be non-empty.
htmlstringOptional HTML body.
from, replyToaddressOverride the configured values for this message.
kindstringLabel for events such as reset, verify or invite. Default mail; a lowercase letter followed by up to 31 lowercase letters, digits or hyphens.
keystringIdempotency key forwarded to providers that accept one; up to 128 characters from A-Z a-z 0-9 . _ : -. It is never deduplicated locally.
expiresDate or numberHard deadline. Pass the expiry of the credential the message carries.
  • An address is a single mailbox of 3 to 254 characters, as a string or { address, name }. Names are at most 128 bytes and cannot contain " < > , ; : \.
  • Control characters are rejected in addresses, names, subject, kind and key. That is the boundary that stops header injection. Bodies may contain line breaks.
  • Text and HTML together may not exceed limit: 256 KiB by default, which is also the maximum.
  • The deadline is the earlier of timeout from now and expires. A message whose deadline has already passed is refused as expired; otherwise the transport receives an AbortSignal that fires at the deadline. Sends waiting for a concurrency slot fail as busy when their deadline arrives.

Transports

http()

A JSON provider over the global fetch, available on Node and on Ajo Engine. The body mapping is the only provider-specific code you write.

TypeScript
import { http } from 'ajo-kit-mail/http'
import { env } from 'ajo-kit/platform'

const transport = http({
  url: 'https://api.provider.example/send',
  headers: () => ({ Authorization: `Bearer ${env('MAIL_TOKEN')}` }), // read on every send
  body: mail => ({ from: mail.from.address, to: mail.to.address, subject: mail.subject, text: mail.text }),
  id: payload => (payload as { id?: string }).id,
})
  • It sends a POST with Content-Type: application/json unless you set another. A header factory lets a rotated token be read per send instead of captured at boot.
  • The message key becomes the Idempotency-Key header.
  • Success bodies are parsed as JSON up to 64 KiB; a larger body fails as a retryable connection error. Failure bodies are cancelled unread, and only the status is classified: 429 is throttled, 5xx is unavailable, 401 and 403 are auth, 408 is timeout, other 4xx are rejected.

The starter reads the endpoint, token and sender from the environment and declares them in kit.engine.env.required, so a production build states what it needs. See Build and configuration.

smtp()

TypeScript
import { smtp } from 'ajo-kit-mail/smtp'

const transport = smtp({ host: 'smtp.example.com', user: 'apikey', pass: process.env.SMTP_PASS })

One connection per message through nodemailer, with mandatory verified TLS. STARTTLS is required on the default port 587; use port: 465 with implicit: true for TLS from the first byte. The certificate is verified and TLS 1.2 is the floor. None of that is configurable, and there is no URL form: the connection takes discrete host, user and pass fields, with user and pass given together. name sets the EHLO name and defaults to the sender’s domain.

capture()

A bounded in-memory transport for development and tests. It keeps the newest 50 envelopes unless you pass keep, and with log: true it prints only the id, kind and recipient domain. configure() refuses it in production.

tests/mail.test.ts
import { configure, deliver, send } from 'ajo-kit-mail'
import { capture } from 'ajo-kit-mail/capture'

const mailbox = capture()
configure({ transport: mailbox, from: 'notes@example.com' })

await send({ to: 'ana@example.com', subject: 'Reset', text: 'Open https://notes.example.com/reset/abc' })
mailbox.link(/\/reset\//) // first matching URL in the last text body

mailbox.fail('throttled', 2) // the next two deliveries fail
const outcome = await deliver({ to: 'ana@example.com', subject: 'Reset', text: 'Again' })
// outcome: { ok: false, kind: 'undelivered', code: 'throttled', retryable: true, … }

The capture also exposes messages, last() and clear().

Custom transports

A transport is an async function that receives a Sealed envelope and may return { id }. Only seal() constructs a Sealed value, so a transport can never receive unvalidated input. Honour mail.signal, and pass failures through classify() so they keep the same sanitization:

TypeScript
import { classify, type Transport } from 'ajo-kit-mail'

const transport: Transport = async mail => {
  try {
    const result = await provider.send(
      { to: mail.to.address, subject: mail.subject, text: mail.text },
      { signal: mail.signal },
    )
    return { id: result.id }
  } catch (error) {
    throw classify(error) // reads shape only: name, code and status fields
  }
}

A transport may also carry a label for events, dev: true to be refused in production, and verify(signal) for probe(). The package exports seal, domain and encode (RFC 2047 encoded words) for transports that build their own headers.

Errors

Both error classes extend Kit’s Failure, so a send() that throws inside an action becomes an HTTP error. Neither ever echoes an address, a subject, a body or provider prose.

ErrorStatusCodes
Refused500no-transport, invalid-config, invalid-sender. These are configuration mistakes and are also logged as [mail] refused: code.
Refused422invalid-recipient, invalid-subject, invalid-name, invalid-kind, invalid-key, empty-body, too-large, expired
Undelivered502Retryable: timeout, busy, connection, throttled, unavailable. Final: tls, auth, rejected, unknown.

A refusal means nothing was sent and no provider was contacted. Undelivered means the transport accepted the envelope and the attempt failed; it may carry a protocol status hint such as smtp 451 or status 429.

Observability

TypeScript
configure({
  transport,
  from: 'notes@example.com',
  observe: delivery => console.log('[mail]', delivery.kind, delivery.outcome, delivery.code ?? '', `${delivery.ms}ms`),
})

Each delivery produces one event: id, kind, transport, outcome (sent, refused or undelivered), optional code and retryable, the recipient’s domain once the message validated, and the elapsed ms. Events are body-free by construction, and an observer that throws never changes delivery.

probe(timeout?) runs the transport’s credential check without sending and resolves to { ok: true } or { ok: false, error }. Of the shipped transports only smtp() has a check; the others report ok.

Source of truth: ajo-kit-mail README