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.
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 n8n template allows you to automatically monitor your company's budget by comparing live Bexio accounting data against targets defined in Google Sheets, sending automated weekly email reports. It
This workflow streamlines HR outreach by fetching contact data, validating emails, enforcing daily sending limits, and sending personalized emails with attachments, all while logging activity. Read HR
This workflow automatically extracts data from invoice documents (PDFs and images) and processes them through a comprehensive validation and approval system. Multi-Input Triggers - Accepts invoices vi