Controls · September 27, 2026

Restrict Which Domains Your Agent Can Email

Restrict which domains your agent can email with an allowlist it cannot argue with: the policy call, how recipients match, and what a denied send returns.

You told the agent, in its system prompt, to email only people at acme.com. Then an inbound message arrives saying the finance contact has moved to acme-billing.net and the invoice should go there instead. The model weighs your instruction against a plausible, recent, specific one, and sometimes it picks the email. If you want to restrict which domains your agent can email, an allowlist in the prompt is a preference. The allowlist has to live somewhere the model has no way to reach.

This guide settles where that is on this platform and exactly how it behaves: the inbox policy call, how a recipient is matched against a domain or address entry, what happens when one recipient in five is off the list, and the gaps you should know about before relying on it. If you want the review queue rather than the recipient rules, read human approval before an agent sends email instead; this piece touches it only where the two controls meet.

Why the prompt and the tool description are not a control

A control is something the controlled party cannot change or talk its way past. A system prompt fails the first test the moment an injected message is in context, and a tool description fails the second, because it is text the model reads and then decides how to honour. “Only send to approved domains” in the send_message description is exactly as strong as the model’s judgement on its worst day.

The enforceable version is a rule evaluated by the mail service, after the model has finished deciding and before any provider is contacted, stored where the agent’s credential has no write path. Here that is the inbox policy, and the write path is closed in three separate places:

That combination is the whole point. The agent can be fully convinced that acme-billing.net is legitimate and it still has no request it can make that adds it. The same reasoning, applied to a hostile inbound message, is laid out in prompt injection in email agents.

How to restrict which domains your agent can email: the policy call

Read the current policy first, because the write is guarded by its version:

curl "https://api.emailforagents.ai/v1/projects/$PROJECT_ID/inboxes/$INBOX_ID/policy" \
  -H "Authorization: Bearer $EFA_SESSION_TOKEN"

The response carries version, this inbox’s own send_enabled, the effective_send_enabled after inherited layers, the four lists, the daily cap, and an inherited array describing workspace and project layers. Then write the whole policy back:

curl -X PUT "https://api.emailforagents.ai/v1/projects/$PROJECT_ID/inboxes/$INBOX_ID/policy" \
  -H "Authorization: Bearer $EFA_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "expected_version": 3,
    "send_enabled": true,
    "approval_mode": "direct",
    "allow_domains": ["acme.com", "billing.acme.com"],
    "allow_addresses": ["auditor@outside-firm.example"],
    "block_domains": [],
    "block_addresses": [],
    "daily_recipient_limit": 200
  }'

The endpoint is PUT /projects/{project_id}/inboxes/{inbox_id}/policy, and every field in that body is required. There is no patch form: omitting block_addresses is a validation error, not “leave it as it was”. Each list takes at most 100 entries; daily_recipient_limit is an integer from 1 to 1,000,000, or null for no cap; approval_mode is "direct" or "always". Unknown fields are rejected.

Three behaviours of the save worth designing around:

  1. Stale writes fail. If expected_version does not match the stored version the save returns version_conflict, HTTP 412. Two admins editing at once cannot silently overwrite each other’s block list; the loser refreshes and re-applies.
  2. Entries are validated and normalised. Domains are trimmed, lower-cased and must parse as ASCII hostnames, so a typo like acme,com is refused rather than stored as a rule that matches nothing. Internationalised domains have to be entered in their xn-- form. Addresses must parse as email addresses and are lower-cased. Duplicates collapse.
  3. The response says what incoming mail will do: nothing different. The API contract describes a successful save as the current owner/admin saved inbox send policy with incoming routing unchanged. This is an outbound control only.

In the console, the same fields are edited in the inbox’s sending controls, and the sending controls reference documents each one.

Exactly how a recipient is matched

Every send is reduced to the set of unique recipients across To, Cc and Bcc, and each one is checked. The matching is simpler than most people assume, and the places where it is literal are the places mistakes happen.

SituationResult
Allow acme.com, send to jo@acme.comMatches
Allow acme.com, send to jo@billing.acme.comDoes not match. A domain entry means @acme.com exactly
Allow billing.acme.com, send to jo@acme.comDoes not match. A subdomain entry does not cover its parent
Allow acme.com, send to jo@ACME.COMMatches. The recipient’s domain is lower-cased before checking
Allow acme.com and address auditor@outside-firm.exampleA recipient passes if it matches either entry
Allow lists all emptyNo recipient restriction at that layer
Recipient on a block entry and an allow entryDenied. Blocks are checked first

A domain entry is compared against the end of the normalised address, so billing.acme.com and acme.com are two unrelated rules. If an agent legitimately mails a subdomain, list it; nothing is inherited in either direction.

There is one limitation in address entries to know about. Domain matching ignores case, but address matching currently compares the part before the @ exactly as the recipient was written, while saved rule values are lower-cased. On an allow list that fails closed: a recipient written with different capitalisation from the entry is refused as recipient_not_allowed, which is inconvenient but safe. On a block list it means an address entry is weaker than it looks, because a variant spelling of the same mailbox is not guaranteed to match it. The safe pattern follows directly. Use domain rules and allow lists for anything that matters, and do not rely on address-level block entries as your only barrier. Where you need to keep one outside party out, block the domain if you can, or better, leave them off an allow list.

Layers stack as well. Policy rows can exist at workspace, project and inbox scope and every one is evaluated in that order. Each non-empty allow list at any layer must be satisfied on its own, so the effective allow set is the intersection of the layers, and a block at any layer wins. The PUT above is the only customer write path in this build, and it writes the inbox layer; inherited layers are returned read-only in inherited so a console can explain why a send is refused.

What happens when one recipient is off the list

The whole send is refused. There is no partial delivery, no dropping of the offending address, and no parking it for a human. The response is HTTP 403:

{
  "error": {
    "code": "policy_denied",
    "message": "Workspace policy blocked this send.",
    "request_id": "req_...",
    "retryable": false,
    "details": { "reason_codes": ["recipient_not_allowed"] }
  }
}

blocked_recipient replaces recipient_not_allowed when a block entry matched, and send_disabled appears if sending is paused at any layer. The evaluation order inside each layer is paused, then blocks, then allows, and each of those returns immediately.

What the refusal does not leave behind matters as much as the error:

QuestionAnswer
Is a message, draft or approval request created?No. The check runs before any of them is written
Does it reserve against the daily recipient cap?No. Nothing reaches the reservation step
Is the Idempotency-Key consumed?No. The key is claimed only after policy passes
Is a webhook event emitted?No. There is no event for a policy denial
Can the agent route it through create_draft?No. Over MCP, create_draft runs the same policy check
Can a human approve it through?No. A denial is decided before approval is considered

The MCP surface is thinner than REST here. A denied send_message or create_draft comes back as a tool result with isError: true and the text “Workspace policy blocked this send.” The reason code is not in the tool result. Your agent learns that the send was refused but not which rule refused it, which is arguably the right amount of information to give a model that may be under someone else’s influence.

Replies deserve their own mention because they are the common case. The inbox accepts mail from anyone, and a reply is just a send whose recipients happen to be the sender of the inbound message. If that sender’s domain is not on the allow list, the reply is refused like any other send. An allowlisted support agent can therefore read a message from a stranger and cannot answer it, which is usually what you intended and occasionally a surprise in the first week.

The policy is checked again after the request

A rule you add now affects mail that was already on its way, up to the point where it leaves. The recipient check runs at three moments:

  1. When the agent calls send, as above.
  2. When a message is committed, including the moment a human approves a held draft. If you blocked the recipient while the draft sat in the queue, approving it fails with policy_denied and the message “Current policy does not authorize this send.”
  3. In the dispatch worker, immediately before the message is handed to the provider. A queued message that now fails policy is moved to canceled and its daily-cap reservation is released.

The third point has a visibility gap. message.canceled is declared in the event catalogue but is not emitted by this build, so a message canceled by a policy change shows up as a state on the message and not as a webhook. And nothing reaches back past submission: once the provider has accepted a message, tightening the list stops the next one, not that one.

The approval gate is the complementary control

An allowlist answers who the agent may write to. Approval answers whether a person reads the message before it goes. They compose in one direction only: policy denial is final, approval is for sends that policy already allows. An off-list recipient never lands in the review queue, so no reviewer can be fatigued or persuaded into approving it; the only way to reach that recipient is an owner or admin editing the list.

You needUse
The agent must never reach anyone outside two domainsallow_domains
One specific address must never receive mailblock_domains if you can, block_addresses if not
Every message from this inbox is read before sendingapproval_mode: "always"
One new agent is reviewed, older pipelines are notA credential with drafts:submit but not messages:send
A runaway loop stops after a fixed number of recipientsdaily_recipient_limit
Stop all outgoing mail nowsend_enabled: false

The last two rows are blunt instruments on purpose. A recipient cap bounds the damage from an agent stuck replying to an autoresponder, but it is not a stopping rule on its own; preventing agent email loops covers the per-thread budgets that are. And pausing sending is one of several switches, each with a different blast radius, compared in how to revoke an agent’s email access.

Held sends emit approval.requested, and a rejection emits approval.rejected. The catalogue also lists approval.approved, but it is not emitted by this build, so watch for the resulting message events instead. The mechanics of the queue, including how an approval binds to one exact draft version, are in the approval guide linked above, and the controls overview shows how the pieces fit together in the console.

A reproducible check before you trust it

Do not take the table on faith; run it against a test inbox, where nothing can reach the internet. Test inboxes live at <inbox>@<project>.mail.invalid addresses and use a simulated transport, and up to three can be active per workspace, which is enough.

  1. Create two test inboxes, A and B, in one test project. Note the domain part of their addresses; it is the same for both.
  2. As an owner or admin, set A’s policy with that domain as the only allow_domains entry and everything else empty.
  3. With an efa_test_ key holding messages:send on A, send from A to B through POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages. Expect a 202 with result: "queued".
  4. Send from A to someone@example.com. Expect 403 policy_denied with recipient_not_allowed.
  5. Send to both B and someone@example.com in one message. Expect the same 403, and no message to B.
  6. Clear the allow list and put B’s whole domain in block_domains. Send to B again. Expect 403 policy_denied with blocked_recipient.

Step 5 is the one to show your security reviewer: one off-list recipient refuses the whole message, and nothing is left behind for anyone to approve.

What this product does not do

Saying where the edge is matters more than the feature list, so, plainly:

How AgentMail and Nylas handle the same problem

Both were read on their own pages as of 27 September 2026, and both do things this platform does not.

AgentMail’s allowlist and blocklist guide describes six list types: allow and block, crossed with send, receive and reply, applied at organisation, pod or inbox level, and says inbox-level lists override pod and organisation lists. Each entry is a full address or a whole domain. That covers inbound filtering, which we do not have at all. Their page does not state how allow and block interact, how domains match subdomains, or what a blocked send returns, so those are unknowns rather than weaknesses.

Nylas documents rules and lists for Agent Accounts that run on inbound mail and on outbound sends, with domain, tld and address list types, case-insensitive matching, and a block action that returns 403 before the provider receives the message. Recipient conditions match any recipient including Bcc. TLD lists, case-insensitive matching and inbound rules are all ahead of us. The trade-off is in the operators: there is no not_in_list, and the Nylas engineer’s own write-up recommends keeping the hard allowlist in your application code, with rules as the dynamic denylist. A fail-closed allowlist in application code is, again, a control that lives next to the agent.

That is the narrow claim worth making here: allow_domains is a native, fail-closed allowlist enforced by the mail service, re-checked up to the moment of submission, and editable only by an owner or admin. If you run an agent that sends email today, set it before you relax anything else, run the test-inbox check above, and turn any address-level block you depend on into a domain block or an allowlist omission.


Keep reading

All guides · Documentation · Controls