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.

ScopeApplies toTypical 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:

  1. Sending disabled? Deny immediately with reason send_disabled.
  2. Any recipient on a block rule? Deny with blocked_recipient. Blocks win over allows, always.
  3. 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.
  4. 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.

RuleMatchesDoes 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 + draft holds drafts:submit but not messages: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_conflict rather 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:

OutcomeEffect on today's bucket
Send queuedRecipients are reserved up front.
Provider acceptedThe reservation is consumed.
Send failedThe 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

CodeOutcomeMeaning
send_disabledDeniedSending is paused at some layer.
blocked_recipientDeniedA recipient matched a block rule.
recipient_not_allowedDeniedA recipient was outside a non-empty allow list.
unsupported_policy_ruleDeniedA rule this build cannot evaluate. Refusing is deliberate: a rule that cannot be understood must not be ignored.
alwaysApprovalThe inbox requires review for every message.
new_recipient_domainApprovalAn operator-configured rule for unfamiliar recipient domains.
attachmentsApprovalThe message carries attachments and a rule requires review for those.
draft_only_principalApprovalThe 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.