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. 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
messageIdand therecipientsthe platform accepted and counted. An address named twice counts once. - A typed refusal. Every other response throws an
EmailRefusal. Itsrefusalmember is the platform's refusal name,memberscontains the refusal's other values, andresetsAtis the time a refused send may go again. - A test double,
createEmailDoubleat@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.
Related feature packages #
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. |