Automate Email Triage with Local AI: n8n + Ollama Email Classification Workflow

Published March 24, 2026 · 12 min read

Your inbox is a mess. Support tickets mixed with sales inquiries, billing questions buried under spam, and urgent messages sitting unread for hours. Most email classification tools charge per message and require sending every email — including sensitive internal communications — to external AI providers.

With n8n and Ollama, you can build an email triage system that runs entirely on your own hardware. Every email stays on your server, classification costs nothing per message, and the system works even when your internet goes down.

In this tutorial, you'll build a workflow that:

  1. Polls your inbox via IMAP (or Gmail API)
  2. Classifies each email into categories: support, sales, billing, urgent, or spam
  3. Auto-labels emails and routes notifications to the right Slack channel
  4. Drafts contextual replies for common categories
  5. Logs everything to a spreadsheet for tracking

Why Local AI for Email Triage

Emails are some of the most sensitive data in any organization. They contain customer details, financial information, internal discussions, and legal communications. Sending all of that to OpenAI or Google's API creates real compliance and privacy risks.

ConcernCloud AI (GPT-4, Gemini)Local AI (Ollama)
Data privacyEmails sent to third-party serversEverything stays on your machine
Cost at scale$0.01–0.06 per email$0 per email after hardware
ComplianceMay violate GDPR, HIPAA, SOC 2Full control over data residency
ReliabilityDepends on API uptimeWorks offline, no rate limits
SpeedNetwork latency + queue time2–4 seconds on modest hardware

The privacy argument is decisive here. Email classification is one of the strongest use cases for local AI. A company processing 200 emails/day through GPT-4 spends $120–360/month on API calls alone. With Ollama, that same volume costs nothing and keeps customer data off third-party servers.

Architecture Overview

The workflow connects four components in a straightforward pipeline:

[IMAP/Gmail: Fetch new emails]
        |
        v
[Ollama: Classify email category + priority]
        |
        v
[Switch: Route by category]
   /    |    |    \     \
  v     v    v     v     v
[Support][Sales][Billing][Urgent][Spam]
  |      |     |      |
  v      v     v      v
[Slack] [Slack][Slack][Slack + SMS]
  |      |     |      |
  v      v     v      v
[Ollama: Draft reply for each category]
        |
        v
[Google Sheets: Log classification]

The IMAP trigger polls every 2 minutes. Each email takes 3–6 seconds to classify and draft a response — two Ollama calls. For a mailbox receiving 100 emails/day, that's under 10 minutes of total compute.

Prerequisites

You need n8n and Ollama running. If you haven't set these up yet:

# Install Ollama and pull a model
curl -fsSL https://ollama.ai/install.sh | sh
ollama pull llama3:8b

# Run n8n in Docker
docker run -d --name n8n -p 5678:5678 \
  --add-host=host.docker.internal:host-gateway \
  -v n8n_data:/home/node/.n8n \
  n8nio/n8n

You'll also need IMAP credentials for your email account. For Gmail, enable IMAP in settings and create an App Password. For other providers, use your standard IMAP server and credentials.

Docker users: Use http://host.docker.internal:11434 for Ollama URLs in your n8n workflows. If n8n runs natively (not in Docker), use http://localhost:11434.

Step-by-Step: Building the Email Triage Workflow

STEP 1: Connect Your Inbox

Add an IMAP Email trigger node in n8n. Configure it with your mail server settings:

  • Host: imap.gmail.com (or your provider's IMAP server)
  • Port: 993
  • User: Your email address
  • Password: Your app password (not your regular password)
  • Mailbox: INBOX
  • Poll interval: Every 2 minutes

The node outputs subject, from, text (plain body), and html for each new email. We'll use the plain text body for classification since it's cleaner for the LLM to parse.

STEP 2: Classify with Ollama

Add an HTTP Request node that sends the email to Ollama for classification. The prompt is the core of the system — it needs to be specific about categories and output format:

You are an email triage system. Classify this email into exactly
one category and assign a priority level.

Categories:
- support: Customer needs help, bug reports, feature requests
- sales: Purchase inquiries, pricing questions, partnership proposals
- billing: Invoice questions, payment issues, subscription changes
- urgent: Security alerts, system outages, legal notices, time-sensitive
- spam: Marketing, newsletters, unsolicited outreach, promotions

Priority levels: high, medium, low

From: {{ $json.from }}
Subject: {{ $json.subject }}
Body: {{ $json.text.substring(0, 1500) }}

Respond in this EXACT JSON format, nothing else:
{"category": "<category>", "priority": "<high|medium|low>",
 "reason": "<one sentence explanation>",
 "suggested_labels": ["<label1>", "<label2>"]}

Key details: We truncate the body to 1,500 characters to keep inference fast and avoid context window issues. Temperature is set to 0.1 for consistent, deterministic classification.

STEP 3: Route by Category

Add a Switch node after classification. Parse the JSON response and route based on the category field:

  • support → #support-tickets Slack channel
  • sales → #sales-leads Slack channel
  • billing → #billing Slack channel
  • urgent → #urgent-alerts Slack channel (+ optional SMS via Twilio)
  • spam → Log only, no notification

Each Slack message includes the sender, subject, category, priority, and the AI's reasoning. This gives the receiving team enough context to act without opening the email.

STEP 4: Auto-Draft Replies

For non-spam categories, add a second Ollama call that drafts a contextual reply. The prompt varies by category:

Draft a professional reply to this email. Be helpful and concise.

Category: {{ $json.category }}
Original email from: {{ $json.from }}
Subject: {{ $json.subject }}
Body: {{ $json.text.substring(0, 1500) }}

Rules:
- For support: Acknowledge the issue, ask for details if needed,
  mention a ticket has been created
- For sales: Thank them for interest, mention someone will follow
  up within 24 hours
- For billing: Confirm receipt, mention the billing team will review
- For urgent: Acknowledge immediately, confirm it's being escalated

Write ONLY the reply body. No subject line. Keep it under 150 words.

The drafted reply is saved as a Gmail draft (using the Gmail node) or sent to a review queue. Never auto-send without human review — even good AI makes mistakes.

STEP 5: Log to Spreadsheet

Add a Google Sheets node at the end of the pipeline to log every classification. Track columns: timestamp, from, subject, category, priority, reason, draft_created. This data is invaluable for tuning your prompts and measuring accuracy over time.

The Complete Workflow JSON

Import this directly into your n8n instance. You'll need to update the IMAP credentials and Slack webhook URLs for your environment.

Click to expand full workflow JSON
{
  "name": "AI Email Triage + Auto-Draft (Ollama)",
  "nodes": [
    {
      "parameters": {
        "pollTimes": { "item": [{ "mode": "everyMinute", "minute": 2 }] },
        "mailbox": "INBOX",
        "options": { "allowUnauthorizedCerts": false }
      },
      "id": "imap-trigger",
      "name": "Fetch New Emails",
      "type": "n8n-nodes-base.imapEmail",
      "typeVersion": 2,
      "position": [240, 300],
      "credentials": { "imap": { "id": "REPLACE_WITH_YOUR_IMAP_CREDENTIAL_ID", "name": "IMAP Account" } }
    },
    {
      "parameters": {
        "url": "http://localhost:11434/api/generate",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'llama3:8b', prompt: 'You are an email triage system. Classify this email into exactly one category and assign a priority level.\\n\\nCategories:\\n- support: Customer needs help, bug reports, feature requests, technical issues\\n- sales: Purchase inquiries, pricing questions, partnership proposals, demo requests\\n- billing: Invoice questions, payment issues, subscription changes, refund requests\\n- urgent: Security alerts, system outages, legal notices, time-sensitive deadlines\\n- spam: Marketing newsletters, unsolicited outreach, promotions, automated notifications\\n\\nPriority levels: high, medium, low\\n\\nFrom: ' + ($json.from || 'Unknown') + '\\nSubject: ' + ($json.subject || 'No subject') + '\\nBody: ' + ($json.text || $json.html || 'Empty').substring(0, 1500) + '\\n\\nRespond in this EXACT JSON format, nothing else:\\n{\"category\": \"\", \"priority\": \"\", \"reason\": \"\", \"suggested_labels\": [\"\", \"\"]}', stream: false, options: { temperature: 0.1, num_predict: 200 } }) }}",
        "options": { "timeout": 60000 }
      },
      "id": "classify-email",
      "name": "Classify Email (Ollama)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [480, 300]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            { "id": "from", "name": "from", "value": "={{ $('Fetch New Emails').item.json.from }}", "type": "string" },
            { "id": "subject", "name": "subject", "value": "={{ $('Fetch New Emails').item.json.subject }}", "type": "string" },
            { "id": "text", "name": "text", "value": "={{ $('Fetch New Emails').item.json.text || $('Fetch New Emails').item.json.html || '' }}", "type": "string" },
            { "id": "classification", "name": "classification", "value": "={{ (() => { try { return JSON.parse(JSON.parse($json.data).response) } catch(e) { return { category: 'support', priority: 'medium', reason: 'Parse error - defaulting to support' } } })() }}", "type": "string" }
          ]
        }
      },
      "id": "parse-result",
      "name": "Parse Classification",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [700, 300]
    },
    {
      "parameters": {
        "rules": {
          "rules": [
            { "outputKey": "support", "conditions": { "conditions": [{ "leftValue": "={{ $json.classification.category }}", "rightValue": "support", "operator": { "type": "string", "operation": "equals" } }] } },
            { "outputKey": "sales", "conditions": { "conditions": [{ "leftValue": "={{ $json.classification.category }}", "rightValue": "sales", "operator": { "type": "string", "operation": "equals" } }] } },
            { "outputKey": "billing", "conditions": { "conditions": [{ "leftValue": "={{ $json.classification.category }}", "rightValue": "billing", "operator": { "type": "string", "operation": "equals" } }] } },
            { "outputKey": "urgent", "conditions": { "conditions": [{ "leftValue": "={{ $json.classification.category }}", "rightValue": "urgent", "operator": { "type": "string", "operation": "equals" } }] } },
            { "outputKey": "spam", "conditions": { "conditions": [{ "leftValue": "={{ $json.classification.category }}", "rightValue": "spam", "operator": { "type": "string", "operation": "equals" } }] } }
          ]
        }
      },
      "id": "route-email",
      "name": "Route by Category",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [920, 300]
    },
    {
      "parameters": {
        "url": "YOUR_SUPPORT_SLACK_WEBHOOK_URL",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: ':email: *New Support Email*\\n*From:* ' + $json.from + '\\n*Subject:* ' + $json.subject + '\\n*Priority:* ' + $json.classification.priority + '\\n*Reason:* ' + $json.classification.reason }) }}"
      },
      "id": "slack-support",
      "name": "Slack: Support",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1140, 100]
    },
    {
      "parameters": {
        "url": "YOUR_SALES_SLACK_WEBHOOK_URL",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: ':moneybag: *New Sales Inquiry*\\n*From:* ' + $json.from + '\\n*Subject:* ' + $json.subject + '\\n*Priority:* ' + $json.classification.priority + '\\n*Reason:* ' + $json.classification.reason }) }}"
      },
      "id": "slack-sales",
      "name": "Slack: Sales",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1140, 260]
    },
    {
      "parameters": {
        "url": "YOUR_BILLING_SLACK_WEBHOOK_URL",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: ':receipt: *Billing Email*\\n*From:* ' + $json.from + '\\n*Subject:* ' + $json.subject + '\\n*Priority:* ' + $json.classification.priority + '\\n*Reason:* ' + $json.classification.reason }) }}"
      },
      "id": "slack-billing",
      "name": "Slack: Billing",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1140, 420]
    },
    {
      "parameters": {
        "url": "YOUR_URGENT_SLACK_WEBHOOK_URL",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: ':rotating_light: *URGENT EMAIL*\\n*From:* ' + $json.from + '\\n*Subject:* ' + $json.subject + '\\n*Priority:* HIGH\\n*Reason:* ' + $json.classification.reason }) }}"
      },
      "id": "slack-urgent",
      "name": "Slack: Urgent",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1140, 580]
    },
    {
      "parameters": {
        "url": "http://localhost:11434/api/generate",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'llama3:8b', prompt: 'Draft a professional reply to this email. Be helpful and concise.\\n\\nCategory: ' + $json.classification.category + '\\nOriginal email from: ' + $json.from + '\\nSubject: ' + $json.subject + '\\nBody: ' + ($json.text || '').substring(0, 1500) + '\\n\\nRules:\\n- For support: Acknowledge the issue, ask clarifying questions if needed, mention a ticket has been created\\n- For sales: Thank them for interest, mention someone from the team will follow up within 24 hours\\n- For billing: Confirm receipt of their inquiry, mention the billing team will review and respond\\n- For urgent: Acknowledge immediately, confirm the issue is being escalated to the on-call team\\n\\nWrite ONLY the reply body text. No subject line. No signature. Keep it under 150 words.', stream: false, options: { temperature: 0.4, num_predict: 400 } }) }}",
        "options": { "timeout": 60000 }
      },
      "id": "draft-reply",
      "name": "Draft Reply (Ollama)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1360, 340]
    }
  ],
  "connections": {
    "Fetch New Emails": {
      "main": [[{ "node": "Classify Email (Ollama)", "type": "main", "index": 0 }]]
    },
    "Classify Email (Ollama)": {
      "main": [[{ "node": "Parse Classification", "type": "main", "index": 0 }]]
    },
    "Parse Classification": {
      "main": [[{ "node": "Route by Category", "type": "main", "index": 0 }]]
    },
    "Route by Category": {
      "main": [
        [{ "node": "Slack: Support", "type": "main", "index": 0 }],
        [{ "node": "Slack: Sales", "type": "main", "index": 0 }],
        [{ "node": "Slack: Billing", "type": "main", "index": 0 }],
        [{ "node": "Slack: Urgent", "type": "main", "index": 0 }],
        []
      ]
    },
    "Slack: Support": {
      "main": [[{ "node": "Draft Reply (Ollama)", "type": "main", "index": 0 }]]
    },
    "Slack: Sales": {
      "main": [[{ "node": "Draft Reply (Ollama)", "type": "main", "index": 0 }]]
    },
    "Slack: Billing": {
      "main": [[{ "node": "Draft Reply (Ollama)", "type": "main", "index": 0 }]]
    },
    "Slack: Urgent": {
      "main": [[{ "node": "Draft Reply (Ollama)", "type": "main", "index": 0 }]]
    }
  },
  "settings": { "executionOrder": "v1" },
  "tags": [{ "name": "AI" }, { "name": "Ollama" }, { "name": "Email" }, { "name": "Triage" }]
}

Testing the Classification

Before connecting your real inbox, test the classification prompt directly with curl:

curl -s http://localhost:11434/api/generate -d '{
  "model": "llama3:8b",
  "prompt": "You are an email triage system. Classify this email into exactly one category.\n\nCategories: support, sales, billing, urgent, spam\nPriority: high, medium, low\n\nFrom: john@bigcorp.com\nSubject: Pricing for enterprise plan\nBody: Hi, we are a 200-person company looking at your enterprise plan. Can you send pricing details and set up a demo call this week? We need to make a decision by end of month.\n\nRespond in JSON: {\"category\": \"...\", \"priority\": \"...\", \"reason\": \"...\"}",
  "stream": false,
  "options": { "temperature": 0.1 }
}' | jq -r '.response'

Expected output:

{
  "category": "sales",
  "priority": "high",
  "reason": "Enterprise pricing inquiry with specific timeline and team size indicates qualified purchase intent"
}

Production Tips

Handling Attachments

The IMAP node captures attachment metadata (filename, MIME type, size) but not content. For triage purposes, include attachment filenames in the classification prompt — a message with invoice_Q1.pdf attached is almost certainly a billing email. For full attachment analysis, save them to disk and process with a separate workflow.

Rate Limiting and Queuing

If your inbox receives bursts of email (100+ in minutes), Ollama will queue requests but response times will spike. Add a SplitInBatches node after the IMAP trigger to process 5 emails at a time with a 1-second delay between batches. This keeps inference smooth and avoids overwhelming your hardware.

// In the SplitInBatches node settings:
Batch Size: 5
Options > Wait Between Batches: 1000 (ms)

Error Handling

Ollama occasionally returns malformed JSON, especially with longer emails. Wrap the JSON parse in a try/catch (as shown in the Parse Classification node above) and default to category: "support" with priority: "medium". This ensures no email gets silently dropped.

Add an Error Trigger workflow that sends a Slack notification when classification fails. Track the failure rate — if it exceeds 5%, your prompt likely needs tuning for your specific email patterns.

Improving Accuracy Over Time

Use the Google Sheets log to review classifications weekly. Look for patterns in misclassification:

Model recommendation: llama3:8b handles email classification well for most inboxes. If you're processing highly technical or multilingual emails, upgrade to llama3:70b or try mistral:7b-instruct for better instruction following. The workflow JSON works with any Ollama model — just change the model name in the HTTP Request nodes.

Multi-Language Support

If your inbox receives emails in multiple languages, add a line to the classification prompt: The email may be in any language. Classify based on content regardless of language. Always respond in English. Llama 3 handles major European and Asian languages reasonably well for classification tasks.

Want the Production-Ready Version?

The Self-Hosted AI Workflow Pack includes an advanced email triage system with Gmail API integration, auto-labeling, threaded reply drafts, attachment analysis, and 10 more AI workflows — all running locally with Ollama.

Get All 11 Workflows — $39

One-time purchase. No subscriptions. 30-day money-back guarantee.

Next Steps

  1. Import the workflow — Copy the JSON above into n8n and update credentials
  2. Test with curl first — Validate classification accuracy before connecting your real inbox
  3. Connect IMAP — Point the trigger at your mailbox and monitor the first 50 classifications
  4. Add Slack webhooks — Replace the placeholder URLs with real incoming webhook URLs
  5. Enable draft replies — Connect the Gmail node to save drafts for human review

For the free workflow JSON and more n8n + Ollama templates, check out our GitHub repository.

Further Reading