Skip to content
n8nGmailNotionTutorial

n8n + Gmail + Notion: Auto-Log Every Client Email in 10 Minutes

Four nodes that turn your inbox into a searchable client record in Notion. The interesting parts are the Gmail search query and the dedupe step — without them you log everything, twice.

Ritik Makhija
12 min read

TL;DR

Use a Gmail Trigger with a search query narrow enough to match only client mail, a Code node to pull the sender, subject and timestamp out of the raw Gmail payload, a Remove Duplicates node keyed on thread ID so a long thread logs once instead of forty times, and a Notion node creating a database page. The two decisions that matter are the Gmail query and whether you log per message or per thread — everything else is field mapping.

Key takeaways

  • Use a Gmail label and filter `label:clients -from:me` rather than trying to detect clients in n8n. Gmail's own search is faster and free.
  • Gmail Trigger fires per message, not per thread — a twenty-message thread creates twenty Notion pages unless you dedupe on `threadId`.
  • The Notion integration must be explicitly connected to the database from the page's ••• menu, or the API returns object_not_found.
  • Notion property values are set by `Name|type` pairs in n8n, and the type must match the database column exactly.
  • Notion's API allows about three requests per second — fine for email, worth knowing before you backfill a year of it.

The client history you need is always in someone's inbox. Not the shared one — the personal one, belonging to whoever was on the thread, searchable only by them and only if they remember the right word.

A Notion database of client correspondence fixes that, and nobody maintains one by hand for longer than a fortnight. So don't: have n8n write it. This is a four-node workflow, and it genuinely does take about ten minutes once your credentials exist.

What you're building

  1. 1Gmail Trigger — polls for new mail matching a search query.
  2. 2Code — extracts sender, subject, date and thread link from Gmail's raw payload.
  3. 3Remove Duplicates — drops messages from threads already logged.
  4. 4Notion — creates a page in your client log database.

Step 1 — Design the Notion database first

Build the database before you touch n8n. Changing a property type later means editing the mapping in the Notion node, and the errors it produces when they mismatch are not helpful.

PropertyTypeHolds
SubjectTitleThe email subject — every Notion database needs one title property
ClientEmailSender address, so it's clickable
FromRich textSender display name
ReceivedDateISO timestamp of the message
PreviewRich textFirst couple of hundred characters
ThreadURLDeep link back into Gmail
Thread IDRich textThe dedupe key — hide this from your views

Add a Status select later if you want to track replies, but leave it out of the automation. Fields the workflow writes and fields humans edit should stay separate, or an automated re-run will overwrite someone's work.

Step 2 — Connect Notion

  1. 1Go to notion.so/my-integrations and create a new internal integration. Give it *Insert content* capability — it doesn't need *Read* or *Update* for this build.
  2. 2Copy the Internal Integration Secret.
  3. 3In n8n, create a Notion API credential and paste it.
  4. 4Open your database in Notion, click the ••• menu → Connections → Connect to, and pick your integration.

You'll need the database ID too. Open the database as a full page and take the 32-character string from the URL — the part after your workspace name and before the ?v=. n8n accepts it with or without dashes.

Step 3 — Filter in Gmail, not in n8n

Add a Gmail Trigger node and connect a Gmail OAuth2 credential (n8n Cloud offers one-click sign-in; self-hosted needs a Google Cloud project with the Gmail API enabled, same shape as any Google credential).

Then, under Filters, use the Search field. This is the highest-leverage box in the whole workflow. Anything you can type into Gmail's search bar works here, and every message it excludes is one n8n never has to process:

Gmail Trigger — Search query
label:clients -from:me -in:chats -category:promotions -category:social

The best version of this is a Gmail label you already maintain, applied by a Gmail filter on your client domains. Gmail is doing the classification for free and instantly; asking n8n — or worse, a language model — to decide whether an email is client correspondence is paying for a job that's already done.

Other clauses worth knowing: from:(@acme.io OR @northline.co) for a fixed client list, has:attachment if you only care about deliverables, and newer_than:1d as a safety net while testing.

Set Poll Times to *Every Minute* and, importantly, turn Simplify off. Simplified output is easier to read, but the raw payload has the headers and internalDate field the next step needs.

Step 4 — Extract the fields

Gmail's raw format puts everything useful in a payload.headers array of name/value pairs, which is awkward in expressions and trivial in a Code node. Add one, set Mode to *Run Once for All Items*:

Code node — Extract email fields
return $input.all().map((item) => {
  const m = item.json;
  const headers = Object.fromEntries(
    (m.payload?.headers ?? []).map((h) => [h.name.toLowerCase(), h.value])
  );
  const from = headers.from ?? '';
  const email = from.includes('<') ? from.split('<')[1].split('>')[0] : from;

  return {
    json: {
      subject: headers.subject || '(no subject)',
      fromName: from.split('<')[0].split('"').join('').trim(),
      fromEmail: email.trim().toLowerCase(),
      receivedAt: new Date(Number(m.internalDate)).toISOString(),
      preview: (m.snippet ?? '').slice(0, 300),
      threadId: m.threadId,
      link: 'https://mail.google.com/mail/u/0/#inbox/' + m.threadId,
    },
  };
});

Three details in there are worth calling out. internalDate is milliseconds (unlike Stripe's seconds — the inconsistency between APIs is a reliable source of 1970 timestamps). The From header arrives as "Jane Okafor" <jane@acme.io>, so it needs splitting into name and address. And the snippet is truncated deliberately — Notion rich text caps at 2,000 characters per block, and a 300-character preview is what makes the database scannable anyway.

Step 5 — Log threads, not messages

Here's the decision that determines whether this database is useful in three months. The Gmail Trigger fires per message. A back-and-forth thread with a client produces forty messages, and the naive workflow produces forty Notion pages, all with the same subject prefixed by increasingly many Re:.

For a client log you almost always want one entry per conversation. Add a Remove Duplicates node, set Operation to *Remove Items Seen in Previous Executions*, and set the value to keep track of to {{ $json.threadId }}.

That node keeps a persistent store across executions, so a thread logged last Tuesday is still recognised today. First message of a thread creates the page; every reply after it is dropped.

Step 6 — Write to Notion

Add a Notion node: Resource *Database Page*, Operation *Create*. Pick your database, then map the properties. n8n identifies each property by name and type together, so the type in the dropdown must match the column type in Notion exactly — a Date value sent to a Rich text column fails with a validation error that names the property but not the mismatch.

Notion propertyValue
Subject (title){{ $json.subject }}
Client (email){{ $json.fromEmail }}
From (rich text){{ $json.fromName }}
Received (date){{ $json.receivedAt }}
Preview (rich text){{ $json.preview }}
Thread (url){{ $json.link }}
Thread ID (rich text){{ $json.threadId }}

Dates must be ISO 8601, which is what the Code node already produced. Notion will reject anything else rather than guessing.

The full workflow

Import with Workflows → Import from Clipboard, then re-select your credentials and database.

gmail-to-notion.json
{
  "name": "Client emails to Notion",
  "nodes": [
    {
      "parameters": {
        "pollTimes": { "item": [{ "mode": "everyMinute" }] },
        "simple": false,
        "filters": { "q": "label:clients -from:me -in:chats" },
        "options": {}
      },
      "id": "a9b8c7d6-gmail-trigger",
      "name": "New client email",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [-320, 0],
      "credentials": { "gmailOAuth2": { "id": "REPLACE_ME", "name": "Gmail account" } }
    },
    {
      "parameters": {
        "jsCode": "return $input.all().map((item) => {\n  const m = item.json;\n  const headers = Object.fromEntries((m.payload?.headers ?? []).map((h) => [h.name.toLowerCase(), h.value]));\n  const from = headers.from ?? '';\n  const email = from.includes('<') ? from.split('<')[1].split('>')[0] : from;\n  return {\n    json: {\n      subject: headers.subject || '(no subject)',\n      fromName: from.split('<')[0].split('\"').join('').trim(),\n      fromEmail: email.trim().toLowerCase(),\n      receivedAt: new Date(Number(m.internalDate)).toISOString(),\n      preview: (m.snippet ?? '').slice(0, 300),\n      threadId: m.threadId,\n      link: 'https://mail.google.com/mail/u/0/#inbox/' + m.threadId,\n    },\n  };\n});"
      },
      "id": "b8c7d6e5-extract",
      "name": "Extract email fields",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-100, 0]
    },
    {
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "logic": "removeItemsWithAlreadySeenKeyValues",
        "dedupeValue": "={{ $json.threadId }}",
        "options": {}
      },
      "id": "c7d6e5f4-dedupe",
      "name": "One entry per thread",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [120, 0]
    },
    {
      "parameters": {
        "resource": "databasePage",
        "databaseId": { "__rl": true, "value": "YOUR_DATABASE_ID", "mode": "id" },
        "title": "={{ $json.subject }}",
        "propertiesUi": {
          "propertyValues": [
            { "key": "Client|email", "emailValue": "={{ $json.fromEmail }}" },
            { "key": "From|rich_text", "textContent": "={{ $json.fromName }}" },
            { "key": "Received|date", "includeTime": true, "date": "={{ $json.receivedAt }}" },
            { "key": "Preview|rich_text", "textContent": "={{ $json.preview }}" },
            { "key": "Thread|url", "urlValue": "={{ $json.link }}" },
            { "key": "Thread ID|rich_text", "textContent": "={{ $json.threadId }}" }
          ]
        },
        "options": {}
      },
      "id": "d6e5f4a3-notion",
      "name": "Create Notion page",
      "type": "n8n-nodes-base.notion",
      "typeVersion": 2.2,
      "position": [340, 0],
      "credentials": { "notionApi": { "id": "REPLACE_ME", "name": "Notion account" } }
    }
  ],
  "connections": {
    "New client email": { "main": [[{ "node": "Extract email fields", "type": "main", "index": 0 }]] },
    "Extract email fields": {
      "main": [[{ "node": "One entry per thread", "type": "main", "index": 0 }]]
    },
    "One entry per thread": { "main": [[{ "node": "Create Notion page", "type": "main", "index": 0 }]] }
  },
  "settings": { "executionOrder": "v1" },
  "pinData": {}
}

What breaks, and why

SymptomCauseFix
object_not_found from NotionIntegration never connected to the databaseDatabase ••• → Connections → your integration
A page per replyGmail Trigger fires per messageDedupe on threadId in Remove Duplicates
Property validation errorsValue type doesn't match the Notion columnMatch the type in the Name|type key exactly
All dates in 1970internalDate handled as secondsIt's milliseconds — don't multiply
headers is undefinedSimplify left on in the triggerTurn Simplify off to get the raw payload
Nothing arrivesSearch query matches nothing, or workflow isn't activePaste the query into Gmail's search bar and check
Notion 429 during a backfillNotion allows roughly 3 requests/secondAdd a Loop Over Items node with a batch interval

Worth doing next

  • Relate it to your clients database. Swap the Client email property for a Relation, look the client up by domain, and each client page gains a live correspondence list.
  • Log attachments. Gmail's payload includes attachment metadata — push files to Drive and put the link on the Notion page.
  • Alert on silence. A scheduled workflow that queries Notion for clients with no email in 21 days, posting the list to Slack, is more valuable than the log itself.
  • Add a summary, carefully. This is the one place a model earns its place — a one-line summary of a long thread is genuinely hard to produce with string operations. Do it on threads over some length, not on every message, and use a cheap model rather than a frontier one.

Worth saying plainly: this workflow copies client correspondence into another system. Check that's allowed before you turn it on — if you handle regulated or client-confidential material, the person who needs to approve it is not you. Logging metadata (sender, subject, timestamp, link) without the body is often the version that passes review, and it's a one-line change.

If you want this wired into an existing client system rather than a fresh database — or you're logging enough volume that rate limits and retries start mattering — that's the sort of thing I build. Describe your setup and I'll tell you what it takes.

Frequently asked questions

Use an n8n workflow with four nodes: a Gmail Trigger filtered by a search query such as `label:clients -from:me`, a Code node that extracts sender, subject and timestamp from the raw Gmail payload, a Remove Duplicates node keyed on the thread ID so each conversation logs once, and a Notion node creating a database page. Connect a Gmail OAuth2 credential and a Notion internal integration, and connect that integration to the database itself.

About the author

Ritik Makhija

Ritik Makhija

Founder & Product Lead · AI Kaptan

I build AI agents and automation that run in production — and I've open-sourced 5,000+ workflows so you can read the work rather than take my word for it. I run outreach infrastructure sending 6,000 emails a day on this stack, and I've mentored 700+ builders 1:1.

Got a process you're trying to automate?

Tell me what it is and I'll say straight whether it needs an agent, a plain workflow, or nothing at all. Free 30 minutes, no pitch.

Send a message