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

ApproachWhose address it isInbound pathWhat a prompt injection can reach
Gmail node as an AI Agent toolthe human who authorisedGmail Trigger poll, unread onlyevery operation the node exposes, including Send and Delete
HTTP Request node against this REST APIthe projectsigned message.received webhookexactly the scopes on the key, and nothing outside them
MCP Client Tool node against a grantthe projectsigned message.received webhookonly 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:

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:

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 expressionNode Retry On FailManual execution retryMulti-item input
{{ $execution.id }}safeduplicate409 on items 2 and after
{{ $execution.id }}-{{ $runIndex }}-{{ $itemIndex }}safeduplicatesafe
{{ $json.message_id }}-reply-v1safesafesafe
a fresh UUID or {{ $now.toMillis() }}duplicateduplicatesafe, 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 requestTools in tools/listAbsent
email.read4create_draft, send_message
email.draft5send_message
email.send6none

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

All guides · Documentation · Integrations