Integrations · September 22, 2026
Give an OpenClaw Agent an Email Inbox
Give an OpenClaw agent an email inbox it can read and draft in, with outbound mail held at a gate the agent itself is not permitted to release.
An always-on personal agent is the worst available place to put an unbounded send capability. OpenClaw is a self-hosted gateway that bridges chat apps to an AI agent, so it is awake, it is reachable from Telegram or Signal or a group thread, and everything arriving on those channels is text that some model will read as instruction. Give an OpenClaw agent an email inbox with sending switched on and you have handed that surface an address of its own and a way to talk to strangers.
So build it in the other order. This page grants read and draft first, leaves outbound mail sitting at an approval gate, and shows exactly what a human sees before releasing anything. Widening to unattended sending is the last step, not the first, and the controls that hold a send are the reason the first arrangement is usable at all.
Start with a grant that cannot send
OpenClaw supports remote MCP servers over streamable HTTP with OAuth, which is the connection this endpoint expects:
https://mcp.emailforagents.ai/mcp
An API key is rejected there by design. A bearer token that is not a connection grant
returns unauthenticated, and a principal that is not a connection returns
permission_denied. Only an OAuth connection grant may invoke the tools, which means the
authorising human is recorded and the grant can be revoked as a unit. Nothing here is a
certified or endorsed integration with OpenClaw, and there is no one-click install: what
follows is a configuration that works because both sides implement the same protocol.
Three OAuth scopes exist and each maps to a fixed scope set. The one to ask for is the middle one.
| Requested scope | Tools returned | What the agent can do |
|---|---|---|
email.read | 4 | read threads and message bodies |
email.draft | 5 | read, plus compose a fully formed message |
email.send | 6 | read, compose, and transmit subject to inbox policy |
email.draft is the posture this page builds. The agent reads its mail, writes complete
replies with real recipients and real threading, and cannot put any of them on the wire.
How to give an OpenClaw agent an email inbox, in two commands
Save the server, then run the OAuth flow:
openclaw mcp set email '{"url":"https://mcp.emailforagents.ai/mcp","transport":"streamable-http","auth":"oauth","oauth":{"scope":"email.draft"}}'
openclaw mcp login email
Or the equivalent block in the config file:
{
mcp: {
servers: {
email: {
url: "https://mcp.emailforagents.ai/mcp",
transport: "streamable-http",
auth: "oauth",
oauth: { scope: "email.draft" },
enabled: true,
},
},
},
}
Two fields there are load-bearing and both have a bad default for this server.
transport must be set explicitly. OpenClaw’s transport documentation states that
when the field is omitted it defaults to SSE. A GET on this endpoint returns HTTP 405
with Allow: POST, because it speaks streamable HTTP only, so an omitted transport fails
at connect time and presents as an unreachable server rather than a configuration error.
If openclaw mcp doctor email --probe reports a connection failure while curl -X POST
against the same URL returns JSON-RPC, the transport is the problem.
auth: "oauth" replaces static authorization headers. Putting a key in headers
instead is the failure this endpoint is built to produce rather than tolerate.
One behaviour worth knowing before you debug it: when a server rejects a token with
insufficient_scope, OpenClaw preserves the scope you asked for and prompts you to run
openclaw mcp login again rather than silently refreshing, because a refresh cannot
grant permissions the grant never had. That is the right behaviour, and it means the way
to widen from drafting to sending is a deliberate re-consent, not a config edit.
The five-tool session, and why create_draft cannot transmit
With email.draft, tools/list returns five tools: list_inboxes, list_threads,
list_messages, get_message and create_draft. send_message is not in the list.
That absence is the control. Each tool declares the scopes it needs, the server filters
tools/list by the grant, and a tools/call naming a filtered tool is re-checked and
refused with permission_denied and the message “This connection cannot use that tool.”
So an instruction hidden inside an inbound email has no send tool to name, and naming one
anyway does not work.
The MCP tool surface as a permission boundary
works through how that set is computed per request.
The more interesting half is what create_draft actually is. It is not a separate
lightweight endpoint that stores text somewhere. In packages/mcp/src/index.ts the
handler for create_draft calls the same send service as send_message, with one
difference: it builds an actor by copying the principal and filtering messages:send
out of its scopes before the call. The request then runs the entire send path, the same
strict schema, the same canonical payload hash, the same idempotency record, the same
policy chain, and arrives at the branch that holds a draft-only principal.
Three consequences follow, and they are the reason this posture is worth the trouble:
- The draft is a real, validated message. Recipient shapes, the 25-recipient ceiling, subject length, attachment reuse and thread parentage are all enforced at draft time, not deferred to approval. A draft that exists is a draft that can be sent.
create_draftcannot transmit on any grant, includingemail.send. The scope is removed in the call path itself, so there is no combination of permissions under which that tool puts mail on the wire.- The reply is already threaded. Pass the inbound message’s ID as
reply_to_message_idand the draft reuses the parent’s thread, so approving it later does not produce an orphan message.
The tool also requires a stable idempotency_key, between 1 and 200 characters. Derive
it from the message being answered rather than from the attempt, or an agent that retries
a tool call after a timeout will queue a second identical approval for a human to
disambiguate.
toolFilter is a preference; the grant is the boundary
OpenClaw lets you filter a server’s tools per client with toolFilter.include and
toolFilter.exclude, accepting exact names or simple globs. It is tempting to leave the
grant at email.send and write "exclude": ["send_message"].
Do not use that as the send control. The filter hides the tool from the agent in that
OpenClaw configuration. The server still returns send_message for that grant, and
anything else holding the same credential still has it: a second client, a manual
JSON-RPC call, a plugin, an operator who edits the config, a process that reads the token
out of the gateway’s state. Narrowing the grant is a boundary enforced on every request
by the server. Narrowing toolFilter is a local preference enforced by the process an
attacker has already reached.
Use both, in the right roles. Request email.draft so send_message does not exist for
that connection, and use toolFilter for what it is good at, which is keeping an
irrelevant tool out of the model’s context.
A connection’s scopes are also not freely composed. They are whichever of three fixed
presets the OAuth scope maps to, and none of the three contains keys:manage,
members:manage, billing:manage, policies:write, inboxes:delete, messages:delete,
messages:raw or exports:write. No consent screen and no misread preset yields a
session that can rotate your keys, read raw MIME or loosen the policy that forced the
approval. The grant is then intersected with the authorising human’s current permissions
on every request, so if that person loses project access the connection narrows on the
next call with no revocation step.
Shared or per-requester: who is actually driving the address
This is the OpenClaw-specific question that decides how much the approval gate is doing, and it has nothing to do with email.
OpenClaw’s OAuth block takes an identity field. The default is shared: one
operator-managed credential, stored in the gateway’s state database, used by every sender
the gateway accepts. Set "oauth": {"identity": "per-requester"} instead and each sender
authenticates separately, which requires gateway.publicOrigin to be set to a reachable
HTTPS origin because the provider redirects back through
<gateway.publicOrigin>/oauth/mcp/callback. When a sender calls a tool before connecting
an account, OpenClaw returns a sign-in link rather than exposing somebody else’s
credential.
For a single-operator gateway on your own machine, shared identity is correct and simpler. For a gateway anyone else can message, including a group chat with an allowlist you have not audited lately, shared identity means every one of those people is driving your agent’s email address through your grant, and the audit trail on our side records one connection rather than a person. In that arrangement the approval queue is not a nicety. It is the only place a human distinguishes between what you asked for and what somebody in a group thread asked for.
Either way the grant carries a clock. A connection is created with a 30-day expiry and
nothing extends it. Once expires_at passes, the grant resolves as denied, and the
symptom is an agent that read its mail fine for a month and now cannot. Re-consent with
openclaw mcp login email.
What the operator sees when the agent asks to send
When the agent calls create_draft, the send is held and an approval is created. In the
console the request lands in a list that refreshes every five seconds, showing the
subject, the time, To plus a count of the other recipients, the draft version, and a
state tag. Opening one shows a fact panel before any content:
- Status, and Held by, which renders the
reason_codeson the stored record. Expect “Inbox policy” here rather than a code, and know why: the tool call’s own response carriesreason_codes: ["draft_only_principal"], but that fallback is applied to the response and not to the approval row, so when no inbox rule matched the stored list is empty and the console shows its default label. A code appears on this line only when a policy rule fired, in which case it is one ofalways,new_recipient_domainorattachments. - Recipients, as a count across To, Cc and Bcc, with every address listed individually further down, Bcc included. In a test project the same line says the send is simulated and nothing leaves the sandbox.
- Draft version, annotated with the fact that approving submits exactly that version.
- Requested and Expires.
Below that: the message body, rendered from a server-sanitised projection rather than the canonical HTML the hash was taken over; the parent message, expandable, when the draft is a reply; and per-file attachment state.
Four behaviours of that screen matter more for an always-on agent than for a scripted one, because an always-on agent revises things while you are reading them.
Approve is disabled until the review is fully loadable; reject never is. If the sanitised body has not arrived, or any attachment is not both scanned clean and fully stored, the approve control stays unavailable and the page says so. You can still reject. The asymmetry is deliberate: the safe action is always available, the irreversible one is gated on having actually seen the thing.
If the agent replaces the draft mid-review, the console says so by version. You get an alert naming the version you opened and the version now on screen, the confirmation dialog is withdrawn, and you have to acknowledge the new version before approving. For an agent that may be reacting to a chat message while you read, this is the difference between approving what you read and approving what it says now.
Approving is two steps and the second one counts recipients. In a live project the confirmation asks whether to send this real email to a stated number of recipients and says that it cannot be undone. In a test project the same dialog says nothing leaves the sandbox.
The agent cannot approve its own draft. The approve route requires a human principal
holding approvals:decide, and a connection grant is not a human principal: it fails
with “Only a human member can perform this action.” No prompt, no scope request and no
tool call reaches the release button.
Human approval before an agent sends email covers
what approval binds to and the five properties a home-built reviewer has to reproduce.
One number to plan around: a pending approval expires seven days after it is created,
and the record then reads expired rather than pending. An agent that drafts overnight
and an operator who reviews the queue fortnightly will lose drafts. Either check the
queue weekly or push a notification out of approval.requested.
The two approval events that fire, and the two that do not
Four approval event types are declared: approval.requested, approval.approved,
approval.rejected and approval.invalidated. Two are emitted by the current build.
approval.requested fires when a send is held, carrying approval_id and draft_id.
approval.rejected fires on rejection. approval.approved and approval.invalidated are
accepted at webhook registration and simply never arrive.
That distinction is worth checking against
the event table before you build anything on it. A gateway
waiting on approval.approved to hear that its message went out is waiting on an event
that never fires, and waits forever with nothing in any log explaining why. Detect the
release by watching the message or the event cursor for the send that follows it.
For OpenClaw specifically, approval.requested is the event to push into a channel. A
self-hosted gateway is usually already the thing that can message you on Signal or
Telegram, so a small webhook receiver that turns approval.requested into a chat message
with the approval ID gets you notified in the place you already are. Verify the callback
before trusting it: each carries webhook-id, webhook-timestamp and
webhook-signature, which is v1, followed by the base64 HMAC-SHA256 of
id.timestamp.rawBody. Delivery is at-least-once, up to eight attempts, so deduplicate on
the event ID or you will get paged twice for one draft.
When to widen the grant, and what does not change
Move to email.send when the agent’s outbound traffic is high-volume, low-consequence
and bounded by recipient, and when somebody is actually reading the approval queue rather
than clicking through it. A gate nobody reads is worse than no gate, because it
manufactures a record of review that did not happen.
Widening the OAuth scope does not remove the gate, which is the part people get wrong.
send_message appears in the tool list, but inbox policy still applies, so a rule
requiring approval for a new recipient domain or for attachments still holds those sends
and still emits approval.requested. The scope decides which tools exist; the inbox
policy decides which sends need a person. They are separate controls and you can run
them in any combination. The presets and how to scope a grant to one inbox are on
the integrations page and in
the MCP email server walkthrough.
What does not change at any scope: nothing stops a determined operator from being talked
into approving a hostile draft, so treat email content as untrusted data throughout, as
prompt injection through email sets out. No
certification is held here, no uptime commitment is published, and no MCP client is
certified, OpenClaw included. The free allowance is one live inbox and 100 email units a
month per verified owner identity, where a unit is one message received or one recipient
sent to; Builder and Team are proposed plans with checkout disabled, and custom domains
require a paid plan, so every live address today sits on agents.emailforagents.ai.
Rehearse the whole thing before it can reach anyone. Point the same config at a test
project, where inboxes use a non-routable .mail.invalid address and a simulated
transport, and inject inbound mail with
POST /v1/projects/{project_id}/test/inbound. The agent reads a real message, drafts a
real reply, and a real approval appears in the console, with nothing capable of leaving
the system.
Keep reading
MCP Email Server: Give Your Agent Client a Real Inbox
How a remote MCP email server works, what the consent screen must show, how to scope a grant to one inbox, and how to verify it before allowing sends.
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.
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.