AutomationFlowsGeneral › Email to Notion Automation

Email to Notion Automation

Original n8n title: Email to Notion

Email to Notion. Uses stickyNote, emailReadImap, httpRequest, noOp. Manual trigger; 13 nodes.

Manual trigger★★★★☆ complexity13 nodesEmail Read ImapHTTP Request
General Trigger: Manual Nodes: 13 Complexity: ★★★★☆ Added:

This workflow follows the Emailreadimap → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Email to Notion",
  "nodes": [
    {
      "parameters": {
        "content": "## Email to Notion\n\nIMAP polls a mailbox, parses each new message, optionally filters by sender domain or subject keyword, and writes a structured row into a Notion database (subject, from, snippet, received-at, attachment-count, raw-message-id link).\n\nSet of patterns wired:\n- Idempotency on Message-ID hash (the same email, fetched twice, lands in Notion once)\n- Rate limit on the Notion API call (defense if a flood arrives)\n- Per-domain or per-subject filter (opt-in)\n- Error branch with Slack alert\n\nNo HMAC, this trigger is IMAP poll, not a public webhook.\n\nSee `README.md` for setup, env vars, and extension recipes.",
        "height": 320,
        "width": 400,
        "color": 6
      },
      "id": "note-intro",
      "name": "Sticky Note - Intro",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -200,
        -100
      ]
    },
    {
      "parameters": {
        "content": "### >> SET ME <<\n\n1. Create an n8n IMAP credential pointing at the mailbox you want to mirror.\n2. Set `NOTION_API_TOKEN` (Internal Integration Token from notion.so/my-integrations).\n3. Set `NOTION_DATABASE_ID` (32-char UUID of the target database).\n4. Share the database with your Notion integration (top-right Share menu in the database page).\n5. Optional: `EMAIL_FROM_WHITELIST=domain1.com,domain2.com` and / or `EMAIL_SUBJECT_INCLUDE=invoice,receipt,order`.\n6. Optional: `SLACK_OPS_WEBHOOK` for error alerts.\n7. Self-hosted n8n: set `NODE_FUNCTION_ALLOW_BUILTIN=crypto`.",
        "height": 320,
        "width": 380,
        "color": 5
      },
      "id": "note-setup",
      "name": "Sticky Note - Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -200,
        240
      ]
    },
    {
      "parameters": {
        "content": "## Production Patterns\n\nThree opt-in nodes wired, default-off so the import boots clean.\n\n- **Rate limit:** `RATE_LIMIT_ENABLED=1` (60 ops / 5 min, defense for the Notion API call)\n- **Idempotency:** `IDEMPOTENCY_ENABLED=1` (5-min window on Message-ID hash)\n- **Filter:** `EMAIL_FROM_WHITELIST` and / or `EMAIL_SUBJECT_INCLUDE` (opt-in, comma-separated)\n- **Error branch:** always on. Notion failure -> Slack alert + structured log.\n\nNo HMAC: IMAP poll trigger is server-to-server, not public.\n\nFor clustered n8n, swap the in-memory dedup for Redis SET NX EX 300. Snippet in the node's comments.",
        "height": 320,
        "width": 380,
        "color": 7
      },
      "id": "note-production-patterns",
      "name": "Sticky Note - Production Patterns",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        840,
        -260
      ]
    },
    {
      "parameters": {
        "mailbox": "INBOX",
        "postProcessAction": "read",
        "options": {
          "allowUnauthorizedCerts": false,
          "forceReconnect": 60
        }
      },
      "id": "email-1-trigger",
      "name": "IMAP Email",
      "type": "n8n-nodes-base.emailReadImap",
      "typeVersion": 2,
      "position": [
        240,
        60
      ],
      "credentials": {
        "imap": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Skip non-text messages or those without a Message-ID (rare, but possible).\n// Decide whether to-process or to-skip based on whitelist envs.\n\nconst item = $input.first();\nconst json = item.json || {};\nconst headers = json.headers || {};\nconst messageId = headers['message-id'] || headers['Message-ID'] || json.messageId;\nconst from = (json.from && (json.from.text || (Array.isArray(json.from.value) && json.from.value[0] && json.from.value[0].address))) || '';\nconst subject = String(json.subject || '');\n\nif (!messageId) {\n  return [{ json: { skipped: true, reason: 'no-message-id', subject, from } }];\n}\n\nconst whitelistRaw = $env.EMAIL_FROM_WHITELIST || '';\nconst whitelist = whitelistRaw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);\nif (whitelist.length > 0) {\n  const fromLower = String(from).toLowerCase();\n  const matched = whitelist.some(domain => fromLower.includes('@' + domain) || fromLower.endsWith('@' + domain) || fromLower.includes('<' + domain) || fromLower === domain);\n  if (!matched) {\n    return [{ json: { skipped: true, reason: 'from-domain-not-whitelisted', from, subject } }];\n  }\n}\n\nconst includeRaw = $env.EMAIL_SUBJECT_INCLUDE || '';\nconst include = includeRaw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);\nif (include.length > 0) {\n  const subjLower = subject.toLowerCase();\n  const matched = include.some(kw => subjLower.includes(kw));\n  if (!matched) {\n    return [{ json: { skipped: true, reason: 'subject-keyword-not-matched', subject, from } }];\n  }\n}\n\nreturn [{\n  json: {\n    skipped: false,\n    messageId,\n    from,\n    subject,\n    raw: json,\n  },\n}];"
      },
      "id": "email-2-filter",
      "name": "Filter Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Per-key sliding-window rate limit on the Notion API call, opt-in.\n// Default 60 ops / 5 min, scoped to the workflow as a whole.\n\nif ($env.RATE_LIMIT_ENABLED !== '1') {\n  return $input.all();\n}\n\nconst LIMIT = 60;\nconst WINDOW_MS = 5 * 60 * 1000;\nconst MAX_KEYS = 5000;\n\nconst data = $getWorkflowStaticData('global');\ndata.rateBuckets = data.rateBuckets || {};\nconst buckets = data.rateBuckets;\nconst now = Date.now();\n\nfor (const k of Object.keys(buckets)) {\n  buckets[k] = (buckets[k] || []).filter(t => now - t < WINDOW_MS);\n  if (buckets[k].length === 0) delete buckets[k];\n}\nif (Object.keys(buckets).length > MAX_KEYS) {\n  const oldest = Object.entries(buckets).sort((a, b) => (a[1][0] || 0) - (b[1][0] || 0)).slice(0, 100);\n  for (const [k] of oldest) delete buckets[k];\n}\n\nconst key = 'notion-write';\nconst hits = buckets[key] || [];\nif (hits.length >= LIMIT) {\n  throw new Error('RATE_LIMIT_EXCEEDED: ' + LIMIT + ' Notion writes per ' + Math.round(WINDOW_MS / 60000) + ' minutes reached');\n}\nbuckets[key] = [...hits, now];\n\nreturn $input.all();"
      },
      "id": "email-pp-1-ratelimit",
      "name": "Rate Limit (opt-in)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        640,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// 5-minute idempotency window on Message-ID hash, opt-in.\n// Skips items where Filter already marked skipped=true.\n\nconst crypto = require('crypto');\n\nif ($env.IDEMPOTENCY_ENABLED !== '1') {\n  return $input.all();\n}\n\nconst WINDOW_MS = 5 * 60 * 1000;\nconst MAX_KEYS = 5000;\n\nconst data = $getWorkflowStaticData('global');\ndata.seenKeys = data.seenKeys || {};\nconst seen = data.seenKeys;\nconst now = Date.now();\n\nfor (const k of Object.keys(seen)) {\n  if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\nif (Object.keys(seen).length > MAX_KEYS) {\n  const oldest = Object.entries(seen).sort((a, b) => a[1] - b[1]).slice(0, 500);\n  for (const [k] of oldest) delete seen[k];\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  if (j.skipped) { out.push(item); continue; }\n  const messageId = j.messageId || '';\n  const dedupKey = crypto.createHash('sha256').update(String(messageId), 'utf8').digest('hex').slice(0, 32);\n  if (seen[dedupKey]) {\n    out.push({ json: { ...j, skipped: true, reason: 'duplicate-message-id', dedupKey } });\n    continue;\n  }\n  seen[dedupKey] = now;\n  out.push(item);\n}\nreturn out;\n\n// Redis variant for clustered n8n:\n// const result = await redis.set('email-idem:' + dedupKey, '1', 'EX', 300, 'NX');\n// if (result === null) treat-as-duplicate;"
      },
      "id": "email-pp-2-idempotency",
      "name": "Idempotency Check (opt-in)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        840,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Normalize the email payload into a stable Notion page-create shape.\n// Strip HTML to plain text for the snippet. Cap snippet length.\n\nconst stripHtml = (s) => String(s || '')\n  .replace(/<style[\\s\\S]*?<\\/style>/gi, '')\n  .replace(/<script[\\s\\S]*?<\\/script>/gi, '')\n  .replace(/<[^>]+>/g, ' ')\n  .replace(/&nbsp;/gi, ' ')\n  .replace(/&amp;/gi, '&')\n  .replace(/&lt;/gi, '<')\n  .replace(/&gt;/gi, '>')\n  .replace(/&quot;/gi, '\"')\n  .replace(/&#x27;/gi, \"'\")\n  .replace(/&#39;/gi, \"'\")\n  .replace(/\\s+/g, ' ')\n  .trim();\n\nconst SNIPPET_LIMIT = 1800; // Notion rich_text segment soft-limit ~2000 chars\nconst SUBJECT_LIMIT = 200;\n\nconst out = [];\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  if (j.skipped) {\n    out.push({ json: { ...j, normalizedSkipped: true } });\n    continue;\n  }\n  const raw = j.raw || {};\n  const subject = String(j.subject || '(no subject)').slice(0, SUBJECT_LIMIT);\n  const from = String(j.from || '').slice(0, 200);\n  const bodyText = raw.text || stripHtml(raw.html || '');\n  const snippet = String(bodyText).slice(0, SNIPPET_LIMIT);\n  const receivedAt = (raw.date && new Date(raw.date).toISOString()) || new Date().toISOString();\n  const attachments = Array.isArray(raw.attachments) ? raw.attachments : [];\n\n  out.push({ json: {\n    skipped: false,\n    messageId: j.messageId,\n    subject,\n    from,\n    snippet,\n    receivedAt,\n    attachmentCount: attachments.length,\n  }});\n}\nreturn out;"
      },
      "id": "email-3-normalize",
      "name": "Normalize Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Pass-through filter: drops items already marked skipped, only forwards real ones.\n\nconst out = [];\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  if (!j.skipped) out.push(item);\n}\nif (out.length === 0) {\n  return [{ json: { skipped: true, reason: 'all-items-filtered-or-deduped' } }];\n}\nreturn out;"
      },
      "id": "email-4-passthrough",
      "name": "Forward Live Items Only",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1240,
        60
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.notion.com/v1/pages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.NOTION_API_TOKEN }}"
            },
            {
              "name": "Notion-Version",
              "value": "2025-09-03"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ parent: { database_id: $env.NOTION_DATABASE_ID }, properties: { Subject: { title: [{ text: { content: $json.subject } }] }, From: { rich_text: [{ text: { content: $json.from } }] }, Snippet: { rich_text: [{ text: { content: $json.snippet } }] }, ReceivedAt: { date: { start: $json.receivedAt } }, Attachments: { number: $json.attachmentCount }, MessageId: { rich_text: [{ text: { content: $json.messageId } }] } } }) }}",
        "options": {}
      },
      "id": "email-5-notion",
      "name": "Notion Create Page",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1440,
        60
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "respondWith": "noData",
        "options": {}
      },
      "id": "email-6-done",
      "name": "Done",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1640,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Fallback when the Notion call fails. Build a structured error log\n// and post a Slack alert if SLACK_OPS_WEBHOOK is set.\n\nconst input = $input.first();\nconst raw = input.json || {};\nconst errorRaw = raw.error || raw;\nconst isHttpError = !!(errorRaw && (errorRaw.message || errorRaw.code));\nconst errorMessage = isHttpError ? (errorRaw.message || 'Notion API error') : 'Unknown error: ' + JSON.stringify(errorRaw).slice(0, 200);\n\n// Pull the email metadata for the alert. Fall back gracefully if missing.\nconst forward = $('Forward Live Items Only').first();\nconst emailMeta = (forward && forward.json) || {};\n\nreturn [{\n  json: {\n    notionError: { message: errorMessage, raw: errorRaw },\n    email: { subject: emailMeta.subject, from: emailMeta.from, messageId: emailMeta.messageId },\n  },\n}];"
      },
      "id": "email-err-fallback",
      "name": "Error Fallback",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1440,
        380
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.SLACK_OPS_WEBHOOK }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: ':warning: Notion write failed for email \"' + ($json.email.subject || '?') + '\" from ' + ($json.email.from || '?') + ': ' + ($json.notionError.message || 'unknown error') }) }}",
        "options": {}
      },
      "id": "email-err-slack",
      "name": "Slack Alert",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1640,
        380
      ],
      "onError": "continueRegularOutput"
    }
  ],
  "connections": {
    "IMAP Email": {
      "main": [
        [
          {
            "node": "Filter Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Email": {
      "main": [
        [
          {
            "node": "Rate Limit (opt-in)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rate Limit (opt-in)": {
      "main": [
        [
          {
            "node": "Idempotency Check (opt-in)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Idempotency Check (opt-in)": {
      "main": [
        [
          {
            "node": "Normalize Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Email": {
      "main": [
        [
          {
            "node": "Forward Live Items Only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Forward Live Items Only": {
      "main": [
        [
          {
            "node": "Notion Create Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notion Create Page": {
      "main": [
        [
          {
            "node": "Done",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Error Fallback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Error Fallback": {
      "main": [
        [
          {
            "node": "Slack Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

How this works

Transform incoming emails into organised Notion pages effortlessly, saving you hours of manual data entry and ensuring your knowledge base stays up to date without constant oversight. This workflow suits busy professionals or teams managing customer support, project updates, or research notes via email, who rely on Notion for centralised information. The key step involves reading emails through IMAP, processing their content with custom code to filter and normalise data, then using an HTTP request to create structured pages in Notion seamlessly.

Use this workflow when you receive regular emails that need archiving or actioning in Notion, such as lead captures or feedback forms, to automate routine tasks efficiently. Avoid it for high-volume inboxes exceeding thousands of emails daily, as the built-in rate limiting may require scaling; opt for dedicated enterprise tools instead. Common variations include adding AI for email summarisation before insertion or integrating with Slack for notifications on new Notion entries.

About this workflow

Email to Notion. Uses stickyNote, emailReadImap, httpRequest, noOp. Manual trigger; 13 nodes.

Source: https://github.com/studiomeyer-io/n8n-workflows/blob/main/templates/11-email-to-notion/workflow.json — original creator credit. Request a take-down →

More General workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

General

Transporeon - orders - step 3 - process single. Uses start, functionItem, httpRequest, microsoftSql. Manual trigger; 26 nodes.

Start, Function Item, HTTP Request +3
General

ACAPS. Uses start, httpRequest, itemLists. Manual trigger; 10 nodes.

Start, HTTP Request, Item Lists
General

Workflow-Buy-Stocks. Uses httpRequest. Manual trigger; 2 nodes.

HTTP Request
General

Apify. Uses httpRequest, stickyNote. Manual trigger; 9 nodes.

HTTP Request
General

OpenAI e-mail classification - application. Uses emailReadImap, stickyNote, textClassifier, informationExtractor. Manual trigger; 10 nodes.

Email Read Imap, Text Classifier, Information Extractor +1