Integrations · September 29, 2026
n8n AI Agent Gmail Node: Invalid Email Address
Why the n8n AI agent Gmail node throws an invalid email address error: it only checks for an @. The model outputs that trip it, and the ones that slip past.
Your AI Agent node calls its Gmail tool, the execution goes red, and the log says
Invalid email address (item 0) with a description like
The email address '...' in the 'To' field isn't valid. You open the model’s output and
the address looks fine. That is the usual shape of the n8n AI agent Gmail node invalid
email address error, and the address the model wrote is rarely the problem. The node
checks one thing, and the value it checked is often not the one you looked at.
This article settles what the Gmail node actually validates, read from n8n’s source on 29 September 2026, which model outputs trip that check, which ones pass it and break later, and two fixes: a structured tool that normalises the recipient before Gmail sees it, and wiring that takes the Gmail node out of the path. Whether the recipient should be trusted at all is a separate question, covered in when your agent invents a recipient address.
What the Gmail node actually checks
The whole validator is one test: after splitting the field on commas, every entry
must contain an @ character. Nothing else about the address is checked by n8n. This
is prepareEmailsInput in
packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts (lines 400 to 426 on
master, commit 62cadddf, read 29 September 2026):
input = String(input ?? '');
input.split(',').forEach((entry) => {
const email = entry.trim();
if (email.indexOf('@') === -1) {
const description = `The email address '${email}' in the '${fieldName}' field isn't valid`;
throw new NodeOperationError(this.getNode(), 'Invalid email address', {
description,
itemIndex,
});
}
if (email.includes('<') && email.includes('>')) {
emails += `${email},`;
} else {
emails += `<${email}>, `;
}
});
Four consequences follow directly from that code.
fieldNameisTo,CC,BCCorReplyTo. The Send operation passes all four through this function (v2/GmailV2.node.ts, from line 272), and Create Draft does the same for its optional recipients. The description tells you which field failed.(item 0)is the item index. Inside an AI Agent tool call there is usually one item, so it is usually 0. It does not point at recipient number zero.- Whitespace is trimmed per entry, so a trailing space or newline never causes this error.
- Anything with an
@in it passes, however malformed. An entry containing both<and>is passed through untouched; anything else is wrapped in angle brackets.
The result is handed to nodemailer’s MailComposer to build the MIME message, and then to
Gmail’s API. n8n’s
Gmail common issues page
covers the attribution footer, Forbidden, 401 and Bad request, and does not mention
this error at all, which is why people end up in the forum.
Why the n8n AI agent Gmail node says invalid email address
The error fires when an entry has no @. With an agent in the loop, that almost always
means the node received something other than the model’s address. These are the ways
an entry ends up with no @, from the source and from the forum threads:
| What reached the To field | How it got there | Error description shows |
|---|---|---|
{{ $fromAI('recipient') }} as literal text | The field is set to Fixed, not Expression | the $fromAI call itself |
| an empty string | an expression resolved to nothing, or a missing column | '' |
a header row such as email or correo | Google Sheets emitted the header as an item | the column name |
the customer, {{ recipient_email }} | the model wrote a placeholder instead of an address | the placeholder |
jane@example.com, | a trailing comma leaves an empty entry after the split | '' |
Doe, Jane <jane@example.com> | the comma in a display name splits it in two | Doe |
[object Object] | a $fromAI of type json returned an object | [object Object] |
The first row is the one the forum threads turn on. In
Help - cannot send email (gmail)
(February 2025), the error description quoted the $fromAI('recipient') expression
itself, and the poster had checked the agent’s output and found a correct address. The
resolution, in their own follow-up, was that the field was set to Fixed rather than
Expression. In Fixed mode the text is never evaluated, so the model’s answer is never
used and the node validates the literal string. In
Problem in node gmail invalid email address item 0
(January 2025) there was no agent at all. The reply that answered it pointed out two
input items, the first apparently a column header carrying the text correo, and
suggested emitting only the second, which held a valid address. Both are “the value
checked is not the value you looked at”.
The object row needs one version note. String(input ?? '') was added to this function
by PR #33800, merged 8 July 2026. Its test
asserts that an array of two addresses becomes <alice@example.com>, <bob@example.com>, ,
so an array now works. Before that change the function called .split on whatever it
was given, so a non-string value would throw a ... is not a function TypeError
instead of producing this message. That older failure
is what issue #22614 reports, on n8n
1.122.4: this.getNodeParameter(...).trim is not a function (item 0), caused by an
object in the Message field, not the recipient. The n8n bot notes the fix shipped in
n8n@2.31.0. If you see that TypeError, you are on an older build, and the fix is to
stringify or upgrade, not to fix an address.
The model outputs that pass the check and break later
The shapes people blame for this error mostly do not cause it. A display name, a
markdown link, a quoted string and trailing punctuation all contain an @, so n8n
accepts them. What they do instead is reach Gmail as a malformed header. We ran each
through a verbatim copy of the prepareEmailsInput logic and nodemailer 8.0.10, the
version pinned in n8n’s workspace catalog, and recorded the recipient address
MailComposer parsed out of the result. This was our local test of the message n8n
builds, not a send through Gmail:
| Model output | n8n check | Recipient nodemailer parsed |
|---|---|---|
jane@example.com | pass | jane@example.com |
Jane Doe <jane@example.com> | pass | jane@example.com (correct) |
jane@example.com, bob@example.com | pass | two recipients (correct, if you wanted two) |
[jane@example.com](mailto:jane@example.com) | pass | [jane@example.com](mailto:jane@example.com) |
mailto:jane@example.com | pass | mailto:jane@example.com |
"jane@example.com" | pass | "jane@example.com" |
jane@example.com. | pass | jane@example.com. |
jane@example.com; bob@example.com | pass | "jane@example.com; bob"@example.com |
jane@example.com and bob@example.com | pass | "jane@example.com and bob"@example.com |
Jane Doe (jane@example.com) | pass | "Jane Doe (jane"@example.com) |
Two things stand out. The display-name form, the one most often blamed, is handled
correctly: the angle brackets mark it as already formatted and nodemailer extracts the
address. And the semicolon and “and” forms are the dangerous ones: the node wraps the
whole string in brackets, and the parser turns it into a single, quoted, nonsense
recipient. What Gmail’s API does with each malformed row, whether it rejects the request
or accepts it and bounces, is Google’s behaviour and we did not test it against a live
account. Neither outcome is the email you meant to send, and neither produces the
Invalid email address message, so a guide that tells you the error means “remove the
display name” sends you after the wrong thing.
What $fromAI tells the model about the field
The schema the model receives for a $fromAI field says “a string” and nothing
else. $fromAI() takes key, description, type (string, number, boolean
or json, default string) and defaultValue, per n8n’s
Use AI for parameters
page. In source, generateZodSchema in packages/workflow/src/from-ai-parse-utils.ts
maps string to a bare z.string(), attaches the description if you gave one, and
applies defaultValue if you set one. There is no format, no pattern and no way to say
“exactly one address”.
The star button that lets the model fill a parameter writes the expression for you
(buildValueFromOverride in the editor’s fromAIOverride.utils.ts). For the Gmail To
field it produces a key derived from the display name and an empty description:
={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}
So the model is told there is a string argument called To. The node’s own help text,
“Multiple addresses can be separated by a comma”, is shown to you in the editor, not to
the model. Everything the tool schema tells the model about the shape it should produce
comes from the key name and whatever you put in the description.
Two changes help, and one setting hurts:
- Write the description. Something like
A single bare email address such as name@example.com, copied exactly from the conversation. No name, no angle brackets, no mailto, no quotes, no trailing punctuation.This reduces the malformed rows above. It does not eliminate them, because it is an instruction rather than a constraint. - Rename the key.
recipient_emailcarries more meaning thanTo. - Do not set a
defaultValuethat looks like an address. If the model omits the argument, the default is used, and a default ofsomeone@example.compasses every check in this article.
For replies there is a better option than any description. The Gmail node’s Reply
operation takes a Message ID and builds the To header from the original message’s
headers using nodemailer’s addressparser (utils/replyToEmail.ts, from line 98): the
sender from From, or from Reply-To when one is present on node version 2.2 and
later, plus the original To recipients other than you unless Reply to Sender Only
is on. The model supplies an ID, never an address, so none of the rows above can occur.
Only CC and BCC still pass through prepareEmailsInput.
Fix: give the model a structured send tool instead
The robust fix is to stop letting the model’s string reach the Gmail node directly. An
AI Agent cannot have a Code node between itself and a tool, so move the send into a
sub-workflow and give the agent a Call n8n Sub-Workflow Tool with three inputs:
to_email, subject and body. The sub-workflow starts with the When Executed by
Another Workflow trigger, normalises the recipient in a Code node, and only then
sends. The model still fills those inputs as strings; the difference is that your code
now runs before anything reaches Gmail. The sub-workflow’s last node output is what the
model receives back, so a rejection becomes a precise message the model can act on
rather than a red execution.
The normaliser parses well-formed input and refuses to guess:
// Code node, mode "Run Once for Each Item"
const raw = String($json.to_email ?? '').trim();
const found = raw.match(/[A-Za-z0-9._%+'-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g) ?? [];
const byKey = new Map(found.map((a) => [a.toLowerCase(), a]));
const reserved = /@((?:[^@]+\.)?example\.(com|net|org)|[^@]+\.(example|invalid|test|localhost))$/i;
if (byKey.size !== 1) {
return { json: { ok: false, error: byKey.size === 0
? `No email address found in "${raw}". Supply one bare address.`
: `Found ${byKey.size} addresses. This tool sends to exactly one.` } };
}
const email = [...byKey.values()][0];
if (reserved.test(email)) {
return { json: { ok: false, error: `${email} is a reserved example domain, not a recipient.` } };
}
return { json: { ok: true, email, subject: $json.subject, body: $json.body } };
We ran it against every shape in both tables, with a real-looking domain in place of
example.com. It returns the bare address for the display name, Doe, Jane <...>, the
markdown link, mailto:, the quoted form, the trailing period, the trailing comma and
surrounding whitespace. It rejects the literal $fromAI text, placeholders, empty
input, [object Object] and the two-address list, each with a reason the model can
read. Route ok: false straight back as the tool output; route ok: true to a Gmail
node that is no longer a tool, with To set to {{ $json.email }} in Expression mode.
The reserved-domain line is there because the worst output is not the malformed one. A
model that writes customer@example.com in place of an address it did not have passes
n8n, passes this regex, and passes every syntax check anywhere. For recipients that must
come from a record, and how to hold an unknown one for a person, go back to the
recipient resolution order linked at the top. For the syntax and DNS checks worth
running before a send, see
validating an address before your agent sends.
Wiring the send around the Gmail node
The same sub-workflow can end in an HTTP Request node instead of Gmail, which also removes the borrowed-mailbox problem: the agent sends from its own address rather than as whoever authorised the Google credential, and a Google restriction on that account stops being your agent’s outage. The different ways Google cuts off a sending account are in why Gmail blocked my bot from sending. Agent inbox versus connecting Gmail covers that trade-off; the point here is what the request shape does to this specific failure.
The send endpoint is
POST https://api.emailforagents.ai/v1/projects/{project_id}/inboxes/{inbox_id}/messages,
with an Idempotency-Key header, which is mandatory. Recipients are not a
comma-separated string. to is an array of objects with a required email and an
optional name, up to 200 characters (recipientSchema,
packages/contracts/src/schemas.ts:14-17). The display name the model wanted to write
has a field of its own, so add name when you have one from a record. The minimal JSON
body after the normaliser, with the body field in Expression mode:
{
"to": [{ "email": {{ JSON.stringify($json.email) }} }],
"subject": {{ JSON.stringify($json.subject) }},
"text": {{ JSON.stringify($json.body) }}
}
There is no split step to go wrong, so the semicolon and “and” rows cannot turn into one
quoted recipient. Each email is checked with Zod’s email check, capped at 320
characters, and refused if it contains CR or LF (schemas.ts:8-12). The schema does
not trim. Every malformed shape from both tables, including a trailing space, comes back
as HTTP 400 rather than a send, and so does Jane Doe <jane@example.com>, which n8n
handles but which belongs in name here:
{
"error": {
"code": "invalid_request",
"message": "The request does not match the required format. Check the fields and JSON syntax.",
"request_id": "req_...",
"retryable": false,
"details": { "fields": ["to.0.email"] }
}
}
That envelope is built in packages/api/src/index.ts:80-85, and details.fields names
the exact recipient, so you can hand the model “recipient 1 in to is not a valid
address” instead of a generic failure. A bare string for to fails at ["to"].
retryable is false: resending the same body produces the same 400, so fix the input
rather than looping. The rest of the send contract, the 202 outcomes and which errors
are worth retrying, is in
the agent email API loop.
One detail of the strictness is easy to get wrong. The request object is .strict()
(schemas.ts:31), so an unknown top-level field such as from is a 400 with
details.fields of ["request"]. The recipient object is not strict: a recipient sent
as {"email": "jane@example.com", "display_name": "Jane"} is accepted, and
display_name is silently dropped. Use name.
If the agent reaches the inbox over MCP instead, through n8n’s MCP Client Tool node and
an OAuth grant (the connection caveats are in
the n8n wiring guide), the shape is declared to the model
before it calls. The send_message and create_draft input schemas describe to, cc
and bcc each as an array of at most 25 objects with email (format: email,
required) and name, and additionalProperties: false
(packages/mcp/src/index.ts:463-472). That is more than a $fromAI string ever tells
the model. The server then parses the arguments
against the same sendMessageRequestSchema as REST (index.ts:321-325), with one
difference worth knowing: a failure comes back as a tool result with isError: true and
the text Invalid tool arguments or unavailable content., with no field path
(index.ts:364-373). Over MCP the model learns that the call was wrong, not which
recipient.
What this does not fix
A strict schema rejects shapes. It does not know whether jane@northwind.com is the
Jane you meant. recipient@example.com is a valid address to the send endpoint too; the
reserved-domain check in the normaliser is yours to run. A 202 from the send endpoint
means the message was queued or held for approval, and a later message.accepted means
the provider took it, not that anyone received it.
There is no Email for Agents node for n8n (npm search, 29 September 2026), so the HTTP
Request node is the integration, configured by hand as the wiring guide above
describes, including an Idempotency-Key that survives n8n’s retries. The free
allowance is one live inbox and 100 email units a month per verified owner identity,
where a send to three recipients uses three units. The full surface, including
idempotency and error codes, is on the developer reference, and
the other integration paths are on the integrations page.
If your execution log shows Invalid email address (item 0) right now, check three
things in order: whether the To field is in Expression mode, what the error description
quotes, and whether the node received one item or two. In both forum threads above,
one of those was the whole answer.
Keep reading
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.
AI Agent Hallucinated an Email Address? Fix the Source
An AI agent's hallucinated email address either bounces or reaches a stranger. Take recipients from records, not model output, and gate the rest.
Validating an Address Before Your Agent Sends
How to validate an email address before your agent sends: the syntax and DNS checks that are safe to run, and why probing the mailbox is not.