---
title: Send email from your application
description: Declare the email service on a Pro application, send an occasional request or notice from the backend, and read what each send accepted.
slug: /guides/send-email-from-your-application
order: 35
visibility: public
redirects_from: []
---

# Send email from your application

Prompt:

> Email each customer a confirmation when their order ships.

Also works:

- "Send a receipt by email when a payment goes through."
- "Email a team member when someone invites them."
- "Why was our order email refused with email_limit_reached?"

## What your tool does

- Checks the application's plan with `list_applications`, and asks you before it moves the application to Pro with `set_plan`.
- Adds the email service to the [manifest](/concepts/glossary#manifest) and resubmits it with `submit_manifest`.
- Takes the Email package from the library and writes the backend's send with its client, as step 3's sample does, and tests it over the package's [test double](/concepts/glossary#test-double).
- Reads the month's recipients with `read_usage`, and each message's outcome with `read_counters` and `read_logs`.
- Asks you who receives each message and what it says, where the prompt does not say.

## What you need

- An application on the Pro plan. Email send is a feature of Pro alone, and [Plan and usage](/guides/plan-and-usage) moves an application to Pro.
- The events that send a message, and who receives each one.

## Before your AI starts

This section is for your AI tool: what it checks and gathers before it begins. You don't need to do these steps yourself.

- A connected, signed-in tool ([Connect Claude Code or Codex](/getting-started/connect-your-tool)) and an application with a submitted manifest ([Deploy an application](/getting-started/deploy-an-application)).
- The application's identifier and its `plan`, from `list_applications`.

## Steps

### 1. Check the plan

Email send is a feature of the Pro plan. `read_plan_quotas` with `measure` set to `email-send-supported` returns 1 for Pro and 0 for Free and Standard. On a plan that reads 0, the platform refuses the manifest's email line and every send, `email_not_on_plan`.

The plan counts the recipients each application's sends address. A Pro application sends to at most 20 recipients an hour, 100 a day, and 1,000 a month, in UTC, and its two environments count together. `read_plan_quotas` returns these figures as `email-recipients-per-hour`, `email-recipients-per-day`, and `email-recipients-per-month`. The counts keep the service to occasional requests and notices, never newsletters or marketing mail.

### 2. Declare the email service

Add `{"kind": "email"}` to the manifest's `services` and resubmit it with `submit_manifest`. The declaration sets up nothing else and needs no setting, no mail provider's key, and no domain of your own.

Every message goes from the platform's own sending address under your application's name. On the production platform, turnzero.ai, the address is `no-reply@mail.ai.host`, and the person sees the message from "<application name> via mail.ai.host". The development platform, dev.turnzero.ai, sends from `no-reply@mail.dev.ai.host`, shown as "<application name> via mail.dev.ai.host". A reply goes to the message's reply-to address, where it names one.

A manifest that declares `email` stays recorded and deploys after a move off Pro, but every send is refused. Its next submission is refused `email_not_on_plan` until you remove the line.

### 3. Send from the backend

The backend sends with the Email package's client, built once from `TURNZERO_CLOUD_API` and `TURNZERO_CLOUD_TOKEN`, the [platform credential](/concepts/glossary#platform-credential). The credential names the application and its environment, so a send names neither. Your tool takes the package as [Use the library](/guides/use-the-library) describes.

This module emails a customer when their order ships. A test runs this sample over the Email package's test double and checks the send, the recipients counted, and a refused send.

```js
// src/notify.mjs: emailShipped(customer, order) emails the customer that their order shipped.
import { createEmailClient, EmailRefusal } from '@turnzero/email';

// The platform's origin and this copy's credential, from the settings it receives.
const email = createEmailClient({ api: process.env.TURNZERO_CLOUD_API, token: process.env.TURNZERO_CLOUD_TOKEN });

export async function emailShipped(customer, order) {
  try {
    const sent = await email.send({
      to: [customer.email],
      subject: `Order ${order.id} shipped`,
      text: `Hello ${customer.name}, your order ${order.id} is on its way and arrives ${order.eta}.`,
      replyTo: 'orders@example.com',
    });
    // messageId names the queued message; recipients counts the addresses accepted.
    return { messageId: sent.messageId, recipients: sent.recipients };
  } catch (error) {
    // A refused send queued nothing: record it, and let the request that called this succeed.
    if (!(error instanceof EmailRefusal)) throw error;
    console.error(`email refused ${error.refusal}: ${error.detail}`);
    return { refused: error.refusal, retryAt: error.resetsAt };
  }
}
```

A message names up to five recipients, in `to` and `cc` together, and an address named twice counts once. Its subject is one line of up to 1,000 characters. Its subject, its text, and an optional `html` body hold 256 kilobytes at most together. A message carries no blind copy and no attachment.

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

### 4. Handle a refused send

A refused send queued nothing. After `email_limit_reached`, the refusal's `resetsAt` is the first moment of the next hour, day, or month, in UTC, when the application may send again. Hold the message until then, or send it through your own mail provider.

For more mail than the counts allow, or for mail the service does not send, such as newsletters or attachments, use your own mail provider. Declare its HTTPS API as an upstream with your own key, as [Call an external API with an API key](/guides/call-an-external-api-with-an-api-key) describes.

### 5. Test the sends

The tests build the client over the package's test double, `createEmailDouble` from `@turnzero/email/testing`: pass `double.transport` as the client's `transport`, and read what was sent in `double.sends`. The double answers a send as the platform does, from the plan's figures and a clock the test gives it, and delivers nothing. [Test your application locally](/guides/test-your-application-locally) runs the tests offline.

## Expected result

- The send returns a `messageId` and the `recipients` the platform accepted and counted. The platform answers at once and promises no delivery time.
- The platform hands each message to its mail provider within seconds. The application's log stream counts each message under `email_sent` or `email_failed`, with one line per message naming its `message_id` and its `recipients`, never an address ([Read logs and counters](/guides/read-logs-and-counters#2-read-a-counter)).
- A failed line gives its `failure`: `refused` where the mail provider refused the message, `exhausted` where three attempts failed, or `orphaned` where a delivery was cut off twice.
- The mail provider stops mail to an address whose earlier mail bounced, or whose recipient marked a message as spam, and a message to it ends `refused`. That holds for every application on the same platform, turnzero.ai or dev.turnzero.ai.
- `read_usage` returns the month's recipients under `email_recipients`, with the hour's and the day's counts beside it ([Plan and usage](/guides/plan-and-usage#3-read-the-usage)). When the month's recipients first pass the warning fraction, the send writes one `usage warning` event into the log stream for each environment.

## Refusals

This table lists the refusals these steps most often meet; the [refusals page](/reference/refusals) lists every refusal with its cause and remedy.

| Refusal | Status | Cause | Remedy |
| --- | --- | --- | --- |
| `email_not_on_plan` | 409 | A manifest declaring `email`, or a send, on a plan without email send. The body names the plan and the measure. | Move the application to Pro with `set_plan`, or remove the `email` line and resubmit the manifest. |
| `email_not_declared` | 409 | A send from an application whose manifest declares no email service. | Add `{"kind": "email"}` to the manifest's services and resubmit it. |
| `email_limit_reached` | 429 | The send would pass the application's count for the hour, the day, or the month. Its `period`, `used`, `quota`, `requested`, and `resets_at` members name the count and when it admits again. | Hold the message until `resets_at`, or send it through your own mail provider on a declared upstream. |
| `invalid_request` | 400 | A member of the message is malformed, a `bcc` or a sixth recipient was named, or the message is past its size. The `detail` names the member or the bound, never an address. | Correct the named member. |
| `platform_credential_required` | 403 | A send ran under a session or a minted token. | Send from the backend under the platform credential. |
| `end_user_credential_not_admitted` | 403 | A send ran under an end user's session. | Send from the backend under the platform credential. |

## Related

- [Plan and usage](/guides/plan-and-usage) moves an application to Pro and reads the month's recipients.
- [Call an external API with an API key](/guides/call-an-external-api-with-an-api-key) sends mail through your own provider, past the plan's counts.
- [Read logs and counters](/guides/read-logs-and-counters) explains how to read the email counters and lines.
- [Test your application locally](/guides/test-your-application-locally) runs the tests on the packages' test doubles.
