{
  "id": "RGkUW7zWOZIU2Gqw",
  "name": "Host and Deploy Custom HTML Forms for any CRM via CustomJS",
  "tags": [],
  "nodes": [
    {
      "id": "e2048ffc-e1be-4585-bc37-d6b5a9d0f08e",
      "name": "When clicking 'Execute workflow'",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        448,
        352
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "1e2287ad-16dd-4425-87e9-24e9665d54d0",
      "name": "Upsert Approval Page",
      "type": "@custom-js/n8n-nodes-pdf-toolkit-v2.pdfToolkit",
      "position": [
        1824,
        352
      ],
      "parameters": {
        "pageName": "=Dashboard",
        "resource": "page",
        "operation": "upsert",
        "htmlContent": "={{ $json.html }}"
      },
      "credentials": {
        "customJsApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "b389898a-9936-4eda-bef0-4a0d543c2f65",
      "name": "Sticky Note Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        16
      ],
      "parameters": {
        "color": 5,
        "width": 380,
        "height": 972,
        "content": "# AI Email Reply Assistant\n\n### \ud83d\ude80 Overview\nThis two-lane workflow automates email draft generation using AI, hosts an interactive human-in-the-loop approval dashboard, and dispatches approved replies.\n\n### \ud83d\udce5 Lane 1: Draft Generator\n1. **Fetch Emails:** Retrieves recent unread messages from your Gmail inbox.\n2. **Filter Out Spam:** Excludes automated/no-reply senders.\n3. **Generate AI Draft:** An LLM generates a personalized draft reply.\n4. **Build Dashboard:** Bundles pending emails into an inbox-style Tailwind dashboard hosted via **CustomJS**.\n\n### \ud83d\udce4 Lane 2: Response Handler\n1. **Receive Approval:** Webhook captures your edits and approval from the dashboard.\n2. **Send Reply:** Sends the reply as a threaded Gmail response.\n3. **Mark Read:** Archives the email by marking it as read.\n\n### \u2699\ufe0f Initial Setup\n1. **Gmail Credentials:** Connect your Gmail account in the *Get many messages*, *Reply to a message*, and *Mark a message as read* nodes.\n2. **OpenAI Credentials:** Connect your OpenAI API key in the *Generate AI Draft Reply* node.\n3. **CustomJS Credentials:** Connect your CustomJS credentials in the *Upsert Approval Page* node.\n4. **Activate:** Toggle the workflow to **Active** so the Lane 2 Webhook can receive submissions."
      },
      "typeVersion": 1
    },
    {
      "id": "040903ff-0fe2-4053-9689-dce9041985fe",
      "name": "Sticky Note Email",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        400,
        16
      ],
      "parameters": {
        "color": 5,
        "width": 560,
        "height": 300,
        "content": "### \ud83d\udcec Email Source\nThis section retrieves unread emails to process.\n\n- **Trigger:** Currently manual execution. For production, replace the manual trigger with a **Gmail Trigger** (On Email Received) or an **IMAP Email** trigger.\n- **Gmail Node:** Fetches unread emails. Customize the fetch limit (currently 3) in the parameters as needed.\n- **Filter Node:** A JavaScript code block that automatically filters out standard `no-reply@` addresses to keep your dashboard clean."
      },
      "typeVersion": 1
    },
    {
      "id": "b4f80070-8748-4ffc-b901-2648b3e6af5a",
      "name": "Sticky Note AI",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        976,
        16
      ],
      "parameters": {
        "color": 5,
        "width": 364,
        "height": 300,
        "content": "### \ud83e\udd16 AI Reply Generation\nGenerates a contextual response draft.\n\n- **OpenAI Node:** Uses `gpt-5` (or your preferred LLM) to analyze the email body, sender, and subject.\n- **Prompt:** Instructs the model to write a professional email reply based on the sender's input.\n- **Customization:** Modify the prompt template or system instructions to match your desired tone, style, or business context."
      },
      "typeVersion": 1
    },
    {
      "id": "6a4e4d7e-79b3-480e-b979-ee5edfa6e01f",
      "name": "Sticky Note Host",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1360,
        16
      ],
      "parameters": {
        "color": 5,
        "width": 640,
        "height": 300,
        "content": "### \ud83c\udf10 Host Approval Dashboard\nCompiles the email inbox and hosts the user interface.\n\n- **Format Drafts:** Maps email fields and pairs them with their respective AI draft responses.\n- **Build Approval Page:** A Code node containing a premium Tailwind CSS + Alpine.js web app. It bundles all pending emails into a single-page app where you can view side-by-side comparisons and edit the draft.\n- **Upsert Approval Page (CustomJS):** Automatically hosts the generated HTML on the CustomJS CDN."
      },
      "typeVersion": 1
    },
    {
      "id": "6d08a274-6a10-42ae-a345-2927f7a6f328",
      "name": "Get many messages",
      "type": "n8n-nodes-base.gmail",
      "position": [
        640,
        352
      ],
      "parameters": {
        "limit": 10,
        "filters": {
          "readStatus": "unread"
        },
        "operation": "getAll"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "e870c110-1271-4efd-b17c-13e537e99203",
      "name": "Webhook - Receive Approval",
      "type": "n8n-nodes-base.webhook",
      "position": [
        464,
        816
      ],
      "parameters": {
        "path": "email-reply-submit",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "lastNode"
      },
      "typeVersion": 1
    },
    {
      "id": "c57dd724-8b61-47db-b691-4b24713e4c47",
      "name": "Extract Reply Data",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        816
      ],
      "parameters": {
        "jsCode": "var body = $input.all()[0].json.body;\nvar replyData = {\n  approvedReply: body.approvedReply || \"\",\n  originalFrom: body.originalFrom || \"\",\n  originalFromName: body.originalFromName || \"\",\n  originalSubject: body.originalSubject || \"\",\n  messageId: body.messageId || \"\",\n  threadId: body.threadId || \"\",\n  approvedAt: new Date().toISOString()\n};\nreturn [{ json: replyData }];"
      },
      "typeVersion": 2
    },
    {
      "id": "b9b77f43-ee78-4c3d-851e-294bf2c21cba",
      "name": "Lane 2 Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        400,
        576
      ],
      "parameters": {
        "color": 5,
        "width": 1604,
        "height": 412,
        "content": "## Lane 2: Receive & Send Approved Replies\nThis lane handles the submission from the dashboard, sends the email, and updates the inbox status.\n\n1. **Webhook - Receive Approval:** Listens for the POST request containing the approved text and message ID.\n   *(Note: Set the workflow to Active for this webhook URL to work)*\n2. **Extract Reply Data:** Parses the incoming payload fields.\n3. **Reply to a message:** Sends the approved text as a threaded reply via Gmail.\n4. **Mark a message as read:** Marks the original message as read in Gmail, removing it from future draft generations."
      },
      "typeVersion": 1
    },
    {
      "id": "60c542e3-54cb-4807-8a3d-d5a4d9e66955",
      "name": "Filter No Reply Emails",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        352
      ],
      "parameters": {
        "jsCode": "const filtered = $input.all().filter(item => {\n  const json = item.json || {};\n\n  const from =\n    (json.from ||\n     json.From ||\n     json.sender ||\n     \"\").toLowerCase();\n\n  // Exclude common no-reply patterns\n  const blockedPatterns = [\n    \"no-reply\",\n    \"noreply\",\n    \"do-not-reply\",\n    \"donotreply\"\n  ];\n\n  return !blockedPatterns.some(pattern =>\n    from.includes(pattern)\n  );\n});\n\nreturn filtered;"
      },
      "typeVersion": 2
    },
    {
      "id": "05c18cad-9faf-4ddf-96f1-0643b9bdbebf",
      "name": "Generate AI Draft Reply",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        1056,
        352
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5",
          "cachedResultName": "GPT-5"
        },
        "options": {},
        "responses": {
          "values": [
            {
              "content": "=Email Subject: {{ $json.Subject }},\nEmail Body: {{ $json.snippet }}\nEmail From: {{ $json.From }}\nEmail To: {{ $json.To }}\n\nCan you please just return the email reply no subject."
            }
          ]
        },
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "f65d636a-1837-45f1-a4b9-0d2cd8e34ff6",
      "name": "Format Email Drafts",
      "type": "n8n-nodes-base.code",
      "position": [
        1392,
        352
      ],
      "parameters": {
        "jsCode": "// Iterate on each input item.\n// This code is meant for an n8n Code node.\n\nconst items = $('Filter No Reply Emails').all();\nconst aiItems = $('Generate AI Draft Reply').all();\n\nreturn items.map((item, index) => {\n  const data = item.json;\n  const aiDraft = aiItems[index].json?.output?.[0]?.content?.[0]?.text;\n  \n  const email = {\n    from: data.From,\n    fromName: data.From,\n    subject: data.Subject,\n    body: data.snippet,\n    date: data.internalDate,\n    messageId: data.id,\n    threadId: data.threadId,\n    aiDraft: aiDraft,\n  };\n\n  return {\n    json: email,\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "f92e4530-b581-465a-a735-09d837eb90c9",
      "name": "Build Approval Page",
      "type": "n8n-nodes-base.code",
      "position": [
        1616,
        352
      ],
      "parameters": {
        "jsCode": "// Aggregates all email items into a single inbox-style HTML page.\n\nconst items = $input.all();\n\n// Normalize incoming emails safely\nconst emails = items.map(item => {\n  const json = item.json || {};\n\n  return {\n    from: json.from || \"\",\n    fromName: json.fromName || json.from || \"Unknown Sender\",\n    subject: json.subject || \"(No Subject)\",\n    body: json.body || \"\",\n    date: json.date || Date.now(),\n    messageId: json.messageId || Math.random().toString(36).slice(2),\n    threadId: json.threadId || \"\",\n    aiDraft: json.aiDraft || \"\",\n  };\n});\n\n// \u2699\ufe0f CONFIGURE: Set your n8n instance base URL here.\nconst N8N_BASE_URL = \"http://localhost:5678\";\n\n// Use \"webhook-test\" while testing, \"webhook\" in production.\nconst webhookPrefix = \"webhook-test\";\n\nconst SUBMIT_URL =\n  `${N8N_BASE_URL}/${webhookPrefix}/email-reply-submit`;\n\nconst safeEmails = JSON.stringify(emails)\n  .replace(/<\\/script>/gi, \"<\\\\/script>\");\n\nlet html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>AI Email Reply Assistant</title>\n\n  <script src=\"https://cdn.tailwindcss.com\"></script>\n  <script defer src=\"https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js\"></script>\n\n  <link href=\"https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n\n  <style>\n    body {\n      font-family: 'Plus Jakarta Sans', sans-serif;\n      background: #070b14;\n    }\n\n    [x-cloak] {\n      display: none !important;\n    }\n\n    .glass-card {\n      background: rgba(255,255,255,0.04);\n      backdrop-filter: blur(20px);\n      border: 1px solid rgba(255,255,255,0.08);\n    }\n\n    .email-item {\n      transition: all 0.2s ease;\n      cursor: pointer;\n      border-left: 3px solid transparent;\n    }\n\n    .email-item:hover {\n      background: rgba(255,255,255,0.05);\n    }\n\n    .email-item.active {\n      background: rgba(99,102,241,0.12);\n      border-left-color: #6366f1;\n    }\n\n    .reply-textarea {\n      background: rgba(255,255,255,0.06);\n      border: 1px solid rgba(255,255,255,0.12);\n      color: white;\n      resize: vertical;\n    }\n\n    .reply-textarea:focus {\n      outline: none;\n      border-color: #6366f1;\n    }\n\n    .send-btn {\n      background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);\n    }\n\n    .send-btn:hover {\n      opacity: 0.95;\n    }\n\n    .email-body-text {\n      white-space: pre-wrap;\n      word-wrap: break-word;\n    }\n\n    /* Slide-out animation for removed emails */\n    .email-remove {\n      animation: slideOut 0.25s ease forwards;\n    }\n\n    @keyframes slideOut {\n      to {\n        opacity: 0;\n        transform: translateX(-100%);\n        max-height: 0;\n        padding: 0;\n        margin: 0;\n        overflow: hidden;\n      }\n    }\n  </style>\n</head>\n\n<body class=\"min-h-screen text-white\">\n\n<div\n  x-data=\"emailApp()\"\n  x-cloak\n  class=\"min-h-screen flex flex-col\"\n>\n\n  <!-- Header -->\n  <div class=\"border-b border-white/10 px-6 py-4 flex justify-between items-center\">\n    <div>\n      <h1 class=\"text-xl font-bold\">\n        AI Email Reply Assistant\n      </h1>\n\n      <p class=\"text-slate-400 text-sm\">\n        Review and approve AI-generated replies\n      </p>\n    </div>\n\n    <div class=\"flex gap-3\">\n      <div class=\"px-3 py-1 rounded-full bg-yellow-500/20 text-yellow-300 text-sm\">\n        <span x-text=\"emails.length\"></span> Pending\n      </div>\n\n      <div class=\"px-3 py-1 rounded-full bg-green-500/20 text-green-300 text-sm\">\n        <span x-text=\"sentCount\"></span> Sent\n      </div>\n    </div>\n  </div>\n\n  <div class=\"flex flex-1 overflow-hidden\">\n\n    <!-- Sidebar -->\n    <div class=\"w-80 border-r border-white/10 overflow-y-auto\">\n\n      <!-- Empty state -->\n      <div\n        x-show=\"emails.length === 0\"\n        class=\"p-8 text-center text-slate-500 text-sm\"\n      >\n        <p class=\"text-2xl mb-2\">\ud83c\udf89</p>\n        <p>All emails handled!</p>\n      </div>\n\n      <template x-for=\"(email, idx) in emails\" :key=\"email.messageId\">\n\n        <div\n          @click=\"selectEmail(idx)\"\n          class=\"email-item p-4 border-b border-white/5\"\n          :class=\"{ active: selectedIdx === idx }\"\n          :id=\"'email-' + email.messageId\"\n        >\n\n          <div class=\"flex justify-between items-start mb-1\">\n            <p\n              class=\"font-semibold text-sm truncate\"\n              x-text=\"email.fromName\"\n            ></p>\n          </div>\n\n          <p\n            class=\"text-slate-300 text-xs truncate mb-1\"\n            x-text=\"email.subject\"\n          ></p>\n\n          <p\n            class=\"text-slate-500 text-xs truncate\"\n            x-text=\"(email.body || '').substring(0, 70)\"\n          ></p>\n\n        </div>\n\n      </template>\n    </div>\n\n    <!-- Content -->\n    <div class=\"flex-1 overflow-y-auto p-6\">\n\n      <div\n        x-show=\"selectedIdx === null\"\n        class=\"h-full flex items-center justify-center text-slate-500\"\n      >\n        <div x-show=\"emails.length > 0\">Select an email</div>\n        <div x-show=\"emails.length === 0\">No more emails to review</div>\n      </div>\n\n      <div\n        x-show=\"selectedEmail\"\n        class=\"grid grid-cols-1 lg:grid-cols-2 gap-6\"\n      >\n\n        <!-- Original -->\n        <div class=\"glass-card rounded-2xl p-6\">\n\n          <h2 class=\"font-semibold text-lg mb-4\">\n            Original Email\n          </h2>\n\n          <div class=\"space-y-2 text-sm mb-6\">\n            <p>\n              <span class=\"text-slate-400\">From:</span>\n              <span x-text=\"selectedEmail?.fromName\"></span>\n            </p>\n\n            <p>\n              <span class=\"text-slate-400\">Subject:</span>\n              <span x-text=\"selectedEmail?.subject\"></span>\n            </p>\n          </div>\n\n          <div\n            class=\"email-body-text text-slate-300 text-sm\"\n            x-text=\"selectedEmail?.body\"\n          ></div>\n        </div>\n\n        <!-- Reply -->\n        <div class=\"glass-card rounded-2xl p-6 flex flex-col\">\n\n          <h2 class=\"font-semibold text-lg mb-4\">\n            AI Draft Reply\n          </h2>\n\n          <textarea\n            x-model=\"replyText\"\n            rows=\"12\"\n            class=\"reply-textarea w-full rounded-xl p-4 text-sm flex-1\"\n          ></textarea>\n\n          <button\n            @click=\"sendReply\"\n            :disabled=\"sending\"\n            class=\"send-btn mt-4 py-3 rounded-xl font-semibold\"\n          >\n            <span x-show=\"!sending\">\n              Approve & Send\n            </span>\n\n            <span x-show=\"sending\">\n              Sending...\n            </span>\n          </button>\n\n          <div\n            x-show=\"error\"\n            class=\"mt-3 text-red-400 text-sm\"\n            x-text=\"error\"\n          ></div>\n\n        </div>\n\n      </div>\n    </div>\n  </div>\n</div>\n\n<script>\nfunction emailApp() {\n\n  const EMAILS = %%EMAILS_DATA%%;\n  const SUBMIT_URL = %%SUBMIT_URL%%;\n\n  return {\n\n    emails: EMAILS.map(e => ({ ...e })),\n\n    selectedIdx: null,\n    replyText: '',\n    sending: false,\n    error: null,\n    sentCount: 0,\n\n    get selectedEmail() {\n      return this.selectedIdx !== null\n        ? this.emails[this.selectedIdx]\n        : null;\n    },\n\n    selectEmail(idx) {\n      this.selectedIdx = idx;\n      this.replyText = this.emails[idx].aiDraft || '';\n      this.error = null;\n    },\n\n    async sendReply() {\n\n      const email = this.emails[this.selectedIdx];\n      const currentIdx = this.selectedIdx;\n\n      this.sending = true;\n      this.error = null;\n\n      try {\n\n        const res = await fetch(SUBMIT_URL, {\n          method: 'POST',\n          headers: {\n            'Content-Type': 'application/json'\n          },\n          body: JSON.stringify({\n            approvedReply: this.replyText,\n            originalFrom: email.from,\n            originalSubject: email.subject,\n            messageId: email.messageId,\n            threadId: email.threadId\n          })\n        });\n\n        if (!res.ok) {\n          throw new Error('Failed');\n        }\n\n        // \u2500\u2500 Animate out then remove from array \u2500\u2500\n        const el = document.getElementById('email-' + email.messageId);\n        if (el) {\n          el.classList.add('email-remove');\n          // Wait for animation to finish before splicing\n          await new Promise(resolve => setTimeout(resolve, 260));\n        }\n\n        this.sentCount++;\n        this.emails.splice(currentIdx, 1);\n\n        // Auto-select next email, or previous if we were at the end\n        if (this.emails.length === 0) {\n          this.selectedIdx = null;\n          this.replyText = '';\n        } else {\n          const nextIdx = currentIdx < this.emails.length\n            ? currentIdx\n            : currentIdx - 1;\n          this.selectEmail(nextIdx);\n        }\n\n      } catch (e) {\n\n        this.error = 'Failed to send reply';\n\n      } finally {\n\n        this.sending = false;\n      }\n    }\n  };\n}\n</script>\n\n</body>\n</html>`;\n\nhtml = html.replace(\n  \"%%EMAILS_DATA%%\",\n  safeEmails\n);\n\nhtml = html.replace(\n  \"%%SUBMIT_URL%%\",\n  JSON.stringify(SUBMIT_URL)\n);\n\nreturn [\n  {\n    json: {\n      html\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "843abcb3-3ce4-4c56-bdb6-d429b5e03fe6",
      "name": "Reply to a message",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1184,
        816
      ],
      "parameters": {
        "message": "={{ $json.approvedReply }}",
        "options": {},
        "emailType": "text",
        "messageId": "={{ $json.messageId }}",
        "operation": "reply"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "902e6dab-22d3-4351-b944-23dfe4d9bc65",
      "name": "Mark a message as read",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1584,
        816
      ],
      "parameters": {
        "messageId": "={{ $('Extract Reply Data').item.json.messageId }}",
        "operation": "markAsRead"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "dfabf0cb-2e1e-4250-928c-09d83895ae5f",
  "connections": {
    "Get many messages": {
      "main": [
        [
          {
            "node": "Filter No Reply Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Reply Data": {
      "main": [
        [
          {
            "node": "Reply to a message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply to a message": {
      "main": [
        [
          {
            "node": "Mark a message as read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Approval Page": {
      "main": [
        [
          {
            "node": "Upsert Approval Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Email Drafts": {
      "main": [
        [
          {
            "node": "Build Approval Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter No Reply Emails": {
      "main": [
        [
          {
            "node": "Generate AI Draft Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate AI Draft Reply": {
      "main": [
        [
          {
            "node": "Format Email Drafts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - Receive Approval": {
      "main": [
        [
          {
            "node": "Extract Reply Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When clicking 'Execute workflow'": {
      "main": [
        [
          {
            "node": "Get many messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}