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:
- Both reading and writing the policy require a human principal. An API key or an
OAuth connection is refused with
permission_deniedbefore scopes are even checked (requireHumanin the policy service). - An MCP connection grant is issued from one of three fixed presets (read, read and draft, read, draft and send), and none of them contains a policy scope, so an MCP session cannot even be issued with one.
- Among humans, writing needs the
owneroradminrole in addition to the scope, and the role is re-read from current membership on every save. Adeveloperoroperatorcan read the policy but cannot change it.
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:
- Stale writes fail. If
expected_versiondoes not match the stored version the save returnsversion_conflict, HTTP 412. Two admins editing at once cannot silently overwrite each other’s block list; the loser refreshes and re-applies. - Entries are validated and normalised. Domains are trimmed, lower-cased and must
parse as ASCII hostnames, so a typo like
acme,comis refused rather than stored as a rule that matches nothing. Internationalised domains have to be entered in theirxn--form. Addresses must parse as email addresses and are lower-cased. Duplicates collapse. - 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.
| Situation | Result |
|---|---|
Allow acme.com, send to jo@acme.com | Matches |
Allow acme.com, send to jo@billing.acme.com | Does not match. A domain entry means @acme.com exactly |
Allow billing.acme.com, send to jo@acme.com | Does not match. A subdomain entry does not cover its parent |
Allow acme.com, send to jo@ACME.COM | Matches. The recipient’s domain is lower-cased before checking |
Allow acme.com and address auditor@outside-firm.example | A recipient passes if it matches either entry |
| Allow lists all empty | No recipient restriction at that layer |
| Recipient on a block entry and an allow entry | Denied. 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:
| Question | Answer |
|---|---|
| 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:
- When the agent calls send, as above.
- 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_deniedand the message “Current policy does not authorize this send.” - In the dispatch worker, immediately before the message is handed to the provider. A
queued message that now fails policy is moved to
canceledand 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 need | Use |
|---|---|
| The agent must never reach anyone outside two domains | allow_domains |
| One specific address must never receive mail | block_domains if you can, block_addresses if not |
| Every message from this inbox is read before sending | approval_mode: "always" |
| One new agent is reviewed, older pipelines are not | A credential with drafts:submit but not messages:send |
| A runaway loop stops after a fixed number of recipients | daily_recipient_limit |
| Stop all outgoing mail now | send_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.
- Create two test inboxes, A and B, in one test project. Note the domain part of their addresses; it is the same for both.
- As an owner or admin, set A’s policy with that domain as the only
allow_domainsentry and everything else empty. - With an
efa_test_key holdingmessages:sendon A, send from A to B throughPOST /v1/projects/{project_id}/inboxes/{inbox_id}/messages. Expect a 202 withresult: "queued". - Send from A to
someone@example.com. Expect 403policy_deniedwithrecipient_not_allowed. - Send to both B and
someone@example.comin one message. Expect the same 403, and no message to B. - Clear the allow list and put B’s whole domain in
block_domains. Send to B again. Expect 403policy_deniedwithblocked_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:
- No inbound allowlisting. Anyone can mail the inbox and the policy does not filter senders. Treat every inbound message as untrusted input, and see how inboxes receive mail for what the address accepts.
- No wildcards, TLD lists or patterns. A domain entry is one exact domain. List each subdomain you mean, within the 100-entry limit per list.
- No case folding for address entries. Address rules compare the part before the
@literally, so prefer domain rules and allow lists overblock_addresses. - No partial sends. One refused recipient refuses the message; if partial delivery matters, have the agent send one message per recipient.
- No preflight for the agent. Agent credentials cannot read the policy, so the agent has to handle a 403 rather than check first.
- No denial event. Refusals are synchronous errors, not webhook traffic, so log them where your agent makes the call.
- No customer write path for workspace or project layers in this build. They are
evaluated, and shown in
inherited, but the endpoint here writes the inbox layer.
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
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.
Prompt Injection by Email: What Actually Helps
Any stranger can email your agent. What an email-borne injection looks like, why message text must never grant permission, and the controls that limit damage.
Scoped Credentials for Agent Email: Read, Draft, Send
Give an agent the smallest useful email permission: project and inbox scoping, one-time secrets, draft-only grants, rotation, and revocation you can verify.