Send email from your application #

View this page as Markdown

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

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 " via mail.ai.host". The development platform, dev.turnzero.ai, sends from no-reply@mail.dev.ai.host, shown as " 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. The credential names the application and its environment, so a send names neither. Your tool takes the package as 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.

// 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 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 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).
  • 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). 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 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.