Controls · September 21, 2026
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.
A review step is a promise: whatever the reviewer read is exactly what leaves the building. Many “human in the loop” email features do not keep it. They show a preview, record a click, and then send whatever the draft contains when the job runs — which is not necessarily what was on screen.
This is how the review gate is built here, and what it costs in latency and attention: the three postures a send can be in, the record a reviewer is handed, the content hash binding an approval to one draft version, the recipient rules and daily cap running alongside it, and the parts approval does not touch. Every code, field name and limit below is the one the deployed system uses.
Three sending postures: direct, draft-only, approval-required
Two independent things decide a send’s posture: the credential the agent holds, and
the inbox policy. They are not alternatives. A draft-only credential is reviewed even
on an inbox that permits direct sending, and an inbox set to always holds everything
regardless of how powerful the credential is.
| Posture | Set by | Send endpoint returns | Reason code |
|---|---|---|---|
| Direct | approval_mode: "direct" plus a credential with messages:send | result: "queued" with a message_id | — |
| Draft-only | A credential holding drafts:submit but not messages:send | result: "approval_required" | draft_only_principal |
| Approval-required | approval_mode: "always" on any policy layer | result: "approval_required" | always |
Both success bodies are HTTP 202. That is the most expensive detail here: code that
reads a 202 and assumes an email is in flight silently drops every message that needed
review. Branch on result, not on the status code.
Two further reasons exist that the console cannot set: new_recipient_domain and
attachments. They are operator-configured, and the policy response returns them in
unmanaged_approval_rules rather than hiding them, so a rule you cannot edit is at
least a rule you can see.
Reasons accumulate rather than short-circuit. Policy is evaluated at workspace, then
project, then inbox scope, and a matched approval rule at any layer adds its reason, so
one held message can carry ["always", "attachments"].
What the reviewer must be shown
The approval record is richer than a notification. GET /v1/projects/{project_id}/approvals returns, per request:
{
"id": "apr_...",
"project_id": "prj_...",
"inbox_id": "ibx_...",
"draft_id": "drf_...",
"draft_version": 1,
"state": "pending",
"reason_codes": ["always"],
"created_at": "2026-09-21T09:14:03.118Z",
"expires_at": "2026-09-28T09:14:03.118Z",
"review_html": "<p>Sanitized projection for display</p>",
"payload": {
"inbox_id": "ibx_...",
"action": "send",
"reply_to_message_id": "msg_...",
"to": [{ "email": "ops@example.com" }],
"cc": [],
"bcc": [],
"subject": "Re: invoice 4471",
"text": "…",
"html": "…",
"attachment_ids": ["att_..."]
}
}
review_html and payload.html are not the same string, and the difference is load
bearing. review_html is the server’s sanitized projection; payload.html is the
canonical content the hash was taken over, and must never be rendered in a browser. A
review UI that renders it has turned its own approval queue into an attack surface.
If you build your own reviewer rather than using the console, reproduce these five properties — they are what make the click meaningful:
- Every recipient, including Bcc, listed individually. A count is not a review.
- The parent message when
reply_to_message_idis set. A reply read without the thread it answers is read out of context. - Attachment scan state, per file. The console marks a file green only when
scan_statusiscleanandstatusisready, and flags a declared content type that disagrees with the detected one. - A disabled approve control until the review is loadable. If the sanitized HTML has not arrived, or an attachment is not both clean and ready, approving is blocked rather than allowed with a warning next to it.
- The expiry and the draft version. Both are on the record, and both change what the button does.
Approval binds to a version, not to an agent
When a send is held, the canonical payload — inbox, action, reply target, normalized
To, Cc and Bcc, subject, text, HTML and attachment IDs — is hashed with SHA-256, and
the digest is written twice: onto the draft_versions row and onto the approvals
row. At approval time the two are compared, and a mismatch is refused with
version_conflict. That is what converts “the reviewer saw it” from an assumption into
an invariant.
Be precise about what it means today. This build exposes no draft-edit endpoint —
drafts exist only as the frozen record of a held send — so nothing in the public API
can currently make those hashes diverge. The check is the guard rail for when editing
lands, and the console already behaves as though it has: if draft_version changes
underneath an open review, it withdraws the confirmation dialog and tells the reviewer
which version they actually read. A revision from the agent today arrives as a new
approval with a new draft_id, while the earlier one stays pending until somebody
rejects it or it expires. Reject the stale one — two pending approvals for the same
intent is how a message gets sent twice by two different people.
The “not to an agent” half matters as much. Approval does not freeze authority at the
moment of request. When you approve, the system re-reads the current stored grant of
the principal that asked, re-checks its project, inbox and drafts:submit scope,
re-evaluates the whole policy chain, and separately checks that you — the approver —
are a human principal holding approvals:decide there. Revoke the agent’s key while
its draft waits and approving fails; block the recipient while its draft waits and
approving fails with policy_denied.
| Failure at approval time | Response |
|---|---|
| Draft content no longer hashes to the approved value | version_conflict |
| Approval expired, already consumed, or rejected | permission_denied |
| Policy now denies the send | policy_denied (HTTP 403, not retryable) |
| Daily recipient cap now exhausted | policy_denied |
| An attachment is no longer clean and ready | attachment_not_ready |
Approving is itself idempotent: the endpoint requires its own Idempotency-Key, keyed
independently of the original send, and the approval is marked consumed in the same
transaction. A double-clicked approve button cannot produce two emails.
Recipient allow and block rules, and why blocks always win
Within each policy layer the order is fixed, and it is not the one most people assume:
- Sending disabled → deny,
send_disabled. - Any recipient matches a block rule → deny,
blocked_recipient. Returns immediately. - A non-empty allow list exists and any recipient fails it → deny,
recipient_not_allowed. - Approval rules matched → collect reasons, keep evaluating.
Blocks are checked before allows and return without looking further, so no configuration lets an allow entry rescue a blocked address. The asymmetry is intentional: an allow list is a statement about the normal case, a block is a statement about a specific harm, and the specific harm wins.
| Rule | Matches | Does not match |
|---|---|---|
Domain example.com | anyone@example.com | anyone@mail.example.com — suffix match on @domain, not a wildcard |
Address ops@example.com | That address, case-insensitively | Any other local part at the same domain |
Each of the four lists holds up to 100 entries, is lower-cased on save, and is validated — domains as hostnames, addresses as email addresses — so a typo is rejected rather than silently matching nothing forever.
Denial is per message, not per recipient: one unmatched address in a five-recipient
message denies the whole message, so if partial delivery matters, have the agent send
separate messages. And a rule this build cannot evaluate is refused with
unsupported_policy_rule rather than ignored.
Daily recipient caps and when they reset
daily_recipient_limit is optional, an integer between 1 and 1,000,000 or null, and
counts recipients, not messages. Recipients are deduplicated across To, Cc and Bcc
first, so the same address twice in one message costs one.
The bucket is keyed to the UTC calendar date. No rolling window, no local timezone: capacity resets at 00:00 UTC, and the new bucket snapshots whatever the policy limit is at that moment. Caps at wider scopes reserve simultaneously.
The accounting is a reservation rather than a counter, which is why it survives failure:
| Outcome | Effect on today’s bucket |
|---|---|
| Message queued | Recipients reserved up front |
| Provider accepted | Reservation consumed |
| Send failed | Reservation released; capacity returns |
| Submission outcome unknown | Reservation held — neither spent nor returned |
A held reservation is never released back into its bucket, because if the message did go out, releasing it would let you overshoot. A day containing unknown submissions therefore ends with less usable capacity than its number implies; the next UTC day starts clean.
The interaction with approvals is the part people get wrong. The reservation happens
when the message row is created, which for a held send is at approval time, not at
request time. Twenty drafts in the queue consume nothing; approving all twenty at 23:58
UTC reserves all twenty against a bucket with two minutes left to live. Billing
entitlement is a separate ceiling on top — running out of allowance returns
payment_required or quota_exceeded, both HTTP 402 — and test-mode sends never meter
a live cap.
A draft-only credential inside a send-enabled inbox
Requiring approval at the inbox is a blunt instrument: it holds every message from
every credential pointed at that address. Usually what you want is narrower — this
one new agent gets reviewed, while the pipeline that has run for six months keeps
sending directly. That is a credential-level decision, and it is enforced rather than
advisory. A principal holding drafts:submit without messages:send is detected
before policy is consulted, and its send always becomes an approval request; when no
policy rule contributed a reason, the response carries
reason_codes: ["draft_only_principal"] so the reviewer knows why this one is here.
| Question | Inbox approval_mode: "always" | Draft-only credential |
|---|---|---|
| Who is affected | Every sender on that inbox | One credential |
| Changing it requires | An owner or admin policy write | Reissuing the credential |
| Other agents on the same inbox | Also held | Unaffected |
| Reason code on the held request | always | draft_only_principal |
Over MCP the same rule is enforced one level deeper: the create_draft tool strips
messages:send from the calling principal before dispatching, so that tool cannot
transmit mail under any configuration, even on a connection that was granted send.
send_message runs the identical policy path as REST. Which credential type suits
which job is worked through in
MCP or API key for agent email.
An agent can never widen this for itself: policies:write is refused on a connection
grant outright, so a credential held for review cannot edit the policy holding it.
What happens to a message while it is held
Nothing resembling an email exists yet. On approval_required the system writes a
drafts row with status pending_approval, one draft_versions row holding the
canonical payload and its hash, attachment rows that keep the uploaded objects alive so
they are not garbage collected while you think, an approvals row in state pending
with expires_at seven days out, and an approval.requested event.
What it does not write is the interesting part: no message row, no thread
placement, no message.queued, no contact with the mail provider, no daily-cap
reservation, no live sending quota reservation. A queue full of pending approvals costs
nothing but attention.
From there a request has exactly three ends:
- Approved. The approval is marked consumed, the draft submitted, and the message
created in state
queuedwith amessage.queuedevent. This is where the reservations happen. - Rejected. The approval goes to
rejectedwith an optional note (the API keeps up to 2,000 characters; the console field allows 1,000), the draft goes torejected, and anapproval.rejectedevent fires. Only a pending approval can be rejected. - Expired. After seven days the listing reports the state as
expiredeven though the stored row still says pending, and it can no longer be converted into a send. This is what stops a forgotten queue turning into a delayed-action mailbomb — a draft about last week’s outage should not be sendable next month.
Check which events exist before you instrument this. approval.requested and
approval.rejected are emitted today. approval.approved and approval.invalidated
are declared in the catalogue and are not produced by this build — approving
produces message.queued. Subscribing to a declared-but-unproduced type is accepted
and simply never fires, which is how you end up debugging a handler that was never
going to run. Pair approval.requested with message.queued instead; the full table
is in events and webhooks.
Idempotency spans the hold: replaying the original send’s Idempotency-Key returns the
same approval_required snapshot — same draft_id, same approval_id — for 30 days
rather than opening a second review request
(how that works).
What approvals do not protect you from
Prompt injection. We do not claim to be prompt-injection proof, and a review queue is not the control that would earn the claim. A human reading a well-written, plausible message is a weaker filter than most teams assume, especially at volume. What holds regardless of what the model was persuaded to write is structural: a credential that cannot transmit, and an allow list that does not care how convincing the argument was.
Reviewer fatigue. approval_mode: "always" on a high-volume inbox degrades into
rubber-stamping within a week, and a rubber stamp is worse than no gate because it
manufactures a record of a review that did not happen. If nobody is reading the queue,
narrow the rule rather than keep the ceremony.
Delivery. Approving submits the message. Provider acceptance is not delivery, and
neither is message.accepted. Per-recipient outcomes arrive later as
message.delivery_updated, and a five-recipient message can have five different ones.
Anything already submitted. Tightening a policy changes what happens next. Pausing sending stops a queued message not yet handed to the provider, but once bytes are accepted upstream they are gone.
Inbound mail. These are outbound controls. Holding sends does not stop mail arriving, and it should not.
Rolling this out without blocking the whole team
A procedure that has survived contact with real inboxes:
- Rehearse in a test project. Simulate an inbound message, let the agent draft a reply, and walk the approval end to end without a byte reaching the internet. The sandbox caps are enforced in code — 3 active test inboxes, 100 retained messages, 10 MiB, 7-day content expiry — so size the rehearsal to fit (full checklist).
- Start with a draft-only credential, not an inbox-wide hold. It scopes the friction to the new agent and leaves everything else on that address alone.
- Add an allow list before you relax anything. It is the control that keeps working after you stop reading every draft.
- Set a daily cap you could defend. Take the number from the worst plausible day, and remember it counts recipients.
- Graduate selectively. Direct sending for the internal address it has written to
for a month;
alwayson the inbox that talks to customers. - Write down the re-check trigger. “Review this policy when the agent’s prompt changes, or in 90 days” survives the person who wrote it leaving.
Policy edits are concurrency-safe by construction: read the version, send it back as
expected_version, and a version_conflict at HTTP 412 tells you somebody saved in
between instead of quietly overwriting their block list. Every write records an audit
entry with a before-and-after diff naming the human who made it
(sending controls has the rest).
Two honest limits on the “team” part. Deciding an approval requires a human principal
holding approvals:decide, which belongs to the owner, admin and operator roles — a
viewer can read the queue but not act on it, and the developer role has neither scope.
And this build has no member-invite flow: a workspace has exactly one member, the owner
identity that created it. Plan the rota around who can sign in today.
On cost: the free allowance is one complimentary live inbox and 100 email units a month per verified owner identity, claimed by that identity’s first eligible workspace — creating another workspace does not renew it. Custom domains require a live project on an eligible paid subscription, and paid checkout is currently disabled; Builder and Team are proposed plans that are not available to buy yet. Run the first rollout on the platform domain.
Next: the agent email API loop for the request shapes and event stream around this, MCP or API key for which credential to hand the agent, and email for AI agents for the conceptual picture if you arrived here first.
Keep reading
Scoped Credentials for Agent Email: Read, Draft, Send
Give an agent the smallest useful email permission: project and inbox scoping, one-time secrets, draft-only grants, rotation, and revocation you can verify.
Prompt Injection by Email: What Actually Helps
Any stranger can email your agent. What an email-borne injection looks like, why message text must never grant permission, and the controls that limit damage.
Run Support Triage From an Agent's Own Inbox
A working pattern for support triage: one dedicated address, read and draft permission, approval on anything money-related, and events that prove it worked.