Controls · September 21, 2026
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.
Most agent credentials are minted in about four seconds, on the way to something more
interesting. The agent needs to read a mailbox, the form asks for a permission, you
pick the one that definitely works, you move on. Six weeks later the same secret is in
a CI runner, a laptop .env and a Slack thread, and nobody can say which inboxes it
reaches.
This is the article about those four seconds: what the permission levels do at request time, how scoping composes, what to do with a once-shown secret, and how to rotate and revoke provably. Scope names, error codes and limits below are the ones the API returns today, and assume the model in email for AI agents.
Still choosing between a remote MCP connection and a REST key? Start with MCP or API key for agent email. This article assumes the route is decided and the question is how narrow to make the grant.
Start from the job, not from the API surface
The scope list has thirty-two entries. Reading it top to bottom produces the wrong credential every time: the list is organised by resource, your agent is organised by task.
Write the task down in one sentence first — “read the invoices mailbox and propose a reply a person approves” — then translate:
| The agent needs to… | Scopes |
|---|---|
| See which mailboxes it has | inboxes:read |
| List and read mail | threads:read, messages:read |
| Follow the event stream | events:read |
| Open attachments already scanned clean | attachments:read |
| Propose a reply for a human to approve | drafts:read, drafts:write, drafts:submit |
| Transmit mail itself | messages:send |
| Create fixtures in a test project | test:simulate |
Anything not on that list is not part of the job. messages:raw (original MIME),
messages:delete, exports:write, policies:write, inboxes:delete, keys:manage,
members:manage and billing:manage are administrative, and a running agent has no
business holding them. That is not only advice: those eight cannot be attached to an
MCP connection at all, whatever the consent screen is asked for.
Two rules constrain you even if you ignore the advice. A credential can never carry a
permission its creator does not hold — asking returns HTTP 403 permission_denied, “A
credential cannot grant permissions its creator does not hold.” And scope strings are
checked against what you hold rather than against an enum, so a typo like
message:read is rejected with the same 403 rather than granting nothing quietly.
The three permission levels and what each really allows
Three presets cover almost every real integration, and they resolve to the same scope sets whether the grant arrives over OAuth or as a REST key. The MCP-or-key comparison has the preset table; what matters here is what each level means once a request is in flight.
Read is genuinely inert. No draft object, no send path, no way to reach an attachment that was not already scanned in an inbox the credential can see. A stolen read credential leaks mail; it does not send mail in your name.
Read and draft is where most integrations should start, and it is structural rather
than advisory. A principal holding drafts:submit without messages:send gets
approval_required back from the send endpoint — with
reason_codes: ["draft_only_principal"] — even on an inbox configured for direct
sending. The inbox policy cannot widen the credential. Over MCP the rule is enforced a
level lower: create_draft rebuilds the calling principal with messages:send
filtered out before the tool body runs, so that tool cannot transmit under any
configuration.
Read, draft and send adds messages:send, and from then on the only thing between
the agent and a recipient is the inbox policy — allow lists, block lists, the daily
recipient limit, the approval mode. Worth saying plainly: a send that succeeds reaches
accepted, meaning the provider acknowledged the submission. That is not proof anybody
received the message.
One gap to know about. The console’s key form offers two choices — “Read inboxes, messages, threads, and events” and “Read and send messages” — and neither includes the draft scopes. A draft-only key has to be created through the API, with the scopes named explicitly:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/keys" \
-H "Authorization: Bearer $EFA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "invoice-triage (draft only)",
"scopes": ["projects:read","inboxes:read","threads:read","messages:read",
"events:read","attachments:read",
"drafts:read","drafts:write","drafts:submit"],
"inbox_ids": ["ibx_..."]
}'
The 201 response contains id, prefix, scopes and secret. That is the only
time secret appears anywhere.
Scoping to a project
A key is not a workspace credential. It is created at
POST /v1/projects/{project_id}/keys, the project is written onto the key row, and
every request re-derives the key’s reachable projects from that row intersected with
the current grant of the human who authorised it.
The behaviour to internalise is the failure mode. A request for a project the
credential cannot reach does not return 403 — it returns HTTP 404 not_found,
“Resource not found.” Scoping is enforced as non-existence rather than refusal, so a
credential cannot enumerate a workspace’s projects. When you are debugging 404s on a
project ID you can see in the console, check the credential before you check the ID.
Projects are also where the test/live boundary lives: a project’s environment is fixed at creation and no endpoint changes it afterwards. One project per deployable unit of work, one credential per project, and you have a boundary that cannot drift.
Scoping to specific inboxes
inbox_ids accepts up to 50 IDs, each checked against your own grant and against the
project before the row is written — an inbox from another project returns not_found
and the whole creation fails.
The default is the part people get wrong. Omitting inbox_ids does not mean “no
inboxes”, it means every inbox in the project, including inboxes created later. The
console form spells this out as “All inboxes in this project”, and for a single-purpose
project that is defensible. In a shared project it is how a summarisation agent ends up
reading the recruiting mailbox someone added in March.
MCP connections behave differently on purpose: the endpoint requires at least one inbox
and rejects an empty array with invalid_request — “A connection must name at least
one inbox. An empty list is not unrestricted access.”
Inbox scoping fails the same way project scoping does — 404, not 403. The effective inbox set on a request is the intersection of the key’s inboxes, the authorising human’s inboxes, the project’s inboxes and, for a derived key, its parent’s. Intersection only ever narrows; no ordering of grants widens access.
MCP grants versus API keys: different revocation stories
Both credential types are re-resolved against the database on every request, so neither carries stale authority in a token. What differs is who can take them away, how long they live on their own, and what the client does next.
| API key | MCP connection | |
|---|---|---|
| Created by | POST /v1/projects/{id}/keys, needs keys:manage | Browser OAuth consent, or POST /v1/projects/{id}/connections |
| Lifetime if untouched | Indefinite — the create route accepts no expiry today | Row expires 30 days after creation |
| Access token | The secret itself, no expiry of its own | Bearer token, expires_in: 900 |
| Revoked by | POST /v1/keys/{key_id}/revoke, needs keys:manage | POST /v1/projects/{id}/connections/{id}/revoke, human only |
| After revocation | Next request 401 | Next tool call 401, inside the token window |
| Can hold admin scopes | Yes, if its creator does | No — eight are refused outright |
Three consequences worth planning around.
A revoked connection stops working immediately rather than when its token expires: the
15-minute access token is a pointer to the connection row, not a snapshot of it, and
that row’s status, revoked_at and expires_at are read on every /mcp call.
A connection that hits its 30-day expiry stops without anyone doing anything, and the
token endpoint accepts only grant_type=authorization_code — there is no refresh grant
today, so the client re-runs the authorisation flow. Calendar that for any grant that
matters; the symptom at the client end is usually an unhelpful “server unavailable”.
Only a human member can revoke a connection — the endpoint requires a human principal
holding inboxes:write. A key with keys:manage can revoke other keys but not
connections, and it cannot revoke a key whose scopes exceed its own: that returns 403
“Cannot administer a broader credential.”
Handling a secret that is shown once
A key looks like efa_live_1a2b3c4d_ followed by 43 characters of base64url — prefix,
then 32 random bytes. Only an HMAC-SHA256 digest of the tail is stored, under a
versioned server secret, so the plaintext genuinely cannot be produced again. The
console’s “Only a digest is stored, so it cannot be shown again” is a literal
description of the database.
Four practical consequences:
- Write it to your secret store before you close the dialog. Dismissing does not revoke the key, and the only recovery path is minting a replacement.
- The prefix is safe to log; the tail never is.
GET /v1/projects/{id}/keysreturnsprefixin the clear, so loggingefa_live_1a2b3c4din your own request logs joins your traffic to the credential inventory without a secret on disk. - Be careful with “Copy a ready-to-run command.” It is the quickstart
curlwith the secret already substituted, which also means it puts a live credential into your shell history if you paste it into an interactive terminal. - A leaked key is a revoke, not a rotate. If a secret reached a log aggregator or a chat channel, revoke first and reconcile afterwards.
Rotating without downtime
There is one more reason to be fluent in rotation: a credential’s access cannot be
edited. The console states it directly — “Access is fixed when a credential is
created: to change what something can reach, revoke it and set it up again.” There is
no PATCH on a key’s scopes or inbox list, so widening a grant, narrowing a grant and
rotating a compromised secret are one operation. Make it cheap.
A rotation that does not drop a request:
-
Create the replacement with the same scopes and the same
inbox_ids, named with a version suffix. Note the new prefix. -
Store the new secret alongside the old one under a second variable —
EFA_API_KEY_NEXT— and deploy. Nothing has changed behaviourally yet. -
Cut over: make the code read the new variable, deploy again, watch for
permission_denied. If the replacement’s scope list drifted, this is where it shows up rather than at 3am. -
Verify the new credential is what you think it is, with
GET /v1/me:curl -s "https://api.emailforagents.ai/v1/me" \ -H "Authorization: Bearer $EFA_API_KEY_NEXT"The response echoes
type,environment,project_ids,inbox_idsand the resolvedscopes. Compare it against the sentence you wrote when you started. This is the only way to see the effective grant after every intersection is applied. -
Revoke the old key, then confirm it returns 401.
A note on verification signals. GET /v1/projects/{id}/keys returns last_used_at and
the console renders “Never used” when it is null, but nothing in the key request path
currently writes that column — a key in daily use can still report null. Do not use
it to decide whether a key is safe to revoke; use your own logs keyed by prefix.
Connections are different: their last_used_at is updated after each successful MCP
tool call and is a real signal.
If you run many workers, derived keys help. A key holding keys:manage can mint child
keys, and the whole chain is validated per request: revoke or expire any key in it and
the leaf dies too. One parent per service, one child per worker, and decommissioning
the service is a single revoke.
Revoking, and proving the revocation took
Revocation is one idempotent call:
curl -X POST "https://api.emailforagents.ai/v1/keys/$EFA_KEY_ID/revoke" \
-H "Authorization: Bearer $EFA_ADMIN_KEY"
# {"revoked":true}
{"revoked":true} means the row was updated. It does not prove the credential is
dead. Prove that with the credential you just killed:
curl -s -o /dev/null -w '%{http_code}\n' \
"https://api.emailforagents.ai/v1/me" \
-H "Authorization: Bearer $EFA_OLD_KEY"
# 401
Read the status code carefully, because the three failures mean different things:
| Status | Code | What it actually means |
|---|---|---|
| 401 | unauthenticated | The credential, or the human who authorised it, is no longer active. This is what a successful revocation looks like. |
| 403 | permission_denied | The credential is alive and a scope is missing. Revocation did not happen. |
| 404 | not_found | The credential is alive; the project or inbox is outside its grant. |
A 403 after you thought you revoked something usually means you revoked a sibling key with a similar name. Check the prefix.
The quiet mechanism behind all of this is that permissions are recomputed from current database state per request and intersected with the authorising human’s current grant. When someone’s role narrows, every credential they created narrows on the next call, with no revocation step and no token refresh — and if that identity is deactivated, every credential it authorised starts returning 401, “Credential or its authorizer is no longer active.” That is what makes offboarding effective here.
One honest limitation: there is no customer-facing audit-log endpoint today. Revocations are recorded server-side, but you cannot read that history back over the API. If you need “who revoked what and when”, write it down when you do it.
Keeping test and live credentials apart
The environment is baked into the key. A test key begins efa_test_, a live key
efa_live_, and the environment is derived from that prefix and intersected with the
projects that actually have it. A test key cannot address a live project even if
someone pastes the right project ID, and a live key cannot reach a test one.
Keep them apart in your own systems with the same discipline:
- Separate variable names, never one
EFA_API_KEYwhose meaning depends on the deployment.EFA_TEST_KEYandEFA_LIVE_KEYare worth the extra line. - CI holds the test key only. No workflow needs a live credential to run a test suite,
and
POST /v1/projects/{id}/test/inboundneedstest:simulateplus a test project and a test inbox — anything else is 403test_mode_required. - Expect the test environment to be bounded: 3 active test inboxes, 100 retained
messages, 10 MiB of retained content, 7-day content expiry. Suites that accumulate
fixtures hit those caps with 403
test_limit_exceeded. - Remember what a live credential costs. The free allowance is one complimentary live inbox and 100 email units a month per verified owner identity, claimed by that identity’s first eligible workspace — another workspace does not renew it.
The full sandbox procedure is in testing agent email without sending real mail.
A quarterly access review you can actually run
Ninety minutes, once a quarter, per project. Credentials accumulate silently and nothing in the system will tell you when one has outlived its job.
- List what exists.
GET /v1/projects/{project_id}/keysreturns every key with its scopes,inbox_addresses,created_atandrevokedflag;GET /v1/projects/{project_id}/connectionsreturns every MCP grant with its scopes,status,expires_atand alast_used_atyou can trust. - Name the owner of each one out loud. Not the authorising identity — whoever would notice if it stopped working. A credential nobody can name is one to revoke.
- Check the inbox column. A key reaching every inbox in the project when its job is one mailbox is the highest-value fix on the list, and it is a rotate: narrow replacement, cut over, revoke.
- Check the level. Anything holding
messages:sendthat only ever proposes replies should be draft-only. Anything holding an administrative scope inside a running service should split into an admin credential a human uses and a runtime credential the service uses. - Revoke the connections nobody recognises, then confirm each returns 401. Revoke already-expired grants too; it costs nothing and makes the list readable.
- Sample two live credentials with
GET /v1/meand comparescopesandinbox_idsagainst what you believe you granted. This catches the credential that silently narrowed when someone’s role changed — which presents as an intermittent bug until you look at the grant. - Write the date and the decisions somewhere durable. The API will not tell you later what you did today.
If the review keeps finding over-broad keys, the project layout is the real problem. One project per job makes every subsequent review short.
Next: the REST loop in full if you are building the integration, or the MCP email server if a client is doing the connecting. When you are ready to mint the narrow credential, the quickstart has the exact request and its error codes.
Keep reading
MCP or API Key for Agent Email? How to Choose
Remote MCP over OAuth or a project-scoped REST key: what each can and cannot do, who authorises it, how revocation differs, and why teams use both.
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.