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 →
{
"name": "inbox-pilot \u2014 Ops Autopilot",
"nodes": [
{
"id": "sticky-intro",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
680,
40
],
"parameters": {
"content": "## inbox-pilot \u00b7 Ops Autopilot\n\n**Before activating this workflow:**\n\n1. Set up credentials:\n - `Gmail IMAP` \u2014 use an App Password (not your real password). Generate at myaccount.google.com/apppasswords\n - `Google Sheets` \u2014 Service Account JSON. Share your Sheet with the service account email.\n - `Telegram` \u2014 Bot token from @BotFather\n\n2. Replace placeholders in the nodes:\n - `Google Sheets` node \u2192 paste your Sheet ID\n - `Telegram Alert` node \u2192 paste your Chat ID\n\n3. Activate the workflow (toggle top-right)\n\n**What this does:**\nPolls Gmail every 60 s \u2192 deduplicates \u2192 classifies into order / support / invoice / other \u2192 logs to Google Sheets \u2192 sends Telegram alert.\n\n**Classifier:** keyword-based by default. To switch to Ollama (AI), enable the `Ollama Classify` node and disable `Keyword Classify`.",
"height": 360,
"width": 420,
"color": 5
}
},
{
"id": "sticky-scale",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
680,
440
],
"parameters": {
"content": "## Scaling to queue mode\n\nIn your `.env`:\n```\nEXECUTIONS_MODE=queue\n```\nThen restart with:\n```\ndocker compose --profile queue up -d\n```\nNo changes to this workflow needed.",
"height": 180,
"width": 420,
"color": 6
}
},
{
"id": "node-trigger",
"name": "Every 60 seconds",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
180,
120
],
"parameters": {
"rule": {
"interval": [
{
"field": "seconds",
"secondsInterval": 60
}
]
}
}
},
{
"id": "node-imap",
"name": "Read Unread Emails",
"type": "n8n-nodes-base.emailReadImap",
"typeVersion": 2,
"position": [
180,
280
],
"credentials": {
"imap": {
"name": "<your credential>"
}
},
"parameters": {
"mailbox": "INBOX",
"action": "read",
"options": {
"allowUnauthorizedCerts": false,
"markSeen": true
}
},
"onError": "continueRegularOutput"
},
{
"id": "node-dedup",
"name": "Deduplicate",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
180,
460
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Tracks processed email UIDs in workflow static data.\n// Static data persists between executions \u2014 no external DB needed.\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.seenIds) staticData.seenIds = [];\n\nconst incoming = $input.all();\nconst fresh = [];\n\nfor (const item of incoming) {\n // IMAP UID is the most reliable dedup key; fall back to messageId\n const uid = String(\n item.json.uid ||\n item.json.messageId ||\n item.json.id ||\n ''\n );\n\n if (!uid) {\n // No ID at all \u2014 pass through to avoid silently dropping mail\n fresh.push(item);\n continue;\n }\n\n if (staticData.seenIds.includes(uid)) {\n // Already processed \u2014 skip\n continue;\n }\n\n staticData.seenIds.push(uid);\n fresh.push(item);\n}\n\n// Cap the seen-IDs list at 5000 to prevent memory growth\nif (staticData.seenIds.length > 5000) {\n staticData.seenIds = staticData.seenIds.slice(-5000);\n}\n\nif (fresh.length === 0) {\n // Return empty \u2014 workflow stops here gracefully, no error\n return [];\n}\n\nreturn fresh;"
}
},
{
"id": "node-classify-keyword",
"name": "Keyword Classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
180,
640
],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Keyword-based classifier. Fast, zero dependencies, works offline.\n// Scoring: count how many keywords from each category appear in\n// the subject + first 500 chars of body. Highest score wins.\n\nconst subject = (($json.subject) || '').toLowerCase();\nconst body = (($json.text) || ($json.textHtml) || '').toLowerCase().slice(0, 500);\nconst content = subject + ' ' + body;\n\nconst rules = {\n order: [\n 'order', 'purchase', 'bought', 'buy', 'shipment',\n 'shipped', 'delivery', 'tracking', 'receipt', 'confirmation',\n 'item', 'product', 'cart', 'checkout'\n ],\n support: [\n 'help', 'support', 'issue', 'problem', 'broken',\n 'error', 'bug', 'not working', 'urgent', 'ticket',\n 'crash', 'down', 'fail', 'cannot', 'unable'\n ],\n invoice: [\n 'invoice', 'payment', 'due', 'billing', 'overdue',\n 'statement', 'amount due', 'pay now', 'balance',\n 'subscription', 'renewal', 'charge'\n ]\n};\n\nlet best = 'other';\nlet score = 0;\n\nfor (const [cat, keywords] of Object.entries(rules)) {\n const hits = keywords.reduce((n, kw) => n + (content.includes(kw) ? 1 : 0), 0);\n if (hits > score) {\n score = hits;\n best = cat;\n }\n}\n\n// Normalise sender \u2014 strip display name, keep email address only\nconst fromRaw = ($json.from || '');\nconst fromMatch = fromRaw.match(/<(.+?)>/);\nconst fromEmail = fromMatch ? fromMatch[1] : fromRaw.trim();\n\n// Safe preview: first 200 chars of plain-text body, newlines stripped\nconst preview = ($json.text || '')\n .replace(/<[^>]+>/g, '')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, 200);\n\nreturn {\n json: {\n ...$json,\n category: best,\n categoryScore: score,\n fromEmail,\n preview,\n classifiedAt: new Date().toISOString(),\n }\n};"
}
},
{
"id": "node-classify-ollama",
"name": "Ollama Classify",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
420,
640
],
"disabled": true,
"parameters": {
"method": "POST",
"url": "http://ollama:11434/api/generate",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"llama3.2\",\n \"stream\": false,\n \"prompt\": \"Classify this email into exactly one of these categories: order, support, invoice, other.\\n\\nRespond with ONLY the category word, nothing else.\\n\\nFrom: {{ $json.from }}\\nSubject: {{ $json.subject }}\\nBody (first 300 chars): {{ ($json.text || '').slice(0, 300) }}\"\n}",
"options": {}
},
"notes": "Enable this node and disable 'Keyword Classify' to use Ollama.\nRequires docker compose --profile ai up -d.\nModel: llama3.2 (run: docker exec inbox-pilot-ollama ollama pull llama3.2)"
},
{
"id": "node-switch",
"name": "Route by Category",
"type": "n8n-nodes-base.switch",
"typeVersion": 3,
"position": [
180,
820
],
"parameters": {
"mode": "rules",
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "cond-order",
"leftValue": "={{ $json.category }}",
"rightValue": "order",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "order"
},
{
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "cond-support",
"leftValue": "={{ $json.category }}",
"rightValue": "support",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "support"
},
{
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "cond-invoice",
"leftValue": "={{ $json.category }}",
"rightValue": "invoice",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "invoice"
},
{
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "cond-other",
"leftValue": "={{ $json.category }}",
"rightValue": "other",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "other"
}
]
},
"fallbackOutput": "none"
}
},
{
"id": "node-sheets",
"name": "Log to Google Sheets",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.4,
"position": [
180,
1020
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "REPLACE_WITH_YOUR_GOOGLE_SHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Sheet1",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"Timestamp": "={{ $now.toISO() }}",
"From": "={{ $json.fromEmail || $json.from }}",
"Subject": "={{ $json.subject }}",
"Category": "={{ $json.category }}",
"Score": "={{ $json.categoryScore }}",
"Preview": "={{ $json.preview }}",
"UID": "={{ $json.uid || $json.messageId || '' }}"
},
"matchingColumns": [],
"schema": [
{
"id": "Timestamp",
"displayName": "Timestamp",
"required": false,
"defaultMatch": false,
"canBeUsedToMatch": true,
"display": true,
"type": "string"
},
{
"id": "From",
"displayName": "From",
"required": false,
"defaultMatch": false,
"canBeUsedToMatch": true,
"display": true,
"type": "string"
},
{
"id": "Subject",
"displayName": "Subject",
"required": false,
"defaultMatch": false,
"canBeUsedToMatch": true,
"display": true,
"type": "string"
},
{
"id": "Category",
"displayName": "Category",
"required": false,
"defaultMatch": false,
"canBeUsedToMatch": true,
"display": true,
"type": "string"
},
{
"id": "Score",
"displayName": "Score",
"required": false,
"defaultMatch": false,
"canBeUsedToMatch": true,
"display": true,
"type": "number"
},
{
"id": "Preview",
"displayName": "Preview",
"required": false,
"defaultMatch": false,
"canBeUsedToMatch": true,
"display": true,
"type": "string"
},
{
"id": "UID",
"displayName": "UID",
"required": false,
"defaultMatch": false,
"canBeUsedToMatch": true,
"display": true,
"type": "string"
}
]
},
"options": {
"cellFormat": "RAW"
}
}
},
{
"id": "node-telegram",
"name": "Telegram Alert",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [
180,
1200
],
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"parameters": {
"resource": "message",
"operation": "sendMessage",
"chatId": "REPLACE_WITH_YOUR_TELEGRAM_CHAT_ID",
"text": "={{ '[' + $json.category.toUpperCase() + '] New email from ' + ($json.fromEmail || $json.from) + ' \u2014 \"' + $json.subject + '\"' }}",
"additionalFields": {
"parse_mode": "Markdown"
}
},
"onError": "continueRegularOutput"
},
{
"id": "node-status-webhook",
"name": "Status Page Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
680,
680
],
"parameters": {
"httpMethod": "GET",
"path": "inbox-pilot-status",
"responseMode": "responseNode",
"options": {
"allowedOrigins": "*"
}
},
"notes": "The status-page/index.html polls this URL.\nFull URL: https://YOUR_DOMAIN/webhook/inbox-pilot-status"
},
{
"id": "node-status-read",
"name": "Read Recent Stats",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
840
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Reads from the same static data the main flow writes to.\n// Returns the last 20 processed emails + category counts for today.\nconst staticData = $getWorkflowStaticData('global');\n\nconst recent = (staticData.recentEmails || []).slice(-20).reverse();\nconst todayCounts = staticData.todayCounts || { order: 0, support: 0, invoice: 0, other: 0 };\nconst lastRunAt = staticData.lastRunAt || null;\nconst totalToday = Object.values(todayCounts).reduce((a, b) => a + b, 0);\n\nreturn [{\n json: {\n ok: true,\n lastRunAt,\n totalToday,\n counts: todayCounts,\n recent\n }\n}];"
}
},
{
"id": "node-status-respond",
"name": "Respond with JSON",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
680,
1000
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
},
{
"name": "Cache-Control",
"value": "no-cache"
}
]
}
}
}
},
{
"id": "node-update-static",
"name": "Update Static Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
180,
1380
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Persists today's email log into static data so the status page can read it.\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.recentEmails) staticData.recentEmails = [];\nif (!staticData.todayCounts) staticData.todayCounts = { order: 0, support: 0, invoice: 0, other: 0 };\n\n// Reset counts at midnight\nconst todayStr = new Date().toISOString().slice(0, 10);\nif (staticData.currentDay !== todayStr) {\n staticData.currentDay = todayStr;\n staticData.todayCounts = { order: 0, support: 0, invoice: 0, other: 0 };\n}\n\nfor (const item of $input.all()) {\n const cat = item.json.category || 'other';\n staticData.todayCounts[cat] = (staticData.todayCounts[cat] || 0) + 1;\n staticData.recentEmails.push({\n from: item.json.fromEmail || item.json.from,\n subject: item.json.subject,\n category: cat,\n preview: item.json.preview,\n processedAt: item.json.classifiedAt || new Date().toISOString()\n });\n}\n\n// Keep last 200 emails only\nif (staticData.recentEmails.length > 200) {\n staticData.recentEmails = staticData.recentEmails.slice(-200);\n}\n\nstaticData.lastRunAt = new Date().toISOString();\n\nreturn $input.all();"
}
}
],
"connections": {
"Every 60 seconds": {
"main": [
[
{
"node": "Read Unread Emails",
"type": "main",
"index": 0
}
]
]
},
"Read Unread Emails": {
"main": [
[
{
"node": "Deduplicate",
"type": "main",
"index": 0
}
]
]
},
"Deduplicate": {
"main": [
[
{
"node": "Keyword Classify",
"type": "main",
"index": 0
}
]
]
},
"Keyword Classify": {
"main": [
[
{
"node": "Route by Category",
"type": "main",
"index": 0
}
]
]
},
"Route by Category": {
"main": [
[
{
"node": "Log to Google Sheets",
"type": "main",
"index": 0
}
],
[
{
"node": "Log to Google Sheets",
"type": "main",
"index": 0
}
],
[
{
"node": "Log to Google Sheets",
"type": "main",
"index": 0
}
],
[
{
"node": "Log to Google Sheets",
"type": "main",
"index": 0
}
]
]
},
"Log to Google Sheets": {
"main": [
[
{
"node": "Update Static Data",
"type": "main",
"index": 0
}
]
]
},
"Update Static Data": {
"main": [
[
{
"node": "Telegram Alert",
"type": "main",
"index": 0
}
]
]
},
"Status Page Webhook": {
"main": [
[
{
"node": "Read Recent Stats",
"type": "main",
"index": 0
}
]
]
},
"Read Recent Stats": {
"main": [
[
{
"node": "Respond with JSON",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"saveManualExecutions": true,
"callerPolicy": "workflowsFromSameOwner",
"errorWorkflow": "",
"saveDataSuccessExecution": "last",
"saveDataErrorExecution": "all",
"timezone": "UTC"
},
"staticData": null,
"tags": [
{
"id": "tag-email",
"name": "email"
},
{
"id": "tag-automation",
"name": "automation"
},
{
"id": "tag-inbox-pilot",
"name": "inbox-pilot"
}
],
"meta": {
"templateCredsSetupCompleted": false
}
}
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.
googleSheetsOAuth2ApiimaptelegramApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
inbox-pilot — Ops Autopilot. Uses emailReadImap, httpRequest, googleSheets, telegram. Scheduled trigger; 14 nodes.
Source: https://github.com/debuggerdragon311/ops-autopilot-n8n/blob/main/workflows/ops-autopilot.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Security Scanner - Payment Notification. Uses httpRequest, telegram, googleSheets, emailReadImap. Manual trigger; 5 nodes.
Bank Email Transaction Monitor. Uses emailReadImap, httpRequest, googleSheets. Scheduled trigger; 5 nodes.
Automatically extract structured information from emails using AI-powered document analysis. This workflow processes emails from specified domains, classifies them by type, and extracts structured dat
What This Flow Does
This workflow automates a 3-step cold email sequence from Gmail using leads in Google Sheets, generates personalized copy with Anthropic Claude, enforces a gradual daily sending cap, schedules follow-