Docs
Control what an inbox can send
An agent inbox is only as safe as the smallest set of things it is allowed to do. These controls decide, before any provider is contacted, whether a send happens at all, becomes a draft for a human, or is refused outright.
This is the reference for the controls behind
agent email approvals. Open an inbox in the console, expand
Sending controls, and you are editing exactly the policy documented here. Only
a current workspace owner or administrator can change it; other authorised human members can
read it. An agent credential can read the policy but can never write one — the scope
policies:write is refused on a connection grant entirely.
Three layers, one decision
Policy rows exist at three scopes and they accumulate. Evaluation walks workspace, then project, then inbox, and any layer can deny. A narrower layer cannot loosen a wider one: an inbox cannot re-enable sending that the workspace has paused, and it cannot allow a recipient that the workspace blocks.
| Scope | Applies to | Typical use |
|---|---|---|
workspace | Every project and inbox you own | A global stop, or a company-wide blocked address |
project | Every inbox in that project | Holding a whole environment while something is investigated |
inbox | One address | The per-agent rules you edit day to day |
The policy response separates the two: send_enabled is this inbox's own switch,
effective_send_enabled is the answer after every inherited layer and the inbox's
own status have been taken into account. Show the effective value in any interface a human
makes decisions in. Inherited layers are returned in inherited so you can explain
why an inbox is paused rather than just that it is.
The order the rules are applied in
For each policy layer, in order, the send is evaluated against the deduplicated set of unique To, Cc and Bcc recipients:
- Sending disabled? Deny immediately with reason
send_disabled. - Any recipient on a block rule? Deny with
blocked_recipient. Blocks win over allows, always. - Is there a non-empty allow list? Then every recipient must match it.
One unmatched address denies the whole message with
recipient_not_allowed. An empty allow list adds no restriction. - Any approval rule matched? Collect its reason and keep going; approval does not short-circuit.
After all three layers: any collected approval reason turns the send into an approval request;
otherwise it is sent directly. A denial is a policy_denied response, HTTP 403,
with retryable: false — nothing about retrying it will change the outcome.
{
"error": {
"code": "policy_denied",
"message": "Workspace policy blocked this send.",
"request_id": "req_...",
"retryable": false,
"details": { "reason_codes": ["recipient_not_allowed"] }
}
} Pausing outgoing mail
Pause sending clears send_enabled. New sends are denied, and the
check runs again before a queued message is handed to the provider — so a message that was
already accepted into the queue is still stopped, provided it has not been submitted yet.
Four things pause does not do:
- It does not stop incoming mail. Receiving and reading continue, which is usually what you want while you investigate.
- It cannot recall a message already submitted to the provider. Once bytes are accepted upstream, they are gone.
- Resuming does not replay anything. Canceled sends stay canceled, and a submission whose outcome is unknown stays held for reconciliation rather than being retried.
- It cannot reactivate an inbox paused for another reason — an unresolved provisioning result or an ownership problem on a custom domain. Those clear through domain verification, not through this switch.
Recipient allow and block lists
Under Recipient and approval policy you can list allowed or blocked domains and exact addresses. In the console, one per line or comma-separated; over the API, four arrays. Each list holds up to 100 entries and is normalised to lower case.
| Rule | Matches | Does not match |
|---|---|---|
Domain example.com | anyone@example.com | anyone@mail.example.com — a domain rule is an exact suffix match on
@domain, not a subdomain wildcard. List each subdomain you mean.
|
Address ops@example.com | That address exactly, case-insensitively | Any other local part at the same domain |
Domains are validated as hostnames and addresses as email addresses, so a typo is rejected when you save rather than silently matching nothing. An allow list is the single highest-value control on this page: while an agent is new, restrict it to the handful of addresses it genuinely needs and a prompt-injected instruction to email someone else simply fails.
Requiring human approval
Setting approval_mode to always routes every send from this inbox to
Approvals. The API returns 202 with
result: "approval_required", an approval_id, a
draft_id and the reason codes — and no message exists yet.
Three properties worth knowing before you rely on it:
- A draft-only credential is always reviewed, whatever the inbox says. A
connection granted
read + draftholdsdrafts:submitbut notmessages:send, so its submissions become approval requests even on an inbox that permits direct sending. Reason code:draft_only_principal. - The approval is bound to the exact content. Both the approval and the draft
version carry a SHA-256 of the canonical payload. If the draft is edited after the approval
was created, approving it returns
version_conflictrather than sending something the reviewer did not read. The approval is consumed on use and cannot be replayed. - Approvals expire after 7 days. An expired approval can no longer be converted into a send; the agent must ask again. Authority is also re-checked at approval time and again in the send job, so a credential revoked between request and approval cannot be used to deliver the message.
Approval is not a replacement for the other controls: a direct send that clears approval is
still subject to recipient rules, the daily cap, entitlement and
attachment scan state. Operator-configured approval rules that
the console cannot edit are preserved and returned in
unmanaged_approval_rules so they remain visible rather than silently applied.
The daily recipient cap
daily_recipient_limit is optional and counts recipients, not messages, in
a UTC-day bucket. Setting it to 50 means fifty addresses across all of that inbox's messages
between one midnight UTC and the next.
The accounting is a reservation, not a counter, which is why it behaves well under failure:
| Outcome | Effect on today's bucket |
|---|---|
| Send queued | Recipients are reserved up front. |
| Provider accepted | The reservation is consumed. |
| Send failed | The reservation is released and the capacity returns. |
| Submission outcome unknown | The reservation is held. It is neither spent nor returned, because releasing it would mean overshooting the cap if the message did go out. |
Hitting the cap returns policy_denied with the message that the daily recipient
limit has been reached. Test-mode sends never consume a live cap — the meter is only applied to
live messages. Caps at wider scopes apply as well, and billing entitlement is a separate
ceiling on top: running out of allowance returns payment_required or
quota_exceeded, both HTTP 402.
Reading and writing the policy
The control API is for humans. It requires a human principal with a current membership, and it does not accept an agent API key as an administrator.
GET /v1/projects/{project_id}/inboxes/{inbox_id}/policy
Authorization: Bearer YOUR_SESSION_CREDENTIAL {
"version": 3,
"inbox_status": "active",
"can_manage": true,
"effective_send_enabled": true,
"send_enabled": true,
"approval_mode": "always",
"allow_domains": ["example.com"],
"allow_addresses": [],
"block_domains": [],
"block_addresses": ["restricted@example.com"],
"daily_recipient_limit": 50,
"unmanaged_approval_rules": [{ "type": "new_recipient_domain" }],
"inherited": [{ "scope": "workspace", "version": 1, "send_enabled": true }]
}
Send the version you read back as expected_version when you write.
That is the whole concurrency story: if someone else saved in between, you get
version_conflict (HTTP 412) and nothing changes, instead of your form quietly
overwriting their block list.
PUT /v1/projects/{project_id}/inboxes/{inbox_id}/policy
{
"expected_version": 3,
"send_enabled": true,
"approval_mode": "always",
"allow_domains": ["example.com"],
"allow_addresses": [],
"block_domains": [],
"block_addresses": ["restricted@example.com"],
"daily_recipient_limit": 50
} expected_version: 0 is correct only when the GET returned version 0, meaning the
inbox has no policy of its own yet. Every successful write returns the new version and records
an audit entry with a before-and-after diff, naming the human who made the change.
Reason codes
| Code | Outcome | Meaning |
|---|---|---|
send_disabled | Denied | Sending is paused at some layer. |
blocked_recipient | Denied | A recipient matched a block rule. |
recipient_not_allowed | Denied | A recipient was outside a non-empty allow list. |
unsupported_policy_rule | Denied | A rule this build cannot evaluate. Refusing is deliberate: a rule that cannot be understood must not be ignored. |
always | Approval | The inbox requires review for every message. |
new_recipient_domain | Approval | An operator-configured rule for unfamiliar recipient domains. |
attachments | Approval | The message carries attachments and a rule requires review for those. |
draft_only_principal | Approval | The credential can draft but not send. |
What this does not protect you from
- Content. Policy decides who may be written to and whether a human looks first. It does not read the message. If an agent can be talked into writing something damaging to an allowed recipient, an allow-list will not stop it.
- Prompt injection. Inbound mail is untrusted input. Nothing here makes an agent resistant to instructions embedded in a message it reads; a human approval step is a mitigation, not a fix.
- Delivery. Passing every control means the message was submitted, not that it arrived. See events and webhooks.
- Retroactive effect. Tightening a policy changes what happens next. It does not recall mail already accepted by the provider.