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 hasinboxes:read
List and read mailthreads:read, messages:read
Follow the event streamevents:read
Open attachments already scanned cleanattachments:read
Propose a reply for a human to approvedrafts:read, drafts:write, drafts:submit
Transmit mail itselfmessages:send
Create fixtures in a test projecttest: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 keyMCP connection
Created byPOST /v1/projects/{id}/keys, needs keys:manageBrowser OAuth consent, or POST /v1/projects/{id}/connections
Lifetime if untouchedIndefinite — the create route accepts no expiry todayRow expires 30 days after creation
Access tokenThe secret itself, no expiry of its ownBearer token, expires_in: 900
Revoked byPOST /v1/keys/{key_id}/revoke, needs keys:managePOST /v1/projects/{id}/connections/{id}/revoke, human only
After revocationNext request 401Next tool call 401, inside the token window
Can hold admin scopesYes, if its creator doesNo — 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:

  1. 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.
  2. The prefix is safe to log; the tail never is. GET /v1/projects/{id}/keys returns prefix in the clear, so logging efa_live_1a2b3c4d in your own request logs joins your traffic to the credential inventory without a secret on disk.
  3. Be careful with “Copy a ready-to-run command.” It is the quickstart curl with the secret already substituted, which also means it puts a live credential into your shell history if you paste it into an interactive terminal.
  4. 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:

  1. Create the replacement with the same scopes and the same inbox_ids, named with a version suffix. Note the new prefix.

  2. Store the new secret alongside the old one under a second variable — EFA_API_KEY_NEXT — and deploy. Nothing has changed behaviourally yet.

  3. 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.

  4. 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_ids and the resolved scopes. 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.

  5. 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:

StatusCodeWhat it actually means
401unauthenticatedThe credential, or the human who authorised it, is no longer active. This is what a successful revocation looks like.
403permission_deniedThe credential is alive and a scope is missing. Revocation did not happen.
404not_foundThe 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:

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.

  1. List what exists. GET /v1/projects/{project_id}/keys returns every key with its scopes, inbox_addresses, created_at and revoked flag; GET /v1/projects/{project_id}/connections returns every MCP grant with its scopes, status, expires_at and a last_used_at you can trust.
  2. 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.
  3. 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.
  4. Check the level. Anything holding messages:send that 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.
  5. Revoke the connections nobody recognises, then confirm each returns 401. Revoke already-expired grants too; it costs nothing and makes the list readable.
  6. Sample two live credentials with GET /v1/me and compare scopes and inbox_ids against 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.
  7. 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

All guides · Documentation · Controls