---
title: Email
description: Send an occasional request or notice by email from a Pro application's backend, see what the send accepted, and test the sends with a test double.
slug: /backend-packages/email
order: 25
visibility: public
redirects_from: []
---

# Email

Email lets an application's backend send an occasional request or notice, such as an order confirmation, a receipt, or an invitation, to up to five people at a time. Turn Zero Cloud sends each message from its own sending address under the application's name. Email send is a feature of the Pro plan.

The package provides your backend's side of that service. Its client sends one message under the application's [platform credential](/concepts/glossary#platform-credential). It reports what the send accepted, and it throws a typed refusal that gives the platform's refusal name and, when a count is reached, the time the application may send again.

## Use it when

Use Email when the backend must write to a person: a customer whose order shipped, a member invited to a team, a user who asked for a receipt. Declare the service by adding `{"kind": "email"}` to the manifest's `services` on a Pro application.

Do not use it for newsletters, marketing, or any mail to a long list. The plan counts each application's recipients by the hour, the day, and the month, and refuses a send past a count. For more volume, send through your own mail provider's API with your own key, as [Call an external API with an API key](/guides/call-an-external-api-with-an-api-key) describes.

## What it provides

- **A client**, `createEmailClient`, which [The client](#the-client) describes. The module has no dependencies and keeps no state.
- **A send.** `send(message)` sends one message to the recipients it names.
- **The response.** Each send returns `messageId` and the `recipients` the platform accepted and counted. An address named twice counts once.
- **A typed refusal.** Every other response throws an `EmailRefusal`. Its `refusal` member is the platform's refusal name, `members` contains the refusal's other values, and `resetsAt` is the time a refused send may go again.
- **A [test double](/concepts/glossary#test-double)**, `createEmailDouble` at `@turnzero/email/testing`, which answers a send as the platform does and records every send. [Test with the double](#test-with-the-double) shows it in use.

| Message member | Required | What it contains |
| --- | --- | --- |
| `to` | Yes | The recipients' addresses. |
| `cc` | No | More recipients, shown on the message as copied. A message has five recipients at most, `to` and `cc` together. |
| `subject` | Yes | One line, up to 1,000 characters. |
| `text` | Yes | The plain-text body. |
| `html` | No | An HTML body, sent beside the text. |
| `replyTo` | No | The one address a reply goes to. |

A message carries no blind copy and no attachment. Its subject, text, and HTML hold 256 kilobytes at most together.

## Availability

Version 0.1.0 is the package's current version: this page describes it, and `list_library` returns the version the library serves now. The entry includes the `testing` subpath from version 0.1.0. The entry holds the compiled modules, the package's [product statement and detailed contract](/concepts/glossary#product-statement-and-detailed-contract), and its integration guide. Turn Zero Cloud runs the email service on the Pro plan, and `read_usage` returns the month's recipients under `email_recipients`, with the hour's and the day's counts beside it.

## Related feature packages

[Logging](/backend-packages/logging) records a refused send, and the application's log stream shows each message's outcome. [Egress](/backend-packages/egress) calls your own mail provider's API, for mail past the plan's counts.

## The client

The package is published in the library as the npm package `@turnzero/email`, not on the npm registry; [Use the library](/guides/use-the-library#5-install-a-package-s-compiled-code) installs it with one line that needs no account or sign-in.

`createEmailClient(options)` builds the client from one of two forms:

| Option | Meaning |
| --- | --- |
| `api`, `token` | The platform's origin and the application's platform credential, from `TURNZERO_CLOUD_API` and `TURNZERO_CLOUD_TOKEN`. |
| `environment` | `development` or `production`, the environment header's value, where a credential needs one. The platform credential takes none, and the route refuses a value that contradicts it. |
| `fetch` | The fetch-shaped function the client sends through, the global `fetch` by default. |
| `transport` | In place of `api` and `token`: a function of your own, `(path, init)`, that adds the origin and the credential, such as the test double's `transport`. |

Give `transport`, or `api` and `token`, never both. At construction the client throws `EmailClientError`, code `client_options`, for both forms, a missing `token`, or an `api` that is not an http or https address. `send(message, { signal })` returns a promise of `{ messageId, recipients }`. A send the platform refuses rejects with `EmailRefusal`; [Refusals](#refusals) lists its members and names.

## Refusals

Branch on the refusal's name, never on its message. An `EmailRefusal` has `refusal`, `status`, `detail`, `members`, and `resetsAt`. These are the refusals a send meets most often; the [refusals page](/reference/refusals) lists every refusal the platform answers, with its cause and remedy.

| Refusal | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | 400 | A member of the message is malformed, a sixth recipient was named, or the message is past its size, and the detail identifies which, never an address. A member containing a NUL character (U+0000) or an unpaired UTF-16 surrogate is refused the same way. |
| `email_not_on_plan` | 409 | The application's plan does not include email send. `members` names the plan and the measure. |
| `email_not_declared` | 409 | The application's manifest declares no `email` service. |
| `email_limit_reached` | 429 | The send would pass the application's count for the hour, the day, or the month. |
| `end_user_credential_not_admitted` | 403 | The send ran under an end-user session. |
| `platform_credential_required` | 403 | The send ran under a session or a minted token. |
| `email_answer_unreadable` | As answered | The response was not the send route's answer, such as a gateway's error page. Read `read_usage` before sending again. |

A send refused `email_limit_reached` queued nothing. Its `members` give the `period`, the count that stood, the plan's figure, and the send's own recipients. Its `resetsAt` is the first moment of the next hour, day, or month, in UTC, when the application may send again.

## What an application must do

Declare the service on a Pro application, and send from the backend under the platform credential, as [Send email from your application](/guides/send-email-from-your-application) describes.

Send once for each event. The client retries nothing, because a send the platform accepted before its response was lost is already queued, and a second send would mail the person twice.

Keep a refused send from failing the request that made it. Record the refusal, and respond to the request. After `email_limit_reached`, hold the message until `resetsAt`, or send it through your own provider.

## Test with the double

`createEmailDouble(options)` from `@turnzero/email/testing` answers a send as the platform does, from the plan's figures and the clock the test gives it. Build the client with `createEmailClient({ transport: double.transport })`, or pass `double.fetch` as the client's `fetch`. Read what was sent in `double.sends`. A test runs this sample over the double.

```js
import assert from 'node:assert/strict';
import { createEmailClient, EmailRefusal } from '@turnzero/email';
import { createEmailDouble } from '@turnzero/email/testing';

// Two recipients an hour, and a clock fixed inside one hour.
const email = createEmailDouble({ cells: { hour: 2 }, now: () => new Date('2026-10-12T09:30:00Z') });
const client = createEmailClient({ transport: email.transport });

const sent = await client.send({ to: ['customer@example.com', 'customer@example.com'], cc: ['orders@example.com'], subject: 'Your order shipped', text: 'Your order is on its way.' });
assert.equal(sent.recipients, 2);
assert.equal(email.sends.length, 1);
// The hour's two recipients are spent, so the next send is refused by name, with the time it may go again.
await assert.rejects(
  client.send({ to: ['customer@example.com'], subject: 'Again', text: 'One more.' }),
  (error) => error instanceof EmailRefusal && error.refusal === 'email_limit_reached' && error.resetsAt.toISOString() === '2026-10-12T10:00:00.000Z',
);
```

The double refuses by the email route's own names, which [Refusals](#refusals) lists. Its setup gives these:

| Refusal | Cause | Remedy |
| --- | --- | --- |
| `email_not_on_plan` | The double was created with `cells: { supported: 0 }`, or with a count of 0. | Leave the cells at the defaults, Pro's figures. |
| `email_not_declared` | The double was created with `declared: false`. | Leave the service declared. |
| `email_limit_reached` | The sends passed a count the `cells` gave, read at the `now` clock. | Raise the count, move the clock, or `reset()` between tests. |
| `platform_credential_required`, `end_user_credential_not_admitted` | The double was created with `credential: 'session'`, `'minted'`, or `'end_user'`. | Leave the credential at the default, `'platform'`. |
| A scripted name | `refuseNext(name)` named a refusal, or `failNext()` an unreachable route. | Assert the name the test scripted. |
