Gmail
Seventeen operations against a connected Google account — send and reply in-thread, create and send drafts, search the inbox, read threads, and manage labels. Every string field takes {{payload.x}} templates, and the whole set can be handed to an AI Node as function-calling tools.
Overview
The Gmail node used to be a provider setting inside Email Action. It is now its own node, because the two do genuinely different things: Email Action renders a template and sends it through Resend, while Gmail talks to a real mailbox you own — with a thread history, labels, drafts and read state.
Credential and scopes
Connect a Google account under Settings › Credentials, or straight from the node's Google Account section — the picker requests the right scopes for you. Until an account is connected the node has nothing to configure and shows only that section.
Older credentials carrying just gmail.send still work for send. Everything else — drafts, labels, threads, and the AI toolkit — needs a gmail_full credential (read, modify, compose). If you see “insufficient authentication scopes”, re-authorize the credential to upgrade it in place.
Operations
Messages
send— send a new messagereplyToThread— reply preserving thread headersgetMessage— fetch by id, attachments listedlistMessages— search with Gmail query syntaxdeleteMessage— move to Trash; see TrashmarkRead/markUnreadaddLabel— by name or id; creates the label if it is missingremoveLabel— by name or id
Drafts
createDraft— save without sendingsendDraft— send one saved earlier
Labels
listLabelscreateLabel— / for nesting; reuses the label if the name exists
Threads
getThread— full conversationtrashThread/untrashThread— see Trash
Human in the loop
sendAndWaitForResponse— pause until someone answers
Seventeen in total. Sixteen of them are offered as AI tools: sendAndWaitForResponse is not, because a tool call cannot sit and wait for a human — it only makes sense inside a pipeline, where the run pauses and resumes.
Labels are picked, not typed. In addLabel, removeLabel and listMessages, the labels field lists the account's own — Gmail's (Inbox, Starred, Important…) and yours — so you never look up an id like Label_4815162342. To take them from the flow, choose Type IDs or a template under the list: one id, a list, or a comma-separated text such as Label_12, STARRED — spaces and empty entries are dropped. The AI toolkit's label tools take the same.
One node creates and applies. addLabel and removeLabel also take a label by its name — Clients/Active, in any case. With Create it if it doesn't exist on (the default), a name that is not in Gmail yet is created the first time the node runs and reused every run after, so there is no separate createLabel node to run first. In the list, type the new name in the search box and pick Create “…”. The output lists the labels it had to create in created. With the switch off, a missing name fails the node instead; removeLabel skips one that does not exist and lists it in notFound. A stale id like Label_999 is never turned into a label of that name. And createLabel no longer fails when the label already exists: it returns that one, with created: false.
Trash and restore
deleteMessage and trashThread move mail to Trash, and Gmail empties the Trash by itself after 30 days. Until then it can be restored: untrashThread takes a thread back out and, with Put it back in the Inbox on (the default), back to the Inbox too — like Gmail's «Move to Inbox». That matters because Gmail's own untrash does not: the Inbox is just the INBOX label, Gmail removes it when mail goes to Trash, and without it the thread ends up archived, in All Mail. Turn the switch off to leave it archived.
There is no permanent delete. Gmail only lets an app delete mail for good with full access to the mailbox, and HostWebhook doesn't ask for it — so the node, its AI tool (delete_email) and the MCP server only move mail to Trash. To delete a message for good, do it in Gmail (Delete forever, or empty the Trash); the Gmail trigger with Deleted picks that up.
"permanent": false and a note explaining why. An AI Node that added Delete message as a tool before the switch went away doesn't offer it either: the saved tool reaches the model — and shows on the AI Node's page — without the permanent option. If a model asks for a permanent delete anyway, the message still goes to Trash and the output says "permanent": false.operationConfig
Every operation reads its parameters from operationConfig, and the node's form is generated from each operation's schema — pick the operation and you get exactly its fields. String values support {{payload.x}} templates.
// Create a draft reply to the sender
{
"credentialId": "<cred-id>",
"operation": "createDraft",
"operationConfig": {
"to": ["{{payload.from}}"],
"subject": "Re: {{payload.subject}}",
"body": "<p>Thanks for reaching out — I'll reply by end of day.</p>",
"threadId": "{{payload.threadId}}"
}
}
// Label every incoming invoice
{
"credentialId": "<cred-id>",
"operation": "addLabel",
"operationConfig": {
"messageId": "{{payload.id}}",
"labelIds": ["Invoices"],
"createIfMissing": true
}
}
// List unread emails from a sender
{
"credentialId": "<cred-id>",
"operation": "listMessages",
"operationConfig": {
"query": "from:boss@corp.com is:unread",
"maxResults": 10
}
}replyToThread looks up the original thread's headers and sets In-Reply-To + References before sending, so Gmail groups the reply into the original conversation every time.The body can be HTML or plain text. HTML goes out as written. In plain text each line break becomes one line break in the email, so a blank line between paragraphs stays one blank line.
{{ }} in an HTML body comes out as text, so a value from the payload can't add tags or links to your email. To insert HTML you trust, add | raw — for example, the HTML an AI Node wrote: <div>{{$("AI").aiResponse | raw}}</div>. Plain-text bodies don't change.javascript:, data:, tel:…) becomes #. Images and other sources (src) take only http and https.What send returns. The incoming payload, untouched, plus _emailSent: the email's Gmail id and its threadId, its to and subject as they went out — templates already filled in —, success and sentAt. Read the message back with {{payload._emailSent.id}}, or reply in its conversation with {{payload._emailSent.threadId}}.
Config and Advanced
The form splits each operation's fields in two tabs. Config holds what the message needs — recipients, subject, body, or the id the operation acts on. Advanced holds what most emails don't: CC and BCC, the thread headers a draft can carry (threadId, In-Reply-To, References), and file attachments. Operations with nothing advanced show a single flat form. The Advanced tab shows a count of how many of its fields you have set, so a filled-in CC is never hidden behind a tab that looks empty.
Attachments
send, sendAndWaitForResponse, createDraft and replyToThread can attach files. The node does not upload anything itself: it attaches what the flow already has — the files a webhook received, or the files a Drive node downloaded — so an attachment is a payload path, not a file picker. Open the operation's Advanced tab, turn on Attach files from the payload, and point it at the payload — or at the part of it that holds the files.
{{payload}}— every file the payload carries, wherever it sits. The default, and the one that works for any node.{{payload._files}}— what a webhook trigger received (multipart uploads, or a raw file body).{{payload.files}}— what a Drivelistreturned with Auto-download on. Without Auto-download the list has names but no files, and the email goes out without attachments.{{payload.file}}— the file a Drive trigger saw, with Attach file content on. The Drive trigger puts its reference insidefile(asfile._file), not at the root.{{payload.attachments}}— the attachments of the email a Gmail trigger received, with Download attachments on: each one carries its_fileatattachments[i]._file. That is how you forward an email with its files. Without Download attachments the trigger only lists their names, and the email goes out without them.
The node walks whatever the path resolves to and attaches every file reference it finds — the _file objects nodes produce (a webhook's _files[]._file, a Drive list's files[]._file, a Drive trigger's file._file, the _file of a Drive download, an HTTP node or a File Transform, or the key you told an HTTP node to save under). Duplicates are attached once. Nothing found: the email is sent without attachments.
// Forward whatever the webhook received, as attachments
{
"credentialId": "<cred-id>",
"operation": "send",
"operationConfig": {
"to": ["accounting@corp.com"],
"subject": "Invoice from {{payload.vendor}}",
"body": "<p>Attached: the invoice we just received.</p>",
"attachments": "{{payload._files}}"
}
}
// Reply in-thread with the PDFs a Drive list downloaded
{
"credentialId": "<cred-id>",
"operation": "replyToThread",
"operationConfig": {
"threadId": "{{payload.threadId}}",
"body": "<p>Here are this month's reports.</p>",
"attachments": "{{payload.files}}"
}
}In AI toolkit mode the same parameter exists on send_email, draft_email, reply_email and send_email_and_wait: the model passes the _file reference(s) it sees in the payload, as a JSON string. It cannot invent a file — only references the registry knows are attached.
Reading what an email brings. getMessage — and the AI toolkit's get_email — return hasAttachments and an attachments list with each file's filename, mimeType, size and attachmentId — so a model can say which PDF arrived. That list holds the real files only, so its inline is always false: images embedded in the body, such as a signature logo, come apart in inlineAttachments, with the same fields, and don't count for hasAttachments. Only the names and sizes: to read the file itself, use the Gmail trigger with Download attachments on.
AI toolkit mode
Flip the AI toolkit card instead of picking a single operation. The Gmail node does not run an LLM in this mode — it gets exposed as 16 tools to any AI Node that picks it from the tool picker. That AI Node's model (your provider keys, your costs) reads the user message and decides which operation to call.
{
"name": "Smart inbox toolkit",
"credentialId": "<gmail-cred>",
"aiEnabled": true
}On the AI Node side, open the Tools tab → Use a workspace node as a tool and pick this node. The picker sees aiEnabled=true and offers the sixteen at once (send_email, draft_email, add_email_label, …) — check only the ones that flow needs. Each arrives with its own focused input schema, and the tool call lands here with operation already resolved.
Send and wait for a response
sendAndWaitForResponse sends the message with Approve / Disapprove links and pauses the run until someone answers. A link opens a page with a button, and pressing the button is what decides — opening the link alone doesn't. Company mail filters (Microsoft Safe Links, Mimecast, Proofpoint…) open every link in incoming mail to scan it, and they never press buttons, so a scan can't approve or reject on anyone's behalf. The answer arrives downstream under _waitResponse, with approved, who decided, when, and timedOut.
_waitResponse.approved is false — or as approved if you turn on Approve if nobody answers, the same choice an Approval Gate offers. Either way _waitResponse.timedOut is true (it is false when a person clicks), so a Conditional can tell “nobody answered” apart from a real decision. A link clicked after that shows a page saying it expired and was resolved automatically, and changes nothing.Without the Disapprove button — an approve-only confirmation — nobody could have said no, so the flow after it was built to go on only when someone approves. There the timeout doesn't invent a rejection: unless Approve if nobody answers is on, the request just expires and the run stops at this node, as it always did.
Approving on timeout is set on the node, by whoever builds the flow, and only works when the node runs this one operation. Then the recipient, the buttons and the timeout are the ones you set (templates included): nothing in the incoming event or in a call changes them. In AI toolkit mode — an AI Node, or send_email_and_wait over MCP — the call picks the recipient and the buttons, so a request nobody answers always ends as not approved there: a model that could approve itself would just send it to nobody.
Approving or rejecting resumes the same run, so an email the flow sends afterwards still counts as sent by it: a Gmail trigger that started the run won't fire on it, and won't ask for a second approval.
Turn on requireDisapproveComment and a disapproval asks for a reason first; the text lands in _waitResponse.comment, ready for a downstream node to act on. Approving asks for nothing but the button — a justification on the happy path is friction with no upside.
Canvas integration
The node carries the Gmail logo and shows its recipient for a send, or the operation name for any of the other sixteen. Connect anything upstream that produces a payload — a trigger, a filter, an AI node — and reference its fields with the data picker.
API Reference
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/gmail-actions | List all Gmail nodes for your organization |
| POST | /api/gmail-actions | Create a new Gmail node |
| GET | /api/gmail-actions/:id | Get a single Gmail node by ID |
| PATCH | /api/gmail-actions/:id | Update a Gmail node |
| DELETE | /api/gmail-actions/:id | Delete a Gmail node |
| GET | /api/gmail-actions/labels/:credentialId | List the account's labels — used by the label pickers |