sendEmail action. In a workflow 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
plays/outreach.ts
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).
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.
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.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.
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):
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 writes6, then 7, into a column as days pass. Each read of send allowance does two things:
dailyLimit— the formula above, right now.sentCount— successful deliveries in the last 24 hours (rolling, not midnight). Pending and error rows do not count.remainingCountisdailyLimit − sentCount.
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 eachsendEmail 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:
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 aspending 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 viaIn-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:
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:

