Email #

View this page as Markdown

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. 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 describes.

What it provides #

  • A client, createEmailClient, which 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, createEmailDouble at @turnzero/email/testing, which answers a send as the platform does and records every send. 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, 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.

Logging records a refused send, and the application's log stream shows each message's outcome. 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 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 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 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 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.

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 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.