{
  "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
  }
}