Engineering reliability
Human approval for AI email: where the boundary belongs
Place human approval after an AI agent proposes the exact recipient and message, but before delivery. Then bind the decision to that rendering.
The right human approval boundary for AI email sits after the agent has resolved the exact recipient, subject, and body, but before the email provider receives anything. The reviewer should approve that specific rendering. If any reviewed field changes, the approval must become invalid.
Approving an abstract goal such as "reply to the customer" is too early. Asking a person to inspect an audit log after delivery is too late. The useful pause is the narrow point where the proposed action is complete and still reversible.
Approve the action, not the agent's intention#
An agent might begin with a reasonable instruction:
Answer the customer and offer the standard refund when the order qualifies.
Several decisions still stand between that instruction and an email:
- which customer and conversation
- whether the order qualifies
- which address will receive the reply
- what amount or policy language will be included
- whether text from the incoming email influenced the draft
A person cannot meaningfully approve the original sentence without resolving those details. The approval screen needs the proposed action, not a summary of what the agent hoped to do.
For email, that proposed action is at least:
{
"mailbox_id": "b_456...",
"to": "customer@example.com",
"subject": "Re: Order 1049",
"text": "I confirmed that order 1049 qualifies...",
"reply_to_message_id": "m_123..."
}
Show the reviewer the resolved recipient, subject, body, and reply context together. A preview that hides the envelope can make a safe-looking message dangerous simply by sending it to the wrong address.
Put the pause immediately before delivery#
A clean outbound sequence looks like this:
incoming request or email
|
v
agent proposes a complete message
|
v
deterministic policy checks
|
v
pending human approval
|
+---- reject ----> terminal rejection
|
approve
|
v
re-check current policy and quota
|
v
email provider
Keep the approval close to the provider call. If another model turn, address lookup, template merge, or body edit happens after approval, the reviewer did not see the final action.
The same rule applies when an agent framework pauses a tool call. OpenAI's human-in-the-loop documentation exposes the tool name and arguments as an interruption, stores the decision on that call, and resumes the original run. That call-level binding is important. A general "allow send_email" choice is broader than approving one message.
Use a state machine, not a boolean#
A single approved: true field does not describe enough of the workflow. It cannot tell you whether delivery started, whether a rejection raced with an approval, or whether the content changed in between.
Postfleet uses explicit draft states:
draft -> pending_approval -> sending -> sent
|
+---------------------> rejected
The transition out of pending_approval is guarded. An approval and rejection that arrive at the same time compete for the same state transition, so only one can win. A second decision receives an approval_conflict instead of silently overriding the first.
Terminal drafts are tombstoned. After a send or rejection, they disappear from the open draft list. That prevents an old approval item from looking actionable after its decision is finished.
Bind approval to the reviewed rendering#
Between the time a page renders and the time a person clicks Approve, another request may edit the draft. This is a standard time-of-check to time-of-use problem.
Postfleet assigns each draft an internal content hash over these fields:
to + subject + text + in_reply_to
The dashboard sends back the hash associated with the version rendered on the page. The guarded transition to sending succeeds only when the current draft still has that hash. If an edit landed in the meantime, approval fails with approval_conflict and the person must load the new version.
The hash is internal. API clients do not calculate or submit it. It answers one precise question: did any delivery field change after the review page was rendered? It cannot prove that a person read every field or that the review UI displayed enough context.
This is stronger than disabling the edit button in the browser. A UI control cannot stop another tab, process, or API request from changing the row.
Treat pending approval as a successful outcome#
When require_approval is enabled on a Postfleet mailbox, a call to POST /api/v1/send does not deliver immediately. It returns HTTP 202:
{
"draft_id": "d_789...",
"status": "pending_approval"
}
That response means the request was accepted into the human queue. It is not a transient failure. Do not retry it as though the service were unavailable.
Here is a small server-side TypeScript wrapper that keeps the queued and delivered outcomes distinct:
type Delivered = {
kind: "delivered";
messageId: string;
threadId: string;
};
type AwaitingApproval = {
kind: "awaiting_approval";
draftId: string;
};
type SendOutcome = Delivered | AwaitingApproval;
export async function sendAgentReply(input: {
mailboxId: string;
to: string;
subject: string;
text: string;
operationId: string;
}): Promise<SendOutcome> {
const response = await fetch("https://api.postfleet.ai/api/v1/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POSTFLEET_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
mailbox_id: input.mailboxId,
to: input.to,
subject: input.subject,
text: input.text,
client_id: input.operationId,
}),
});
const body = await response.json();
if (response.status === 202 && body.status === "pending_approval") {
return { kind: "awaiting_approval", draftId: body.draft_id };
}
if ((response.status === 200 || response.status === 201) && body.id) {
return {
kind: "delivered",
messageId: body.id,
threadId: body.thread_id,
};
}
throw new Error(body.error ?? `Send failed with ${response.status}`);
}
Use a stable operationId for one logical send. If the request is replayed with the same body, Postfleet returns the stored pending draft or delivered message instead of creating another send operation. A different body with the same ID returns an idempotency conflict.
The calling application can now tell the user "waiting for review" without asking the agent to send again.
Re-evaluate policy when the person approves#
Approval should not freeze every other control. Time may pass while a draft waits in the queue. In that interval, the recipient could be suppressed, an account could reach its quota, or a send-list policy could change.
Postfleet reads the mailbox approval setting when the send is first requested. Once a forced draft enters pending_approval, turning the setting off does not release that draft. When a person later approves, the message goes back through the shared send path, where current recipient policy and quota are checked before provider delivery.
This produces two useful guarantees:
- a draft that required review cannot escape because the setting changed later
- human approval does not bypass a newer deterministic restriction
An approval is permission to attempt the reviewed action. It is not a promise that every downstream policy will accept it.
What the reviewer needs to see#
Keep the review screen focused enough that a person can make the decision. At minimum, show:
- the sending mailbox
- the exact resolved recipient
- the subject and complete plain-text body
- the message being replied to, when applicable
- why the item entered the queue
- whether the draft changed since the page loaded
For higher-risk workflows, add the source record used by the agent and the deterministic policy result. A refund reply might need the order number and approved amount. A legal notice might need the customer account and template version.
Avoid presenting the model's confidence as the approval decision. Confidence can help sort a queue, but it does not establish that the recipient or claim is correct.
Keep the agent out of the control plane#
The process that proposes a message should not be able to approve it. Otherwise an injected or malfunctioning agent can simply complete both halves of the control.
Postfleet keeps approve and reject actions in the authenticated dashboard. API and MCP keys can create and list drafts according to their read and send capabilities, but they cannot approve their own pending draft or disable the mailbox approval requirement. The separation is documented in drafts and human approval and authentication and key scoping.
If you also use an agent-framework approval interruption, preserve the same separation. The approval callback should represent a real user or independent policy decision, not a function that asks the same model whether its own call looks safe.
Test the awkward cases#
The happy path proves very little. Exercise the cases that can invalidate the review:
| Scenario | Expected behavior |
|---|---|
| Draft changes after the page loads | Approval fails; new version needs review |
| Approve and reject happen together | One wins; the other gets a conflict |
| The client repeats the original send | Same pending result is replayed |
| Mailbox approval is turned off later | Existing forced draft still needs approval |
| Recipient becomes suppressed while waiting | Approval does not deliver |
| Quota is exhausted before approval | Send fails and the draft can return for review |
| Provider outcome is unknown | Draft stays in sending for reconciliation |
| Agent tries to call an approval endpoint | No data-plane approval path exists |
Also test the content a reviewer is likely to miss: a changed recipient with an unchanged body, a reply attached to the wrong thread, a quoted instruction from the inbound email, and a draft that embeds sensitive data in an otherwise ordinary paragraph.
Approval is a control, not a cure#
Human review lowers the chance that a bad draft becomes an external action. It does not make incoming email safe, and reviewers can still miss subtle manipulation. Keep prompt-injection screening, narrow tool permissions, recipient policy, idempotency, and delivery reconciliation in place.
Approval sits at the outbound edge, which means everything upstream has already happened by the time a reviewer sees a draft. The inbound gates are what decide whether the message that prompted it ever reached extraction — a draft built from a skipped_injection_risk message should not exist to approve in the first place.
There are operational limits to account for as well. Postfleet currently records approval and rejection events, but not draft creation, edits, or deletion. Approval is dashboard-only. The queue shows the first 200 characters of a draft body rather than a separate full-message review, so long or sensitive drafts need an independent full-content review path. Reply context is not displayed beside the draft. A draft with an ambiguous provider outcome can remain in sending while server-side reconciliation runs, with no manual redrive button.
Those limits should shape the runbook. If your compliance model requires a complete edit history or a programmatic four-eyes approval API, add that requirement explicitly rather than assuming a send queue supplies it.
For the inbound side of the boundary, read Email prompt injection: how to secure an AI agent that reads email. For a working MCP connection with narrow tools, use the OpenAI agent email tutorial.