Integrations · September 21, 2026
n8n AI Agent Email Inbox Without the Gmail Node
Wire an n8n AI agent email inbox to the HTTP Request node: the send call, an Idempotency-Key that survives every n8n retry, and signed inbound webhooks.
Almost every n8n email-agent tutorial ends the same way: an AI Agent node with a Gmail
tool hanging off it. It works. It also means the agent sends as whoever authorised the
Google credential. Their name is on the From line, their account absorbs the
complaints, and their mailbox is what Google restricts if the sending behaviour goes
wrong. An n8n AI agent email inbox that belongs to the agent removes that coupling.
Below is the wiring. One HTTP Request node against the send endpoint, an
Idempotency-Key expression that survives each of n8n’s three retry mechanisms, the
one it cannot survive, and a signed inbound webhook that starts the workflow. If the
prior question is whether to borrow a human mailbox at all,
agent inbox versus connecting Gmail settles that one.
What the Gmail node actually gives your agent
The Gmail node authenticates with a Google account’s OAuth2 credential, and everything the node does, the agent does as that account. The Message resource alone exposes Send, Reply, Delete, Mark as Read and Add Label, and the AI Agent can drive their parameters directly. So the authenticated human is the sender of record, a hostile instruction in an inbound email is executing against years of that person’s correspondence, and if Google restricts the account for its sending behaviour a person loses their mailbox rather than a service identity losing its address. None of that is a bug in n8n. It is what borrowing a credential means.
The inbound half has a quieter problem that costs people an afternoon. The Gmail
Trigger has exactly one event, Message Received, and it fires on a Poll Time
schedule rather than a push subscription, so there is always a window between arrival
and execution. Two of its defaults then interact badly with a mailbox a human also
uses. Read Status defaults to Unread emails only, so if anyone opens the
message in the Gmail UI before the next poll, the workflow never sees it and nothing in
the execution log records a miss. Simplify defaults to on, which returns a reduced
response instead of the raw Gmail payload, so an agent reading $json.snippet is
reasoning over a truncated preview rather than the message. Max Emails per Poll
defaults to 10 with a ceiling of 50, and the overflow queues for the next cycle.
Then the mundane constraint: one address per Google account. Ten workflows that each need their own reply-to address mean ten accounts and ten refresh tokens to keep alive.
Three ways to wire an n8n AI agent email inbox
| Approach | Whose address it is | Inbound path | What a prompt injection can reach |
|---|---|---|---|
| Gmail node as an AI Agent tool | the human who authorised | Gmail Trigger poll, unread only | every operation the node exposes, including Send and Delete |
| HTTP Request node against this REST API | the project | signed message.received webhook | exactly the scopes on the key, and nothing outside them |
| MCP Client Tool node against a grant | the project | signed message.received webhook | only the tools tools/list returns for that grant |
The rest of this page builds row two, then row three. Row one stays correct for one case worth naming plainly: if the job is to act inside a specific person’s real mailbox, triaging their actual inbox and applying their actual labels, a separate address is the wrong shape and the Gmail node is the right tool. Borrowing a mailbox is only a defect when the agent was supposed to have an identity of its own.
Live mail is received and sent on agents.emailforagents.ai, the REST base is
https://api.emailforagents.ai/v1, and every credential is a bearer token in the
Authorization header, which is exactly what n8n’s generic Header Auth credential
produces. The full surface is on the developer reference.
Wiring the HTTP Request node to the send endpoint
One node. Method POST, and this URL:
https://api.emailforagents.ai/v1/projects/{project_id}/inboxes/{inbox_id}/messages
Configure it like this:
- Authentication: Generic Credential Type, then Header Auth. Name
Authorization, valueBearer efa_live_.... There is no predefined credential type for this API in n8n, so generic is the correct choice rather than a workaround. - Send Headers: on, Using Fields Below. Add
Idempotency-Keywith the expression from the next section. - Send Body: on, Body Content Type
JSON, Specify BodyUsing JSON.
The body for a threaded reply:
{
"to": [{ "email": "{{ $json.from_email }}" }],
"subject": {{ JSON.stringify("Re: " + $json.subject) }},
"reply_to_message_id": "{{ $json.message_id }}",
"text": {{ JSON.stringify($json.reply_text) }}
}
Three details in there are load-bearing.
to is an array of objects with an email field, not a bare string and not a
comma-separated list. [{"email":"ap@example.com","name":"Accounts Payable"}] is
valid; "ap@example.com" is a 400. The schema is strict, so an unknown top-level field
is a rejection rather than a warning. Up to 25 unique recipients across To, Cc and Bcc
combined, counted after deduplication, so the same address in To and Cc costs one.
subject is 1 to 998 characters, at least one of text or html is required, and the
whole JSON body is capped at 1 MiB.
A reply needs a read scope, not just a send scope. Supplying reply_to_message_id
makes the request require messages:read on top of messages:send, and
attachment_ids likewise requires attachments:read. A key minted with
messages:send alone sends a fresh email fine, then 403s the moment the workflow
replies into a thread, so the node passes your first manual test and fails on the first
real inbound trigger.
Wrap any model-generated string in JSON.stringify and drop the surrounding
quotes. An LLM reply containing a double quote, a newline or a stray backslash will
otherwise produce invalid JSON, and the node fails with a parse error that reads like
an API problem. Note there are no quotation marks around {{ JSON.stringify(...) }} in
the snippet above; the function supplies them.
In Options, turn on Never Error and Include Response Headers and Status. Both
success paths return HTTP 202, so you want the status code visible, and a 409 should
reach an IF node rather than halt the workflow. Branch on result:
"result": "queued"gives youmessage_id,thread_idandstate."result": "approval_required"gives youdraft_id,approval_idandreason_codes. No email exists yet. Do not retry it and do not treat it as a failure.
For the project and inbox IDs, reach for $env before $vars. On self-hosted n8n
$env works out of the box, because N8N_BLOCK_ENV_ACCESS_IN_NODE ships as false.
$vars is different: n8n lists custom variables among the features that need a paid
plan, so an expression from a tutorial recorded on an Enterprise instance resolves to
nothing on yours with no error to explain it. Failing both, put the IDs in the URL
literally. They are not secrets. The key is, and it belongs in the credential.
Building the Idempotency-Key from the execution ID
Idempotency-Key is mandatory here. A send without it fails with HTTP 400
invalid_request before the JSON body is parsed, so a missing header masks every other
mistake in the node until you fix it. Being mandatory is the useful half: you cannot
half-configure the node and discover the gap during an incident.
One asymmetry, if you also build the approval branch. The send route checks only that
the header is present, with no length ceiling. The approve route,
POST /v1/projects/{project_id}/approvals/{approval_id}/approve, rejects a key over
128 characters with the same invalid_request. Verified in
packages/api/src/index.ts. Share one expression across both and size it for the
stricter route.
The expression most n8n guides reach for is {{ $execution.id }}. Use this instead,
including the leading = that puts the field into expression mode:
={{ $execution.id }}-{{ $runIndex }}-{{ $itemIndex }}
$runIndex is how many times n8n has executed the current node, zero-based, which is
what changes inside a Loop Over Items. $itemIndex is the index of the item being
processed. Keep that construction in the node parameter rather than a Code node:
$itemIndex is one of the few built-ins n8n does not expose to the Code node, and it
resolves to nothing there without complaining.
The bare execution ID breaks on the first multi-item input, in a way specific to how
this API implements the header. The key is bound to a canonical hash of the payload,
not to the endpoint, so the first item sends and every later item in the same execution
presents the same key with a different body and gets HTTP 409 idempotency_conflict.
Not a duplicate email, which is the point of the design, but not the email you wanted
either.
The expression matters less than the next point. n8n can re-run your send three different ways, and they are not equivalent:
| Key expression | Node Retry On Fail | Manual execution retry | Multi-item input |
|---|---|---|---|
{{ $execution.id }} | safe | duplicate | 409 on items 2 and after |
{{ $execution.id }}-{{ $runIndex }}-{{ $itemIndex }} | safe | duplicate | safe |
{{ $json.message_id }}-reply-v1 | safe | safe | safe |
a fresh UUID or {{ $now.toMillis() }} | duplicate | duplicate | safe, and useless |
Retry On Fail reruns the node inside the same execution, so the execution ID is unchanged and the API replays the stored 202 instead of sending again. That is the one case the execution ID covers, and it covers it cleanly.
A manual retry from the executions list is a different execution. n8n assigns a new ID
and records the original as retryOf on the new execution, which the Error Trigger
payload carries but which is not one of the $execution fields exposed to expressions.
The key changes, the payload hashes to something the API has never seen, and a second
email goes out. No care inside the expression editor recovers this, because the input
the expression would need is not there.
So derive the key from the thing you meant to send, not from the attempt. For an
inbound-triggered workflow, message_id is already a stable unique identifier for the
one reply you intend to make, and it survives every retry mechanism n8n has. Keep the
execution-ID form only where there is no natural business key, and know which of the
two you are relying on.
Idempotency keys: stop an agent sending duplicate emails
covers the replay window, what a 409 proves, and why an unknown outcome must not be
retried at all.
Triggering the workflow when mail arrives
Register a webhook against the project, subscribe to message.received, and point it
at your n8n Webhook node URL. Inbound mail then starts an execution in near real time
instead of on a poll interval, which is the half of the Gmail Trigger problem that
nothing in n8n can fix.
The callback body is {"id", "type", "api_version", "data"}, and each callback carries
webhook-id, webhook-timestamp and webhook-signature. The signature is v1,
followed by the base64 HMAC-SHA256 of id.timestamp.rawBody keyed on the signing
secret, which is shown once at registration.
Verifying that in n8n has a wrinkle worth stating, because the obvious configuration
cannot work. The Webhook node parses JSON into $json.body by default, and
JSON.stringify($json.body) is not the bytes that were signed: key order and
whitespace are already gone. Turn on the Raw Body option so the node hands you the
untouched payload, verify the HMAC against those exact bytes in a Code node, and only
then parse. Reject anything whose webhook-timestamp is more than five minutes off.
The events and webhooks reference has the verification
code.
Dispatch is abandoned at 25 seconds and retried up to eight attempts on a fixed backoff
of 10s, 60s, 5m, 30m, 2h, 6h and 12h, with a 4xx terminal except for 408, 409, 425 and 429. That ceiling makes the Webhook node’s Respond setting a correctness decision
rather than a preference. The default, Immediately, acknowledges with Workflow got started the moment the execution begins, which is what you want. Switch it to When
Last Node Finishes and the acknowledgement waits on the whole workflow, so any run
containing a slow model call can cross 25 seconds, get abandoned and be redelivered
while the first execution is still running. Two executions now reply to one inbound
email, from a change that looks purely cosmetic.
So deduplicate on the event ID before doing any work. The built-in way is a Remove
Duplicates node set to Remove Items Processed in Previous Executions, Keep Items
Where set to Value Is New, and Value to Dedupe On set to the event ID; that
operation persists across executions rather than only within one.
Handling email webhooks without processing events twice
explains why event ID and Idempotency-Key solve different problems.
Keep the event cursor as the recovery path. GET /v1/projects/{project_id}/events
returns ordered events with a next_cursor, and a Schedule Trigger reading it every
few minutes catches anything the webhook missed while n8n was down. Persist the cursor
after the batch is processed. A stale one returns HTTP 410 cursor_expired with
retryable: false, meaning restart without a cursor rather than loop.
Connecting the MCP Client Tool node instead
n8n’s MCP Client Tool node points an AI Agent at an external MCP server and exposes that server’s tools to the model. Two things here will surprise you if you configure it the way you would any other server.
Bearer and generic header auth will not work. The node offers Bearer, generic
header, multiple headers and OAuth2. The first three carry an API key, and an API key
is rejected at https://mcp.emailforagents.ai/mcp by design: a bearer token that is
not a connection grant comes back unauthenticated, and a principal that is not a
connection comes back permission_denied with “Only connection grants may invoke MCP
tools.” OAuth2 is the only option that reaches the tools. Discovery is at
/.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server,
the flow is authorization code with PKCE S256, and the requestable scopes are
email.read, email.draft and email.send.
Set Server Transport to HTTP Streamable, then confirm it took. GET on /mcp
returns 405 with Allow: POST; the endpoint speaks streamable HTTP only. The node
gained HTTP Streamable alongside the older SSE transport and kept SSE selectable for
compatibility, and there are reported builds where the dropdown showed HTTP Streamable
while the node opened an SSE connection, which is a GET. Against this server that is
an immediate 405 at connect time and the node reports the server unreachable, which
sends people hunting a firewall problem that does not exist. One request settles it: if
curl -X POST against your MCP URL returns JSON-RPC and the node does not, the node is
not sending the method you selected.
The tool surface is the permission boundary
Narrowing the grant removes tools from the model’s list; it does not merely refuse
them at call time. There are exactly six tools: list_inboxes, list_threads,
list_messages, get_message, create_draft and send_message. Each declares the
scopes it needs, and tools/list returns only the tools whose scopes the grant holds.
The three OAuth scopes map to fixed scope sets, so the tool count is deterministic:
| OAuth scope you request | Tools in tools/list | Absent |
|---|---|---|
email.read | 4 | create_draft, send_message |
email.draft | 5 | send_message |
email.send | 6 | none |
email.draft is the interesting row for an n8n agent. The model gets a five-tool list
with no send_message in it, so there is no tool for an instruction hidden in an
inbound email to name. A tools/call naming it anyway is re-checked against the grant
and fails with permission_denied, so the filtering is not a client-side courtesy.
That agent still composes a fully formed reply through create_draft, which cannot
transmit mail; the reply waits for a human.
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 an MCP session that can rotate your keys, delete a message, 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.
Verified in packages/contracts/src/connections.ts and
packages/domain/src/current-grants.ts.
One cost of the OAuth path, and it is the thing that will bite you: a connection
grant is created with a 30-day expiry and nothing extends it. Once expires_at
passes the grant resolves as denied. Nothing in the n8n UI warns you, and the symptom
is a workflow that ran cleanly for a month failing at the MCP node for no reason you
changed. An API key on the REST path has no equivalent clock. If the workflow must run
unattended past a month, that is the argument for row two of the table rather than row
three.
Do not confuse the grant boundary with the node’s Tools to Include parameter.
Setting it to Selected or All Except hides tools from the agent in that node,
which is a client-side convenience: the server still returns send_message for that
grant, and anything else holding the same credential, a second workflow, a Code node, a
curl command, still has it. Narrowing the grant is a boundary. Narrowing the node’s
tool list is a preference. Only one survives an attacker who reaches your n8n instance.
The presets are on the integrations page, and the attack patterns are
in prompt injection through email.
The equivalent on the REST path is scope selection at key creation. Give the n8n
credential drafts:submit without messages:send and every send it attempts returns
approval_required with reason code draft_only_principal. A credential also cannot
exceed its creator: requesting a scope your own role lacks fails at creation with
permission_denied, not later at use time.
Testing the workflow before it can reach anyone
Point the same workflow at a test project. The n8n nodes do not change; only the credential does.
Test inboxes use a non-routable .mail.invalid address and a simulated transport, and
cannot reach an internet recipient. That matters more for n8n than for hand-written
code, because you will run the workflow dozens of times getting the expressions right
and every one of those runs is a real send attempt against whatever credential is
selected.
POST /v1/projects/{project_id}/test/inbound injects a synthetic inbound message and
returns 201. That fires message.received, which fires your webhook, which starts the
execution, so the receive, parse, thread and reply loop runs end to end with nothing
leaving the system. You can exercise the Raw Body signature check, the Remove
Duplicates node and the Idempotency-Key expression against real traffic before a
recipient exists.
Inbound testing is not rare, whatever a vendor page tells you. Checked 22 September
2026: Mailtrap’s Agent Inbox provisions a hosted address with no MX records to
configure and delivers parsed inbound mail over a webhook, Mailosaur documents
originating a message itself, and PigeonProbe and Devmailr are inbound products. The
distinction that decides how much of your workflow you can rehearse is narrower: does
the message cross a transport, and whose mailbox does it land in. Those tools give you
a routable stand-in mailbox and a real transmission into it, which is the right shape
when the thing under test is your mail plumbing. /test/inbound writes into the
agent’s own non-routable inbox with no transport in the path, which is the right shape
when the thing under test is the workflow. The inbox_id, thread_id and message_id
reaching your Webhook node are the same objects the live workflow receives, and the
message_id you feed to reply_to_message_id is a real parent in a real thread.
Environment is carried in the key prefix and cannot be crossed: an efa_test_ key
resolves only to test projects and test inboxes, and no flag lets it reach live. A
credential swapped into the wrong n8n environment fails closed instead of emailing a
customer. Test mail is never charged, and the test-mode limits and the full pre-launch
checklist are in
testing an agent’s email without sending real mail.
What this does not give you
There is no Email for Agents node on npm today, checked 22 September 2026, so the HTTP Request node is the integration rather than a stopgap. A competitor is ahead of us here and it is worth saying plainly: AgentMail ships a node that n8n lists as verified, built by AgentMail and verified by n8n, with Inbox, Message, Thread and Webhook operations already wired as dropdowns. If you want operations from a menu instead of a URL and a hand-written JSON body, that is a real advantage, and their free tier of three inboxes and 3,000 emails a month is more generous than ours.
The free allowance is one live inbox and 100 email units a month per verified owner
identity, claimed by that identity’s first eligible workspace. An email unit is one
message received or one recipient sent to, so a send to three people uses three, and a
second workspace does not renew it. When it runs out sending stops while received mail
stays readable. Builder and Team are proposed plans at proposed prices with checkout
disabled, and custom domains require a paid plan, so every live address today sits on
agents.emailforagents.ai.
HTTP 202 and message.accepted mean the provider took the message, not that anyone
received it. Recipient outcomes arrive separately as message.delivery_updated, and a
send to five people has five delivery states that can disagree. An n8n workflow that
marks a row “sent” on the 202 is recording submission and calling it delivery. No
certification is held here, no uptime commitment is published, no independent security
review has been commissioned and no MCP client is certified; everything above is
shipped behaviour you can check against a test project in an afternoon.
The next thing to build is the approval branch, and one detail will save you a wasted
afternoon. Route approval_required to a Slack or email notification carrying the
approval_id, and let a human release it from the console rather than teaching the
agent to retry until it gets through. Then resist the obvious second step. Four event
types are declared in the schema but not emitted by the current build:
message.canceled, message.quarantined, approval.approved and
approval.invalidated. A webhook subscribed to approval.approved is accepted at
registration and simply never fires, so an n8n workflow that waits on it waits forever
with nothing in the execution log to explain why. Today, approval.requested and
approval.rejected are the two that arrive; detect the release by polling the message
or the event cursor for the send that follows it. Check the
event table before you wire a Wait node to anything else
in that list.
Keep reading
AI Agent Inbox vs Connecting Gmail: How to Choose
Giving an agent its own inbox versus wiring it into an existing Gmail or Microsoft 365 mailbox: identity, blast radius, OAuth scope and revocation.
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.
Agent Email API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.