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.

ScopeWhat it unlocksWhat it still cannot do
inboxes:readListing inboxes and their addressesCreating, renaming or deleting one
threads:readListing and paging threads in an authorised inboxChanging thread state
messages:readMessage metadata, previews and bounded bodiesAny write to the send path
attachments:readDownloading an attachment once its scan is cleanUploading anything
events:readPolling the project event cursor for message.receivedRegistering 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 toGrantCan it originate outbound mail?
Read and summarise, nothing elseread preset, the five scopesNo
Compose text a human copies out manuallydrafts:write only, REST keyNo, create_draft cannot transmit
Queue mail for a human to releaseread_draft preset or drafts:submitYes, through the approval queue
Send unattended within policyread_draft_send presetYes, 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:

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

All guides · Documentation · Controls