Engineering reliability
Fail closed or fail open? A safer policy for AI email agents
Fail closed when an email's trust or extraction state is unknown before an AI agent acts. Preserve the message and route uncertainty to review.
An AI email workflow should fail closed whenever an unknown screening or extraction state could reach a consequential action. Do not send, update a record, reveal data, or trigger payment on an ambiguous result. Preserve the message, acknowledge receipt when appropriate, and route the decision to review.
Fail closed does not have to mean discard the email or take the whole inbox offline. The safe fallback can be a durable review queue while low-risk functions, such as storing and displaying the cleaned message, remain available.
The direction is not uniform across a pipeline, and it should not be. Postfleet's inbound gates fail in opposite directions on purpose: a spam-scan error passes the message through, because quarantining real mail is the worse outcome, while an injection-scan error stops extraction, because poisoned agent context is. "Fail closed" is a claim about a specific gate, not a posture you apply everywhere.
Decide what "closed" means at each boundary#
The phrase is easy to use and easy to misapply. NIST defines fail safe as terminating system functions in a way that prevents damage to specified resources when a failure is detected. The resource and the damaging action have to be named.
For an email agent, there are several boundaries:
| Boundary | Failure | Closed behavior |
|---|---|---|
| Webhook authentication | Signature is missing or invalid | Reject the request |
| Event delivery | Processing service is temporarily unavailable | Return non-2xx so the sender retries |
| Injection screening | Scanner errors or returns high risk | Do not run semantic extraction or agent action |
| Schema extraction | Model errors or output is invalid | Do not invent missing fields |
| Business validation | Extracted value violates local rules | Hold the side effect |
| Outbound delivery | Provider outcome is unknown | Reconcile before another send |
Those closed states do not all look alike. An invalid signature should be rejected. A verified email with a scanner failure should be retained and made visible, but kept out of the action path. An ambiguous outbound send should be frozen rather than repeated.
Write the rule in terms of a protected action:
If trust state is unknown, do not call the ticket-update tool.
That is testable. "Fail securely" without a named action is not.
Separate delivery from permission to act#
Incoming mail has operational value even when automation cannot trust it. A customer should not have to resend a support request because the extraction model was unavailable for thirty seconds.
A safer architecture separates two questions:
- Did a message arrive and pass transport authentication?
- Is there enough trusted evidence to perform this business action?
Postfleet stores the cleaned message and delivers a deterministic event even when comprehension is partial or skipped for injection risk. The event carries the state that prevented automation. Your consumer can accept and retain it without granting permission to act on it.
Once those questions are separate, a scanner outage no longer forces a choice between dropping the mail and handing unchecked text to an agent.
Read the full comprehension state#
Do not reduce the result to extraction !== null. Postfleet exposes comprehension_status, classification, extraction, and extraction_error because a null extraction has more than one meaning.
| Status | Classification | Extraction | What happened |
|---|---|---|---|
complete |
non-null | object | Screening cleared, extraction ran, and the message matched the schema |
complete |
non-null | null |
Screening and extraction ran, but required evidence did not match the schema |
complete |
null |
null |
LLM comprehension did not run, usually because no schema was configured or quota did not admit it |
partial |
usually null |
null |
Screening or extraction failed; inspect extraction_error |
skipped_injection_risk |
null |
null |
A deterministic or model screen returned high risk; extraction did not run |
The third row is the trap. complete describes the configured pipeline path, not a universal safety verdict. When no schema or LLM stage is available, deterministic cleaning can complete with both classification and extraction null. If your application expected screened extraction, that shape must not fall through to an action.
heuristicRisk: low is not available in the webhook payload, but the same caution applies inside the pipeline: it is a catch-all for "the deterministic check did not fire," including paths where that check did not run. Absence of a positive flag is not proof that all trust stages cleared.
Route each state in code#
Make the expected pipeline configuration an input to the decision. This prevents a mailbox with a missing schema or exhausted comprehension quota from looking like a legitimate non-match.
type MessageReceived = {
event_id: string;
message_id: string;
comprehension_status: string;
classification: string | null;
extraction: unknown | null;
extraction_error: string | null;
};
type Decision =
| { kind: "process"; data: unknown }
| { kind: "ignore_non_match"; classification: string }
| { kind: "store_only" }
| { kind: "review"; reason: string };
export function decideInboundAction(
event: MessageReceived,
options: { expectsExtraction: boolean },
): Decision {
if (event.comprehension_status === "skipped_injection_risk") {
return { kind: "review", reason: "injection_risk" };
}
if (event.comprehension_status === "partial") {
return {
kind: "review",
reason: event.extraction_error ?? "comprehension_partial",
};
}
if (event.comprehension_status !== "complete") {
return { kind: "review", reason: "unknown_status" };
}
if (event.extraction !== null) {
return { kind: "process", data: event.extraction };
}
if (event.classification !== null) {
return {
kind: "ignore_non_match",
classification: event.classification,
};
}
if (options.expectsExtraction) {
return { kind: "review", reason: "comprehension_not_run" };
}
return { kind: "store_only" };
}
The process result is permission to continue validation, not permission to perform the final side effect. Validate the extraction against the expected JSON Schema at your service boundary, then apply deterministic business rules. An invoice total may be valid JSON and still violate a purchase-order limit.
The email-to-JSON guide covers schema validation, non-match behavior, and event_id deduplication in more detail.
Acknowledge held events without creating retry storms#
Postfleet webhooks are delivered at least once. A non-2xx response or timeout causes a retry, and a dead-lettered event can be redriven from the dashboard. Every retry keeps the same event_id.
Use that retry channel for delivery failures, not for trust decisions that have already reached a stable outcome.
If your handler successfully verifies, deduplicates, and stores an event with skipped_injection_risk, return 2xx. Repeated delivery will not make that same payload safer. Place it in review and acknowledge it.
If your database is unavailable before the event is stored, return non-2xx so Postfleet retries. Once durable storage succeeds, insert the event_id in the same transaction as the review item or business side effect. A later duplicate should return 2xx without repeating the work.
The webhook documentation includes the signature recipe, retry schedule, and dead-letter behavior.
Do not build an unsafe fallback model#
A common recovery path looks reasonable in code:
if (event.extraction === null) {
return backupModel.extract(event.body);
}
It is unsafe because it erases why extraction is null. When the injection screen returned high risk, the fallback sends the held content directly to another model. When the screen errored, the fallback processes text that never cleared the gate. When quota prevented comprehension, it silently creates a separate unmetered path with different controls.
If you need a second extraction attempt, keep it inside a controlled pipeline that repeats the same sanitization, screening, schema, and audit requirements. Record that it is a retry. Do not let a downstream consumer improvise its own model call from the webhook body.
The same applies to raw-message retrieval. Postfleet's agent-facing MCP and webhook surfaces return the cleaned body, not the pre-sanitization original. Fetching raw HTML through another system after a risk verdict defeats that containment.
Choose fail-open behavior only for low-impact work#
Fail open can be a reasonable availability choice when the output cannot reach a sensitive sink. Examples include:
- showing the cleaned message to an authenticated human
- storing metadata for later inspection
- incrementing an operational count
- adding an item to a review queue
Even these paths need normal authorization and privacy controls. "Low impact" does not mean public.
Do not fail open into:
- sending or replying
- updating a customer, financial, or access-control record
- following a link from the message
- exposing secrets or unrelated mailbox content
- writing long-term agent memory
OWASP's LLM Prompt Injection Prevention Cheat Sheet calls email an indirect prompt-injection source and recommends least privilege, output validation, and approval around high-risk operations. The AI Agent Security Cheat Sheet adds an important constraint: do not rely on model output alone for authorization.
Treat outbound ambiguity as a separate closed state#
Fail-closed design also matters after an agent decides to send. A network exception during the provider call does not tell the caller whether the provider accepted the message.
With an idempotent Postfleet REST send, a provider-unknown result returns 502 with code: "delivery_outcome_unknown". The keyed operation remains in progress for server-side reconciliation. Reusing the same client_id does not issue another delivery while that state is unresolved.
Do not interpret the 502 as permission to mint a new operation ID and send again. The protected resource here is the recipient's inbox, and the closed behavior is to hold the operation until its outcome is known.
For approval-gated drafts, an unknown provider outcome leaves the draft in sending. There is currently no manual redrive. That is inconvenient, but it is safer than making a duplicate externally visible action look like a recovery mechanism. See sending and idempotency for the replay table.
Test the state table, not just exceptions#
The failure policy belongs in tests as a set of input and action pairs:
| Input | Expected action |
|---|---|
complete, valid extraction |
Validate and apply business policy |
complete, classified non-match |
Ignore or route by classification |
complete, all comprehension fields null, extraction expected |
Review; no agent action |
partial, scan_failed |
Review; no extraction fallback |
partial, extraction error |
Review or controlled pipeline retry |
skipped_injection_risk |
Review; no model fallback |
| Unknown future status | Review by default |
Duplicate event_id |
No duplicate side effect |
| Unknown outbound delivery | Reconcile; no new send |
Include a test that proves the sensitive tool was not called. An error message alone can hide a background action that already happened.
Monitor the distribution of these states as well. A sudden increase in complete events with null classification may signal a missing schema or exhausted quota. A rise in partial may indicate a provider issue. A spike in injection skips may be an attack, a false-positive regression, or a change in the mail your customers receive.
The practical rule#
Keep the message flowing, but stop authority at uncertainty. Store a verified event. Preserve its comprehension state. Let people inspect what automation cannot safely use. Resume consequential work only from an explicit, tested state with validated data.
That approach retains most of the inbox's availability without treating a scanner outage, a null field, or a novel attack as permission to act.
For the threat model behind the gate, read Email prompt injection: how to secure an AI agent that reads email. Postfleet's security record documents current screening claims and limitations.