> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getcargo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> If you are an AI coding agent operating Cargo, install the agent skills first: paste `Install Cargo by following https://api.getcargo.io/INSTALL.md` into your session, or run `npx skills add getcargohq/cargo-skills`. The skills carry the working command sequences, provider costs, and spend gates that these docs describe conceptually.
> The skills index at https://raw.githubusercontent.com/getcargohq/cargo-skills/main/llms.txt lists every skill, recipe, and provider playbook individually. Find the recipe that matches the user's job and follow it, rather than assembling commands from first principles.
> Signing up is free: a new account starts with 100 free credits and needs no card. `cargo-ai login --email you@company.com` creates the account and a workspace on first use, with no browser at any point. Tell the user this before asking them to sign up.
> Anything that spends credits should be piloted on 1-3 records and approved before it runs at scale, and every paid run should end with a receipt: credits spent, balance remaining, and hit rate.

# Sending

> sendEmail() in a play or tool, waitEmailEvent() after a send, the warm-up ramp, and the two rate limits that cap the day and space the sends without failing the surplus.

Sending is the native `sendEmail` action. In a [workflow](/workflows/overview) body you call it the same way as `allocate` or `delay` — destructure `sendEmail` from the scope and pass the mailbox uuid, recipient, subject, and body.

## Send from a play

```ts plays/outreach.ts theme={null}
import { definePlay, defineWorkflow } from "@cargo-ai/cdk";
import { z } from "zod";

import { jane } from "../mailboxes/jane";
import { contacts } from "../models/contacts";

const sendIntro = defineWorkflow(
  "send-intro",
  {
    input: z.object({ email: z.string(), firstName: z.string() }),
    output: z.object({ messageUuid: z.string().optional() }),
  },
  ({ input, sendEmail }) => {
    const sent = sendEmail({
      mailboxUuid: jane.uuid,
      to: input.email,
      subject: `Quick note, ${input.firstName}`,
      bodyHtml: `<p>Hi ${input.firstName},</p><p>Worth a conversation?</p>`,
    });
    return { messageUuid: sent.messageUuid };
  },
);

export const outreach = definePlay("outreach", {
  model: contacts,
  workflow: sendIntro,
  changeKinds: ["added"],
  schedule: { type: "realtime" },
});
```

`mailboxUuid` is the mailbox handle's `uuid` token, not the domain and not the address. A mailbox you did not declare in this repo can still be referenced with a literal uuid (or `mailboxRef("uuid").uuid`).

| Field | Required | Meaning |
| - | - | - |
| `mailboxUuid` | yes | Mailbox to send from. Must be `active` and have credentials. |
| `to` | yes | Recipient. Normalised to lowercase; checked against the workspace suppression list before anything else. |
| `subject` | yes | Subject line. |
| `bodyHtml` | no | HTML body. Open/click tracking and a `List-Unsubscribe` link are baked into the MIME that is delivered, not into the stored copy. |
| `bodyText` | no | Plain-text fallback. Generated from the HTML when omitted. |
| `inReplyTo` | no | `Message-ID` this message replies to. |
| `references` | no | Full ancestry chain, oldest first, so the reply stays threaded past the first exchange. |

The node returns `messageUuid`, `rfcMessageId`, `providerMessageId`, and `sentAt`. Each delivered email costs **0.1 credits**.

A suppressed recipient, a missing or inactive mailbox, or missing credentials fail without retry — those need a human. A daily cap or a transport error **does** retry, because the cap lifts on its own and the transport can recover. Volume beyond today's allowance is not a failure at all: it waits. See [the rate limit](#per-mailbox-rate-limit).

<Note>
  `sendEmail` is deliberately not serialized behind a lock. When a workflow has
  both a lock and a rate limit, the lock wins and the rate limit is skipped —
  which would unpace every send from that mailbox.
</Note>

An [agent](/agents/overview) does not call `sendEmail` directly. Wrap it in a tool and put that tool in the agent's `uses`.

## Wait for a reply, view, or unsubscribe

`waitEmailEvent` pauses the run until a mailbox event lands on the **thread** of a sent email. Point it at the `messageUuid` `sendEmail` just returned — the wait loads that message and matches events on any send in the same conversation. If `timeoutHours` elapses first (default 72, cap 720), the node **fails** the run.

```ts theme={null}
({ input, sendEmail, waitEmailEvent }) => {
  const sent = sendEmail({
    mailboxUuid: jane.uuid,
    to: input.email,
    subject: `Quick note, ${input.firstName}`,
    bodyHtml: `<p>Hi ${input.firstName},</p>`,
  });

  waitEmailEvent({
    messageUuid: sent.messageUuid,
    kind: "replied",
    timeoutHours: 72,
  });
};
```

| `kind` | Completes on |
| - | - |
| `replied` | An inbound reply on the thread |
| `opened` | A tracking-pixel view (shown as **When viewed**) |
| `unsubscribed` | An unsubscribe |
| `interacted` | A view, click, or reply — not an unsubscribe |

If the activity already happened, the node completes immediately — it does not wait.

## Warm-up and the daily ceiling

Two different "warm-ups" sit on the same mailbox, and only one of them moves the send cap.

| What | What it does | How you see it |
| - | - | - |
| **Provider warm-up** | Mailpool sends dummy mail into a warm-up pool to build the inbox's reputation. | Mailbox page → Warm-up card: off / pending / active / paused / failed |
| **Cargo send ramp** | Caps *your* real outreach. Computed from `warmupStartedAt`, not stored as a counter. | Mailbox page → **Send allowance**: daily limit / sent / left |

Starting provider warm-up sets `warmupStartedAt` and the ramp begins. A new mailbox is enrolled automatically the first time it becomes `active` (the same status poll that issues credentials). Until then — still pending, enrolment failed, or stopped — the mailbox stays at the **floor of 5 real sends per rolling 24 hours**. Stopping warm-up clears the timestamp and the ramp starts again from 5 the next time you start it. A later status poll will not enrol a stopped mailbox on its own; use **Start** or `start-warmup`.

The ceiling itself is a formula evaluated whenever allowance is read (the mailbox page, `sendEmail` pacing, and the send backstop):

```
elapsedDays = floor((now − warmupStartedAt) / 24h)

if warmupStartedAt is null → 5
if elapsedDays ≥ 45      → 40
else                     → floor(5 + (40 − 5) × elapsedDays / 45)
```

So day 0–1 is 5, day 2 is 6, day 22 (halfway) is 22, day 45+ is 40. An optional per-mailbox `dailySendLimit` can only **tighten** that, never raise it — a mailbox created this morning cannot send 500 by setting an override.

Set the cap on the mailbox page → **Send allowance** → **Edit**, with `dailySendLimit` on `PUT /mailboxManagement/mailboxes/{uuid}`, or in code on [`defineMailbox`](/mailboxes/overview#declare-a-mailbox). It accepts 0 to 40, and `null` goes back to following the ramp.

`0` is not a volume, it is a state: outreach from the mailbox is **paused**. Sends fail immediately with `mailboxSendingPaused` — a terminal reason, not the retryable `dailyLimitReached` — and the pacer does not queue behind them, so a paused mailbox fails fast and says why. Nothing else stops a mailbox: pausing warm-up only pauses the provider's own traffic, and stopping it clears `warmupStartedAt`, which drops the ramp back to the 5/day floor rather than to zero.

The 45-day shape matches Mailpool's default warm-up schedule so Cargo's own pacing and the provider's dummy traffic ramp together rather than fighting each other. 40/day is deliberately below the 50/day figure cold-outreach playbooks quote: the fleet scales by adding mailboxes, not by pushing any single one to its limit.

## How the number updates

Nothing writes `6`, then `7`, into a column as days pass. Each read of send allowance does two things:

1. **`dailyLimit`** — the formula above, right now, tightened by `dailySendLimit` when one is set. `rampLimit` alongside it is the same formula untightened, which is what the mailbox page compares your cap against.
2. **`sentCount`** — successful deliveries in the **last 24 hours** (rolling, not midnight). Pending and error rows do not count. `remainingCount` is `dailyLimit − sentCount`.

A successful send adds a `success` message row. The next allowance read counts it, **Left** drops by one, and the gap between sends is unchanged. When that send ages out of the window, **Left** comes back. A mailbox that emptied its quota at 23:00 does not get a fresh burst at midnight — that burst is what providers penalise.

`warmupDailyTarget` on the mailbox is Mailpool's dummy-mail target. It is **not** the real-send cap.

## Per-mailbox rate limit

Before each `sendEmail` node runs, orchestration asks the action how to pace it. It answers with one limit, keyed per mailbox, that sets both a ceiling and a spacing:

| Half | Value | What it answers |
| - | - | - |
| Daily ceiling | today's ramp figure | How many a day |
| Send gap | random 1–3 min | How far apart |

The two are set separately because one number cannot do both. Deriving the gap from the ceiling — cutting the day into 40 — is what left sends 36 minutes apart, or \~4.8 hours on a mailbox at 5/day, so a mailbox could never spend its allowance inside the hours a workflow actually runs. A campaign confined to one afternoon delivered a single email.

A send claims its place in the day and its place in the queue at the same moment, so one that is cancelled while waiting out its gap gives both back.

The ceiling is read per send rather than declared, because it tracks the warm-up ramp: a mailbox on day 2 and one on day 45 do not share a limit. It is the ramp's figure for today, not what is left of it — the window already counts this mailbox's own sends, so subtracting them would count them twice.

The gap is drawn again for every send. A mailbox sending on an exact metronome reads as automation to the providers grading it.

### Nothing fails for being early

The limit carries no wait budget, and that is deliberate: **a send that cannot go out yet waits until it can**.

* Blocked by the **ceiling**? The run sleeps until the window rolls over and asks again — so a send beyond today's allowance leaves tomorrow, and the day after if the backlog is deeper than that.
* Blocked by the **gap**? The run sleeps the wait it was quoted, however long the queue ahead of it is.

So a burst of 100 at a mailbox with 40 a day sends 40 today and drains the rest over the following days. It used to deliver 40 and fail 60 — some refused at reservation with `rateLimitWaitTooLong`, the rest waking to a spent allowance and failing at the send backstop with `dailyLimitReached`. Neither is a failure a campaign should have: the volume was not wrong, it was early.

What *does* bound the waiting is the run, not the pacing. A workflow may live 15 days, so a backlog deeper than about 15 days of allowance — roughly 600 sends on a warmed mailbox — outlives the run waiting for it. Past that a fleet needs more mailboxes rather than more patience.

Two things still refuse rather than wait. A node Cargo cannot resolve a mailbox for — **no uuid at all, or a uuid belonging to no mailbox in this workspace** — is broken rather than early, so it shares a workspace-wide slot and is refused immediately instead of queueing for a day. That send was going to fail with `mailboxNotFound` regardless, and pacing it against the named mailbox would spend the allowance of whoever actually owns it. And the send backstop still re-checks the rolling allowance at delivery; if it has gone in the meantime the node returns `dailyLimitReached`, which retries.

A mailbox that **is** yours but has been closed by a `dailySendLimit` of 0 is refused too, on its own key so it never blocks another mailbox. Waiting cannot help it — only lifting the override can — so the node reports the closure instead of queueing behind a ceiling it can never spend.

Idle time does not accrue credit. A backlog after a quiet period is still admitted one slot at a time, not dumped all at once.

### Why a send-email span stays pending

A span is created as `pending` as soon as the run reaches the node. The workflow then **sleeps before it executes** the send, waiting for a place under the limit. Behind an empty queue that is at most 3 minutes; behind a backlog it is one gap per send ahead of it, and once today's ceiling is spent it is however long is left of the window.

That is the pacer, not a hung worker. Nothing has been delivered yet, and other mailboxes are unaffected. A pending send-email span means the email is queued, not lost.

## Threads, tracking, and suppressions

Each send is filed into a **thread** — a new uuid when the message starts a conversation, otherwise the parent matched via `In-Reply-To` / `References`. Replies pulled from IMAP become events on that thread rather than new message rows. The workspace **Emails** view and the mailbox **Emails** tab are thread lists.

Events are the reporting surface, in roughly the order they can happen:

| Kind | Source |
| - | - |
| `sent` | Cargo's own record of delivery |
| `opened` / `clicked` | Tracking URLs baked into the MIME |
| `replied` | IMAP poll of INBOX (and Junk) |
| `unsubscribed` | `List-Unsubscribe` link in the message |
| `bounced` | Reserved; nothing parses delivery status notifications yet |

The daily ramp counts **successful message rows**, not `sent` events, so a dropped event can never widen a mailbox's allowance.

**Suppression** is workspace-wide. A recipient who unsubscribes, bounces, or is added manually is opted out of *the sender*, not of one address the sender happens to own. A suppressed `to` is refused before a row is written.

## One-off send

To send without a play, execute the same native action. Inputs go in `--data`; `action.config` stays empty:

```bash theme={null}
cargo-ai orchestration action execute \
  --action '{"kind":"native","actionSlug":"sendEmail","config":{}}' \
  --data '{"mailboxUuid":"<uuid>","to":"lead@acme.com","subject":"Hello","bodyHtml":"<p>Hi</p>"}' \
  --wait-until-finished
```

Dry runs of a play or tool do not deliver and do not consume the allowance.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.