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.

PostureSet bySend endpoint returnsReason code
Directapproval_mode: "direct" plus a credential with messages:sendresult: "queued" with a message_id
Draft-onlyA credential holding drafts:submit but not messages:sendresult: "approval_required"draft_only_principal
Approval-requiredapproval_mode: "always" on any policy layerresult: "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:

  1. Every recipient, including Bcc, listed individually. A count is not a review.
  2. The parent message when reply_to_message_id is set. A reply read without the thread it answers is read out of context.
  3. Attachment scan state, per file. The console marks a file green only when scan_status is clean and status is ready, and flags a declared content type that disagrees with the detected one.
  4. 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.
  5. 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 timeResponse
Draft content no longer hashes to the approved valueversion_conflict
Approval expired, already consumed, or rejectedpermission_denied
Policy now denies the sendpolicy_denied (HTTP 403, not retryable)
Daily recipient cap now exhaustedpolicy_denied
An attachment is no longer clean and readyattachment_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:

  1. Sending disabled → deny, send_disabled.
  2. Any recipient matches a block rule → deny, blocked_recipient. Returns immediately.
  3. A non-empty allow list exists and any recipient fails it → deny, recipient_not_allowed.
  4. 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.

RuleMatchesDoes not match
Domain example.comanyone@example.comanyone@mail.example.com — suffix match on @domain, not a wildcard
Address ops@example.comThat address, case-insensitivelyAny 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:

OutcomeEffect on today’s bucket
Message queuedRecipients reserved up front
Provider acceptedReservation consumed
Send failedReservation released; capacity returns
Submission outcome unknownReservation 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.

QuestionInbox approval_mode: "always"Draft-only credential
Who is affectedEvery sender on that inboxOne credential
Changing it requiresAn owner or admin policy writeReissuing the credential
Other agents on the same inboxAlso heldUnaffected
Reason code on the held requestalwaysdraft_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:

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:

  1. 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).
  2. 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.
  3. Add an allow list before you relax anything. It is the control that keeps working after you stop reading every draft.
  4. Set a daily cap you could defend. Take the number from the worst plausible day, and remember it counts recipients.
  5. Graduate selectively. Direct sending for the internal address it has written to for a month; always on the inbox that talks to customers.
  6. 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

All guides · Documentation · Controls