> ## 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 every step in https://api.getcargo.io/agent-install.txt` 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://github.com/getcargohq/cargo-skills/blob/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, the warm-up ramp, and the per-mailbox rate limit that spaces sends across the day.

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.

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

## 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. Until then — warm-up off, never started, or stopped — the mailbox stays at the **floor of 5 real sends per rolling 24 hours**, even if it is months old. Stopping warm-up clears the timestamp and the ramp starts again from 5 the next time you start it.

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.

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.
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 spacing stays `24h / dailyLimit`. 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 for a rate limit. The policy is **spread**, keyed **per mailbox** (`mailboxManagement:mailboxes:<uuid>`), so two workflows targeting the same inbox serialize and unrelated mailboxes do not queue behind each other.

Spread turns "N per day" into **one send every `24h / N`**:

| Daily limit       | Spacing     |
| ----------------- | ----------- |
| 5 (never warmed)  | \~4.8 hours |
| 40 (fully warmed) | 36 minutes  |

Spacing is sized from the **daily ceiling**, not from what is left. Sizing it on the remainder would stretch as the day burned down (36 minutes at 40 left → 24 hours at one left) and the mailbox would never spend the allowance the ramp granted it.

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.

The limiter will not park a run longer than **remaining slots × spacing**. A mailbox with 40 left and 36-minute spacing admits at most \~40 waiting sends (about 24 hours). The 41st fails immediately with `rateLimitWaitTooLong` instead of sleeping for two days. When **Left** is already 0, a wait of 0ms still reaches the send backstop (`dailyLimitReached`, which retries); any positive wait is refused.

If the allowance cannot be read, the throttle **fails closed** to one send per day rather than running unthrottled, and still refuses to queue. A misconfigured node with no mailbox uuid shares a workspace-wide slot.

Even after a slot is admitted, delivery re-checks remaining count. If it is 0, the node returns `dailyLimitReached` and retries later.

### 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 the next slot on that mailbox. For a never-warmed inbox that is up to \~4.8 hours, and never more than today's remaining allowance.

That is the pacer, not a hung worker. Nothing has been delivered yet. Other mailboxes are unaffected. If the wait would exceed remaining capacity, the node errors with `rateLimitWaitTooLong` instead of staying pending.

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