Skip to content

Security

Recipient allowlists for AI agents that send email

Enforce exact recipient and domain policy at the send boundary so a manipulated agent cannot choose an arbitrary external address.

An AI email agent should have a server-enforced recipient allowlist, checked against the final resolved address immediately before delivery. Start with the smallest set of exact addresses or domains the workflow needs. Keep policy management outside the agent's credentials, let explicit blocks override allows, and fail closed when an address cannot be normalized.

A sentence in the system prompt such as "only email customers" is useful guidance. It is not authorization. If an untrusted message persuades the model to choose a different recipient, the send service still needs an independent reason to reject that address.

Put policy after recipient resolution#

An agent may produce a recipient in several ways. It can copy an address from an email body, select one from application data, or reply to a message whose sender becomes the destination. Policy has to inspect the address that will reach the provider, not the model's description of its plan.

The safe order is:

  1. Resolve the final recipient and reply context.
  2. Enforce the single-recipient rule.
  3. Canonicalize the address.
  4. Evaluate account and mailbox send policy.
  5. Apply approval policy when required.
  6. Deliver with an idempotency key.

Postfleet accepts one recipient per send. A reply derives its recipient from the original message, while a new email uses the supplied to value. The same policy engine checks either path after resolution. A display name can be unwrapped, but a comma-separated list, semicolon-separated list, or smuggled second address is rejected before delivery.

This order removes a common gap. Checking the model's proposed to field is not enough if later code can replace it from thread state. The policy check belongs beside the provider call, where the application knows the actual envelope.

Prefer an allowlist for narrow workflows#

A blocklist says where the agent cannot send. Everything else remains reachable, including domains you have never seen. That can be reasonable for a human email client. It is a poor default for an autonomous workflow whose expected destinations are known.

An allowlist reverses the burden. Once a scope has at least one allow entry, a recipient must match an entry at that scope. New domains remain unavailable until an operator adds them.

For example, imagine an accounts-payable agent with this policy:

Account allow
  example.com
  invoices.vendor.test

Mailbox allow
  ap@example.com
  receipts@invoices.vendor.test

Mailbox block
  receipts+old@invoices.vendor.test

The account list is the company-wide floor. The mailbox list narrows this specific agent to two addresses. The block keeps one old subaddress denied even if a broader allow rule would otherwise match it.

Proposed recipient Result Reason
ap@example.com Allow Matches the account domain and mailbox address
ceo@example.com Deny Matches the account allowlist but misses the mailbox allowlist
receipts@invoices.vendor.test Allow Satisfies both scopes
receipts+old@invoices.vendor.test Deny Explicit mailbox block wins
ap@sub.example.com Deny Exact domain matching does not include subdomains

This is easier to review than a prompt that asks the model to infer which contacts count as approved.

Account and mailbox allowlists should intersect#

Postfleet send lists have two scopes:

  • Account entries apply to every mailbox.
  • Mailbox entries apply to one mailbox.

Each nonempty allow set is checked independently. A recipient must satisfy the account allow set and the mailbox allow set when both exist. A mailbox rule cannot widen beyond the account floor.

Blocks run first. An account block wins before a mailbox block, and either one wins over any allow match. With no entries at a scope, that scope adds no restriction.

This lets a platform team set broad organizational limits while a workflow owner narrows one agent further. For example, an account may allow the company's domain plus two vendors. A refund agent can be limited to refunds@example.com, while an invoice agent can be limited to the vendor's billing address. Neither mailbox can authorize a domain that the account policy excludes.

The policy editor lives in the Postfleet dashboard. Data-plane API and MCP keys only experience the result. An email agent cannot add a destination, delete a block, or widen its own mailbox policy. Authentication and key scoping describes this control-plane split.

Canonicalization is part of the security contract#

String comparison breaks down quickly with email addresses and internationalized domains. A policy engine needs one canonical form for entries at write time and recipients at send time.

Postfleet applies these rules to both sides:

  • Local parts and domains are lowercased.
  • Unicode domains are converted to ASCII punycode.
  • One trailing dot is removed from a domain.
  • Plus addressing is preserved.
  • Empty domain labels and malformed addresses are rejected.

An entry containing @ is an exact address. A bare entry is an exact domain, compared with the part after the recipient's final @.

Exact means exact. example.com does not match mail.example.com, and there are no wildcard or suffix rules. Add every intended subdomain separately. Likewise, ap+test@example.com is distinct from ap@example.com because stripping plus tags could collapse two destinations that the operator meant to treat differently.

When any send-list policy exists and the recipient cannot be canonicalized, Postfleet rejects the send. The engine does not treat malformed input as a policy miss that can pass through another branch.

Handle a policy rejection as a decision#

A blocked Postfleet send returns 403 with a structured body and no generic error string:

{
  "code": "send_blocked_by_list",
  "reason": "block_match",
  "scope": "mailbox",
  "list_entry_id": "le_123..."
}

reason is block_match when an explicit block matched or no_allow_match when the recipient missed a required allow set. scope identifies the account or mailbox policy that rejected the address. list_entry_id appears only for an explicit block because an allowlist miss has no single matching entry.

Application code should stop the send and preserve that decision for an operator:

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: mailboxId,
    client_id: operationKey,
    to: proposedRecipient,
    subject,
    text,
  }),
});

const result = await response.json();

if (response.status === 403 && result.code === "send_blocked_by_list") {
  await recordPolicyRejection({
    operationKey,
    reason: result.reason,
    scope: result.scope,
    listEntryId: result.list_entry_id ?? null,
  });

  return { sent: false, status: "blocked_by_recipient_policy" };
}

Do not ask the model to try a similar address. That turns a deterministic denial into an address-guessing loop. Show the rejected recipient and policy scope to the operator without copying sensitive message content into the log.

When a request uses client_id, Postfleet stores terminal outcomes against that key. Repeating the same request after an allowlist rejection replays the original result. If an operator changes policy and intentionally authorizes a new attempt, record the decision and use a new operation key. Sending and idempotency documents the replay behavior.

Combine recipient policy with a narrow key#

An allowlist controls destination. It does not control which mailbox the agent can read, whether the agent can edit drafts, or which tools the MCP client exposes.

Use both boundaries:

  • Bind the key to the one mailbox the workflow owns.
  • Disable can_send for read-only agents.
  • Keep provisioning and policy credentials out of the worker.
  • Expose only the tools the workflow calls.
  • Give each outbound action a stable idempotency key.

If one process reads hostile inbound mail and another process sends approved messages, they can use different credentials. The reader has no send path. The sender accepts a small, validated job object rather than the original email body.

The secure MCP inbox guide shows mailbox binding and client-side tool filtering. Server authorization remains necessary because a model instruction or client filter cannot protect a credential that already has broader rights.

Approval covers what an allowlist cannot#

An allowed address can still receive the wrong content. A compromised support agent might send customer data to a real support vendor, or send a rude reply to a customer whose address is legitimately allowed.

Recipient policy answers one question: may this mailbox contact this destination? Human approval can answer a later question: should this exact recipient receive this exact message now?

OWASP classifies external communication as a high-risk agent action and recommends previews, explicit approval, and parameter-bound authorization. For risky workflows, the reviewer should see at least:

  • The final resolved recipient.
  • Subject and complete body.
  • Reply context when applicable.
  • Which policy rules allowed the destination.
  • The business record and operation key behind the send.

Approval should not let a reviewer widen the allowlist from the same screen unless that policy change has its own authorization and audit trail. Sending one message and changing who the agent may contact are different decisions.

Roll out from observed destinations#

Start by listing the destinations the workflow has a legitimate reason to contact. Use real application records and test fixtures rather than asking the model to propose a broad domain list.

Apply the account floor first, then narrow individual mailboxes. Test on a nonproduction mailbox with the same policy shape. Exercise exact addresses, allowed domains, nearby subdomains, plus tags, Unicode domains, malformed input, and an explicit block that also matches an allow.

Add regression tests for the response your worker receives. A useful suite includes:

Test Expected outcome
Address satisfies both allow scopes One delivery
Address satisfies only the mailbox scope 403, account no_allow_match
Address satisfies only the account scope 403, mailbox no_allow_match
Address matches both allow and block 403 block_match
Domain differs only by case or a trailing dot Same canonical policy result
Recipient uses an unlisted subdomain 403 no_allow_match
Same blocked idempotent request is retried Stored rejection is replayed, with no delivery

Review list entries when vendors, employees, or workflows change. An allowlist that only grows becomes a historical address book, not a current policy.

Current Postfleet limits#

Send-list entries are managed in the dashboard. There is no data-plane endpoint for an agent to edit them. Matching is exact, with no wildcard or automatic subdomain coverage. Add each intended domain and subdomain explicitly.

On the malformed-recipient fail-closed path, the current response reports scope: "account" even if the nonempty policy came from a mailbox. Treat the rejection as authoritative, but do not use that one scope value to diagnose which list needs editing.

Recipient policy also does not authenticate inbound senders or make email content trustworthy. Continue to treat every received message as untrusted input. Email prompt injection covers the sanitization, screening, tool, and approval layers around that input, and the inbound gate order documents which of those layers runs when, and what status a message carries out.

Sources#

Continue reading

Put a trust boundary in front of the inbox.

Create a mailbox, issue the narrowest key the workflow needs, and inspect the cleaned message before your agent acts.