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.

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 fieldHow it got thereError description shows
{{ $fromAI('recipient') }} as literal textThe field is set to Fixed, not Expressionthe $fromAI call itself
an empty stringan expression resolved to nothing, or a missing column''
a header row such as email or correoGoogle Sheets emitted the header as an itemthe column name
the customer, {{ recipient_email }}the model wrote a placeholder instead of an addressthe 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 twoDoe
[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 outputn8n checkRecipient nodemailer parsed
jane@example.compassjane@example.com
Jane Doe <jane@example.com>passjane@example.com (correct)
jane@example.com, bob@example.compasstwo recipients (correct, if you wanted two)
[jane@example.com](mailto:jane@example.com)pass[jane@example.com](mailto:jane@example.com)
mailto:jane@example.compassmailto:jane@example.com
"jane@example.com"pass"jane@example.com"
jane@example.com.passjane@example.com.
jane@example.com; bob@example.compass"jane@example.com; bob"@example.com
jane@example.com and bob@example.compass"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:

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

All guides · Documentation · Integrations