Controls · September 23, 2026
Read-Only Email Access for an AI Agent
Read-only email access for an AI agent is five scope strings. The exact list, what the MCP session holds, and three ways the grant quietly leaks.
A triage agent is supposed to read the inbox and tell you what is in it. Nothing it does should ever reach a recipient. Six weeks later somebody notices an outbound message with the agent’s name on it, traces the credential, and finds one extra scope on the grant that nobody remembers adding.
This is the set of scope strings that produces genuinely read only email access for an AI agent, what that grant looks like from inside an MCP session, and the three ways it stops being read-only without producing an error anywhere. If you are still deciding how wide the grant should be in the first place, scoped credentials for agent email covers the read, draft and send tiers; this article is only about the narrowest one and how it leaks.
Everything below was checked against the deployed source in this repository, and the file and line are given wherever the behaviour is not on a documentation page.
The five scopes that make read-only email access for an AI agent
A read-only credential is exactly these five strings, verbatim:
inboxes:read
threads:read
messages:read
attachments:read
events:read
That is not a suggestion. It is the read preset in
packages/contracts/src/connections.ts, the scope set the platform itself issues when an
agent connects with the OAuth scope email.read. Matching it by hand for a REST key gives
you a credential with the same authority as the narrowest connection the consent screen can
produce.
| Scope | What it unlocks | What it still cannot do |
|---|---|---|
inboxes:read | Listing inboxes and their addresses | Creating, renaming or deleting one |
threads:read | Listing and paging threads in an authorised inbox | Changing thread state |
messages:read | Message metadata, previews and bounded bodies | Any write to the send path |
attachments:read | Downloading an attachment once its scan is clean | Uploading anything |
events:read | Polling the project event cursor for message.received | Registering or editing a webhook |
Those five cover the whole read surface over REST:
GET /v1/projects/{project_id}/inboxes, the threads and messages collections under an
inbox, .../messages/{message_id}/body, and GET /v1/projects/{project_id}/events. Each of
those handlers calls requireScope for exactly one of the five and nothing else, which you
can confirm in packages/domain/src/services.ts at lines 817, 3429, 3102, 3489 and 3129.
attachments:read has one behaviour worth knowing before you file a bug against it. The
download handler refuses any attachment whose scan_status is not clean, with
“This file cannot be downloaded until a successful scan marks it clean.” The scope grants
the route, not the bytes. An agent that reads an inbox seconds after delivery will see
attachment metadata in list_messages and get a refusal on the download, and the correct
response is to retry rather than to widen the grant.
Note what is absent. No projects:read, because an agent pointed at a known project ID
never needs to enumerate projects. No approvals:read, because a read-only agent has
nothing in the approval queue. No test:simulate, which is the scope that lets a caller
inject a message into a test inbox and which therefore belongs to your CI harness rather
than to a live triage agent.
Minting it is one call, and the inbox restriction matters as much as the scope list:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/keys" \
-H "Authorization: Bearer $EFA_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "triage-agent (read only)",
"scopes": ["inboxes:read","threads:read","messages:read","attachments:read","events:read"],
"inbox_ids": ["ibx_..."]
}'
The secret comes back once, in secret, prefixed efa_live_ or efa_test_. Omitting
inbox_ids is only possible for an owner or admin: for anyone whose own grant is already
narrowed to specific inboxes, createApiKey refuses with “A scoped credential must choose
allowed inboxes.” When an owner does omit it, no narrowing row exists and the key reaches
every inbox in its environment, so a read-only grant that is not also inbox-scoped is half
a control.
What the agent’s MCP session contains on a read grant
Four tools. list_inboxes, list_threads, list_messages, get_message. create_draft
and send_message are not in the session at all, and that is not cosmetic filtering.
curl -X POST https://mcp.emailforagents.ai/mcp \
-H "Authorization: Bearer $EFA_OAUTH_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
visibleTools() in packages/mcp/src/index.ts:405 filters the tool catalogue to the tools
whose declared scopes are all present on the principal. create_draft declares
drafts:write and send_message declares messages:send, neither of which is in the read
preset, so both disappear from tools/list. The tools/call handler at line 281 runs the
same filter again before dispatching, so a tool name the model produced from its training
data rather than from the catalogue comes back as permission_denied with “This connection
cannot use that tool.” and never reaches a handler.
This is the part that changes the threat model. A model cannot be argued into using a tool
that is not in its context, which means the classic injected instruction in the body of an
inbound email has no send tool to reach for.
The MCP tool surface as the permission boundary
works through the full mechanism, including why an API key is rejected at /mcp by design
and only an OAuth connection grant may call the tools.
Three consent-time details are worth knowing before you hand a connection URL to a teammate, because each one is a way the read grant you intended does not happen.
The consent step cannot choose a preset ranked above the one the client requested.
packages/domain/src/oauth-consent.ts:208 compares read at rank 0 against read_draft at
1 and read_draft_send at 2 and refuses the widening with “Selected access exceeds the
authorization request.” The same ceiling is applied a second time at the token exchange,
where the stored scopes are filtered against the preset derived from the original requested
scope before the access token is issued. Request email.read and the connection is
read-only no matter who clicks Allow and no matter what the row says.
The approving human cannot grant scopes they do not themselves hold, checked five lines
later with “You cannot grant permissions you do not have.” And the approver needs
inboxes:write to complete the screen at all, which the viewer and operator roles do
not have. In practice that means the person who approves an agent’s read-only connection
must be an owner, an admin or a developer. A viewer cannot approve the narrowest possible
grant, which surprises teams who staffed the review step with their least privileged role
on purpose.
Leak one: adding messages:raw, a scope that authorises nothing
messages:raw is the scope people add when an agent needs headers the parsed JSON does not
expose. It buys nothing today, and it costs you the read-only property on paper.
Grep the repository for it. It appears in exactly three places: the SCOPES list in
packages/contracts/src/principal.ts, the developer role in the same file, and
ADMIN_SCOPES_FORBIDDEN_ON_CONNECTIONS in packages/contracts/src/connections.ts. No
handler anywhere calls requireScope(principal, "messages:raw"), so there is no route it
opens. There is also no raw endpoint to open: the only message-content route in
packages/contracts/src/openapi.ts is .../messages/{message_id}/body, every message the
list endpoint returns carries original_raw_available: false hard-coded at
packages/domain/src/services.ts:3124, and the export stream documents itself as excluding
“raw MIME, Bcc and attachment bytes”. Original receipt MIME is read once during ingestion,
to pull envelope recipients off the Received-For headers, and is never handed back to a
tenant.
Because the scope sits on the forbidden list, it cannot reach an agent over MCP at all. A
connection whose preset somehow carried it would be rejected. The only way it ends up on an
agent credential is a hand-written REST key, and there it does one thing: it raises the
floor on who could have minted that key, since ROLE_SCOPES.viewer does not include it and
a credential cannot exceed its creator. You end up with a credential that a narrow reviewer
could not have produced, granting access to a surface that does not exist. Drop it.
Leak two: adding drafts:submit, the one that actually sends
This is the one that sends mail, and it is the one that looks most harmless, because “the agent only writes drafts” sounds like a read-adjacent capability.
It is not. drafts:submit without messages:send makes the credential what the code calls
a draft-only principal, defined at packages/domain/src/services.ts:4779 as holding
drafts:submit and not messages:send. A draft-only principal may still call
POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages. The request is accepted, HTTP
202 comes back with result: "approval_required", a draft_id, an approval_id, and
reason_codes: ["draft_only_principal"], and an approval.requested event lands on the
project cursor. A human approves, the message transmits for real. The agent did not send
it, but the agent originated it, and a read-only agent is one that cannot originate
outbound mail at all.
The distinction people miss is that drafts:write and drafts:submit are different
capabilities with different blast radii.
| You want the agent to | Grant | Can it originate outbound mail? |
|---|---|---|
| Read and summarise, nothing else | read preset, the five scopes | No |
| Compose text a human copies out manually | drafts:write only, REST key | No, create_draft cannot transmit |
| Queue mail for a human to release | read_draft preset or drafts:submit | Yes, through the approval queue |
| Send unattended within policy | read_draft_send preset | Yes, directly |
Row two is only reachable over REST. Over MCP you cannot separate the two: the read_draft
preset bundles drafts:read, drafts:write and drafts:submit together, so an
email.draft connection is by construction a connection that can put messages into the
approval queue.
There is an operational trap in row three that bites the week after setup. The role that
holds approvals:decide is owner, admin or operator. The developer role holds
neither approvals:decide nor approvals:read, so the engineer who wired the agent up
cannot see or clear the queue their own agent is filling. Staff the reviewer as an operator
before you turn on a draft-submitting agent, not after the first message stalls in it.
If a queue is what you want, it is a good design and the human approval gate describes how to run it. Just do not file it under read-only. The queue is a delay, not a wall, and the sending controls documentation sets out which policy reasons force a message into it.
Leak three: minting the key from an over-privileged human
A credential cannot exceed its creator. createApiKey at
packages/domain/src/services.ts:609 walks the requested scope list and throws
permission_denied at line 616 with “A credential cannot grant permissions its creator does
not hold.” This happens at creation time, against the creator’s live grant, not later at use
time. You find out when you type the scope, not when the agent trips over it in production
at 2am.
The trap is that this rule caps a credential; it does not minimise one. Ask who is allowed
to mint keys at all and the asymmetry becomes obvious. keys:manage is required to reach the
endpoint, and keys:manage appears in the owner, admin and developer roles only. It
appears in none of the three connection presets. The humans who can mint your read-only key
are precisely the humans broad enough to have minted a sending one by accident.
There is a second-order effect here that is not documented anywhere else, and it surprises
people the first time it bites. A human principal has one scope set, not one per project.
currentHumanGrant in packages/domain/src/current-grants.ts computes it as the role’s
scopes filtered down to those present in every active assignment preset the human holds:
scopes = ROLE_SCOPES[role].filter(scope => presets.every(preset => preset.includes(scope)))
The source comment explains the intent, which is that a broad assignment on one inbox must
not escalate a narrower assignment on a sibling. The consequence is broader than the intent.
Give a developer a read assignment on one unrelated project and their entire principal
collapses to the five read scopes everywhere, including their ability to mint any key at
all. Owners and admins are exempt, because the same function short-circuits assignment
lookup for those two roles and leaves the preset list empty, so the intersection has nothing
to narrow against. If a teammate reports that key creation started returning
permission_denied for no reason, look at their newest project assignment rather than at
their role, and notice that the colleague who cannot reproduce it is an admin.
The same intersection runs in the useful direction too. A connection’s effective scopes are
the stored connection scopes intersected with the owning human’s current grant, and an API
key’s are intersected with the current grant of every human in its parent chain. Remove the
authorising human’s project assignment and every credential they issued narrows on the very
next request. Remove their membership entirely and currentStoredGrant returns
unauthenticated with “Credential or its authorizer is no longer active.” There is no
revocation step to remember, because the key never carried the authority in the first place.
Authority is recomputed per request, so there is no cached-token window
The token is a lookup handle, not a claim set. On every request the key is resolved through
a recursive walk of its parent chain in packages/domain/src/current-grants.ts, and for
each link the effective scope set is intersected with both the stored scopes on that key and
the current grant of the human who authorised it. Project and inbox allowances are
intersected the same way, then intersected again against the projects and inboxes that
actually exist in the key’s environment.
Three consequences worth designing around:
- A read-only key cannot become a sending key by any path, because widening requires the stored scope list to change, and the stored list is only ever intersected, never unioned.
- Expiry is bounded by the chain. A child key must expire before its parent and in the
future;
createApiKeyrejects anything else with “A credential must expire before its parent and in the future.” - Revocation is immediate rather than eventually consistent. There is no window in which a previously issued token still carries authority that the database has withdrawn, because the token never carried it.
That last property is the one to bring to a security questionnaire, since it is checkable in a couple of minutes and most vendors cannot answer it in the affirmative. Evaluating agent email security without certificates lists the rest of the questions worth asking, and the security page states plainly which technical controls are shipped and which audit certificates are not held. No certification is claimed here, and a per-request authority recomputation is a control you can verify yourself rather than one you take on trust.
Where Gmail-shaped advice will mislead you
Nearly every guide on this query is written against Gmail or Microsoft Graph, and the advice is reasonable for those APIs and wrong here in one specific way.
On Gmail, the usual read-plus-draft recipe is gmail.readonly plus gmail.compose. Google
describes gmail.compose in its scope reference as “Manage drafts and send emails”, checked
on 22 September 2026. An MCP server built on that pair can present a tool list with no send
tool, and the underlying OAuth token can still send through the Gmail API directly, because
the tool list is a property of the server and the authority is a property of the token.
Anything that steals or borrows that token sends mail. A short tool list is a convenience,
not a boundary.
The reason the same reasoning does not apply to a read grant here is that the credential the
agent holds is the connection grant. There is no broader upstream token behind it that the
tool list is hiding. tools/call re-checks the same scope set that produced tools/list, an
API key is rejected at the MCP endpoint by design, and the scopes on a connection can only
come from one of three presets. Widening requires a new consent, and the consent step will
not issue a preset above the one the client asked for.
Say the limits out loud, because a control described past its actual reach is worse than no control. Read-only bounds what the agent can do to your mail. It does nothing about what the agent does with what it reads: an agent that can read an inbox and also call an unrelated HTTP tool can exfiltrate every message in it, and prompt injection through inbound email is a live concern for a read-only agent precisely because reading hostile text is its entire job. The grant narrows the email surface. It is not a sandbox for the rest of the agent.
Verifying a grant is read-only in three calls
Do not infer it from the console. Ask the API.
First, list the tools the connection actually exposes, using the tools/list call above. A
read grant returns exactly four tools and neither create_draft nor send_message is among
them. If either is present, the connection was issued a wider preset than you think.
Second, attempt a send with the credential and confirm the refusal. Include the
Idempotency-Key header, which is mandatory on the send path; without it the request fails
with invalid_request and “Idempotency-Key is required for send operations.” before
authorisation is evaluated, and that failure proves nothing about scope.
curl -i -X POST \
"https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
-H "Authorization: Bearer $EFA_READ_ONLY_KEY" \
-H "Idempotency-Key: readonly-probe-v1" \
-H "Content-Type: application/json" \
-d '{"to":[{"email":"probe@example.com"}],"subject":"probe","text":"probe"}'
The expected result is permission_denied with “This credential cannot perform that
action.”, raised by requireScope in packages/domain/src/authorize.ts. Read the body, not
just the status. A 202 carrying result: "approval_required" is the draft-only failure from
earlier in this article: the credential holds drafts:submit and is not read-only. Run this
probe against a test project first, where the inbox is non-routable and cannot reach an
internet recipient either way.
Third, read the key back and diff the scope list against the five strings. Anything extra is either dead weight or a way out. Put that diff in whatever job already checks your infrastructure, so the next scope somebody adds shows up as a failing check rather than as an outbound message with your agent’s name on it. The controls overview covers the inbox-level policies that sit alongside the grant, and they are worth setting even for an agent that cannot send, because the policy outlives the credential.
Keep reading
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.
The MCP Tool Surface Is the Permission Boundary
How the MCP tool surface permission boundary is computed per request, what a filtered tools/call returns, and three ways the tool list shrinks under you.
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.