595 lines
32 KiB
JSON
595 lines
32 KiB
JSON
|
|
{
|
||
|
|
"name": "WFDASH — Quote Email Monitor (v3.1 agentic + PDF text)",
|
||
|
|
"nodes": [
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"format": "resolved",
|
||
|
|
"options": {
|
||
|
|
"customEmailConfig": "[\"UNSEEN\"]",
|
||
|
|
"forceReconnect": 60,
|
||
|
|
"trackLastMessageId": true
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"id": "2ad1f663-9442-43d1-a9f2-01515f9c504a",
|
||
|
|
"name": "IMAP Quote Mailbox",
|
||
|
|
"type": "n8n-nodes-base.emailReadImap",
|
||
|
|
"typeVersion": 2.1,
|
||
|
|
"position": [
|
||
|
|
0,
|
||
|
|
96
|
||
|
|
],
|
||
|
|
"credentials": {
|
||
|
|
"imap": {
|
||
|
|
"id": "OxBkyAvgByqMElJG",
|
||
|
|
"name": "IMAP account"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"assignments": {
|
||
|
|
"assignments": [
|
||
|
|
{
|
||
|
|
"id": "srv-1",
|
||
|
|
"name": "mcp server",
|
||
|
|
"value": "https://zolau-196-39-76-18.run.pinggy-free.link",
|
||
|
|
"type": "string"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"id": "srv-2",
|
||
|
|
"name": "agent_api_key",
|
||
|
|
"value": "123456",
|
||
|
|
"type": "string"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"includeOtherFields": true,
|
||
|
|
"options": {}
|
||
|
|
},
|
||
|
|
"id": "36f45465-7ae9-4787-afbc-04adff0ac01a",
|
||
|
|
"name": "Edit Fields",
|
||
|
|
"type": "n8n-nodes-base.set",
|
||
|
|
"typeVersion": 3.4,
|
||
|
|
"position": [
|
||
|
|
224,
|
||
|
|
96
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"mode": "runOnceForEachItem",
|
||
|
|
"jsCode": "const item = $json;\n\nfunction asArray(v) {\n if (Array.isArray(v)) return v;\n if (v && typeof v === 'object') {\n if ('item' in v) return [v.item];\n if ('item0' in v) {\n const out = [];\n let i = 0;\n while (('item' + i) in v) { out.push(v['item' + i]); i++; }\n if (out.length) return out;\n }\n }\n return null;\n}\n\nfunction extractEmail(from) {\n if (!from) return '';\n if (typeof from === 'string') {\n const m = from.match(/<([^>]+)>/);\n return (m ? m[1] : from).toLowerCase().trim();\n }\n if (typeof from === 'object') {\n const arr = asArray(from.value);\n if (arr && arr.length > 0) {\n const v = arr[0];\n if (typeof v === 'string') return v.toLowerCase();\n if (v && typeof v === 'object' && v.address) return v.address.toLowerCase();\n }\n if (typeof from.address === 'string') return from.address.toLowerCase();\n if (typeof from.text === 'string') {\n const m = from.text.match(/<([^>]+)>/);\n if (m) return m[1].toLowerCase();\n }\n }\n return '';\n}\n\nfunction extractName(from) {\n if (!from) return '';\n if (typeof from === 'object') {\n if (typeof from.name === 'string' && from.name) return from.name;\n const arr = asArray(from.value);\n if (arr && arr[0]) {\n if (typeof arr[0] === 'object' && arr[0].name) return arr[0].name;\n }\n }\n if (typeof from === 'string') {\n const m = from.match(/^([^<]+)</);\n return m ? m[1].trim().replace(/^\"|\"$/g, '') : '';\n }\n return '';\n}\n\nfunction extractMessageId(field) {\n if (!field) return '';\n if (typeof field === 'string') return field.replace(/^[<]|[>]$/g, '');\n if (typeof field === 'object') {\n if (typeof field.value === 'string') return field.value.replace(/^[<]|[>]$/g, '');\n const arr = asArray(field.value);\n if (arr && typeof arr[0] === 'string') return arr[0].replace(/^[<]|[>]$/g, '');\n if (typeof field.messageId === 'string') return field.messageId.replace(/^[<]|[>]$/g, '');\n }\n return '';\n}\n\nconst fromEmail = extractEmail(item.from);\nconst fromName = extractName(item.from);\nconst messageId = extractMessageId(item.messageId);\nconst inReplyTo = extractMessageId(item.inReplyTo);\nconst mailbox = String(item.mailbox || 'INBOX');\nconst uid = String(item.uid || '');\n\nconst PLAIN_LIMIT = 50 * 1024;\nconst HTML_LIMIT = 200 * 1024;\n\nconst attachmentsMeta = [];\nconst bin = $binary || {};\nfor (const key of Object.keys(bin)) {\n const b = bin[key];\n if (!b) continue;\n attachmentsMeta.push({\n binary_key: String(key),\n filename: String(b.fileName || 'attachment'),\n content_type: String(b.mimeType || 'application/octet-stream'),\n size: Number(b.fileSize || 0)\n });\n}\n\nconst rawSubject = String(item.subject || '');\nconst subject = rawSubject.trim() || '(no subject)';\nconst quoteNumberMatch = subject.match(/Q-\\\\d{4}-\\\\d{2}-\\\\d{3}/);\nconst isReply = inReplyTo.length > 0 || !!quoteNumberMatch;\n\nconst rawBodyPlain = String(item.textPlain || item.text || '');\nconst trimmedPlain = rawBodyPlain.trim();\nconst bodyPlain = trimmedPlain.length > PLAIN_LIMIT\n ? trimmedPlain.slice(0, PLAIN_LIMIT) + '\\\\n\\\\n[... body truncated at 50KB; full body in IMAP mailbox ...]'\n : (trimmedPlain || '(no body)');\n\nconst rawBodyHtml = String(item.textHtml || '');\nconst trimmedHtml = rawBodyHtml.trim();\nconst bodyHtml = trimmedHtml.length > HTML_LIMIT\n ? trimmedHtml.slice(0, HTML_LIMIT) + '\\\\n<!-- body truncated at 200KB; full body in IMAP mailbox -->'\n : trimmedHtml;\n\nreturn {\n json: {\n from_email: fromEmail,\n from_name: fromName,\n subject: subject,\n message_id: messageId,\n in_reply_to: inReplyTo,\n body_plain: bodyPlain,\n body_html: bodyHtml,\n received_at: String(item.date || $now.toISO()),\n attachments: attachmentsMeta,\n has_attachments: attachmentsMeta.length > 0,\n is_reply: isReply,\n route: isReply ? 'reply' : 'new',\n uid: uid,\n mailbox: mailbox\n },\n binary: $binary || {}\n};"
|
||
|
|
},
|
||
|
|
"id": "73a4abec-bb4f-423c-882f-fac1d7ff2d3f",
|
||
|
|
"name": "Parse Email",
|
||
|
|
"type": "n8n-nodes-base.code",
|
||
|
|
"typeVersion": 2,
|
||
|
|
"position": [
|
||
|
|
448,
|
||
|
|
96
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"conditions": {
|
||
|
|
"options": {
|
||
|
|
"caseSensitive": true,
|
||
|
|
"leftValue": "",
|
||
|
|
"typeValidation": "strict",
|
||
|
|
"version": 3
|
||
|
|
},
|
||
|
|
"conditions": [
|
||
|
|
{
|
||
|
|
"id": "no-noreply",
|
||
|
|
"leftValue": "={{ $json.from_email }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "notContains"
|
||
|
|
},
|
||
|
|
"rightValue": "noreply"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"id": "no-donotreply",
|
||
|
|
"leftValue": "={{ $json.from_email }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "notContains"
|
||
|
|
},
|
||
|
|
"rightValue": "donotreply"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"id": "no-mailer-daemon",
|
||
|
|
"leftValue": "={{ $json.from_email }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "notContains"
|
||
|
|
},
|
||
|
|
"rightValue": "mailer-daemon"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"id": "no-postmaster",
|
||
|
|
"leftValue": "={{ $json.from_email }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "notContains"
|
||
|
|
},
|
||
|
|
"rightValue": "postmaster"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"id": "no-ooo",
|
||
|
|
"leftValue": "={{ $json.subject }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "notContains"
|
||
|
|
},
|
||
|
|
"rightValue": "out of office"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"id": "no-auto-reply",
|
||
|
|
"leftValue": "={{ $json.subject }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "notContains"
|
||
|
|
},
|
||
|
|
"rightValue": "automatic reply"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"id": "no-vacation",
|
||
|
|
"leftValue": "={{ $json.subject }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "notContains"
|
||
|
|
},
|
||
|
|
"rightValue": "vacation"
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"combinator": "and"
|
||
|
|
},
|
||
|
|
"options": {}
|
||
|
|
},
|
||
|
|
"id": "48c9dc80-6870-4efb-ba42-475e07bdfa5e",
|
||
|
|
"name": "Drop Blacklist / Auto-replies",
|
||
|
|
"type": "n8n-nodes-base.if",
|
||
|
|
"typeVersion": 2.3,
|
||
|
|
"position": [
|
||
|
|
672,
|
||
|
|
96
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"conditions": {
|
||
|
|
"options": {
|
||
|
|
"caseSensitive": true,
|
||
|
|
"leftValue": "",
|
||
|
|
"typeValidation": "strict",
|
||
|
|
"version": 2
|
||
|
|
},
|
||
|
|
"conditions": [
|
||
|
|
{
|
||
|
|
"id": "is-reply-route",
|
||
|
|
"leftValue": "={{ $json.route }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "equals"
|
||
|
|
},
|
||
|
|
"rightValue": "reply"
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"combinator": "and"
|
||
|
|
},
|
||
|
|
"options": {}
|
||
|
|
},
|
||
|
|
"id": "5d95b51b-584e-464e-a9ef-d728bf94e265",
|
||
|
|
"name": "Is Reply?",
|
||
|
|
"type": "n8n-nodes-base.if",
|
||
|
|
"typeVersion": 2.2,
|
||
|
|
"position": [
|
||
|
|
896,
|
||
|
|
96
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"method": "POST",
|
||
|
|
"url": "={{ $('Edit Fields').item.json['mcp server'] }}/api/mcp",
|
||
|
|
"authentication": "genericCredentialType",
|
||
|
|
"genericAuthType": "httpBearerAuth",
|
||
|
|
"sendHeaders": true,
|
||
|
|
"headerParameters": {
|
||
|
|
"parameters": [
|
||
|
|
{
|
||
|
|
"name": "Content-Type",
|
||
|
|
"value": "application/json"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "Accept",
|
||
|
|
"value": "application/json"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"sendBody": true,
|
||
|
|
"specifyBody": "json",
|
||
|
|
"jsonBody": "={\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"quotes.append_reply\",\"arguments\":{\"from_email\":\"{{ $json.from_email }}\",\"from_name\":\"{{ $json.from_name }}\",\"subject\":\"{{ $json.subject }}\",\"body_plain\":\"{{ $json.body_plain }}\",\"body_html\":\"{{ $json.body_html }}\",\"message_id\":\"{{ $json.message_id }}\",\"in_reply_to\":\"{{ $json.in_reply_to }}\",\"received_at\":\"{{ $json.received_at }}\"}}}",
|
||
|
|
"options": {
|
||
|
|
"response": {
|
||
|
|
"response": {
|
||
|
|
"responseFormat": "json"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"id": "225cee5b-0bc2-4583-828e-f399254961c3",
|
||
|
|
"name": "MCP: quotes.append_reply",
|
||
|
|
"type": "n8n-nodes-base.httpRequest",
|
||
|
|
"typeVersion": 4.4,
|
||
|
|
"position": [
|
||
|
|
1120,
|
||
|
|
0
|
||
|
|
],
|
||
|
|
"credentials": {
|
||
|
|
"httpBearerAuth": {
|
||
|
|
"id": "aRv6op6xIgyTm9Vh",
|
||
|
|
"name": "Bearer Auth account"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"mode": "runOnceForEachItem",
|
||
|
|
"jsCode": "const text = $json.result?.content?.[0]?.text || $json.content?.[0]?.text || '';\nlet parsed = {};\ntry { parsed = JSON.parse(text); } catch (e) { parsed = { matched_by: 'none' }; }\n\nreturn {\n json: {\n matched_by: parsed.matched_by || 'none',\n quote_id: parsed.quote?.id || null,\n reply_id: parsed.reply?.id || null,\n uid: $('Parse Email').item.json.uid,\n mailbox: $('Parse Email').item.json.mailbox\n }\n};"
|
||
|
|
},
|
||
|
|
"id": "288acf0c-a8a9-44ba-8120-96b8ef37a166",
|
||
|
|
"name": "Parse MCP Reply Result",
|
||
|
|
"type": "n8n-nodes-base.code",
|
||
|
|
"typeVersion": 2,
|
||
|
|
"position": [
|
||
|
|
1344,
|
||
|
|
0
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"conditions": {
|
||
|
|
"options": {
|
||
|
|
"caseSensitive": true,
|
||
|
|
"leftValue": "",
|
||
|
|
"typeValidation": "strict",
|
||
|
|
"version": 2
|
||
|
|
},
|
||
|
|
"conditions": [
|
||
|
|
{
|
||
|
|
"id": "is-matched",
|
||
|
|
"leftValue": "={{ $json.matched_by }}",
|
||
|
|
"operator": {
|
||
|
|
"type": "string",
|
||
|
|
"operation": "notEquals"
|
||
|
|
},
|
||
|
|
"rightValue": "none"
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"combinator": "and"
|
||
|
|
},
|
||
|
|
"options": {}
|
||
|
|
},
|
||
|
|
"id": "3482acab-ce3e-4854-afac-23f9fcaa144b",
|
||
|
|
"name": "Route Reply Result",
|
||
|
|
"type": "n8n-nodes-base.if",
|
||
|
|
"typeVersion": 2.2,
|
||
|
|
"position": [
|
||
|
|
1632,
|
||
|
|
0
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"mode": "runOnceForEachItem",
|
||
|
|
"jsCode": "// Carry Email Ctx — passes the full parsed email through to the\n// next nodes (Split Binary into Items) AND explicitly preserves the\n// upstream binary so the eventual upload_attachment_tool subnode can\n// read attachments by key.\nconst __bin = $binary || {};\nreturn { json: Object.assign({}, $json || {}), binary: __bin };"
|
||
|
|
},
|
||
|
|
"id": "a732a9d0-47ac-4fa8-b291-45dc7ad31fa2",
|
||
|
|
"name": "Carry Email Ctx",
|
||
|
|
"type": "n8n-nodes-base.code",
|
||
|
|
"typeVersion": 2,
|
||
|
|
"position": [
|
||
|
|
1120,
|
||
|
|
192
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"jsCode": "// Split Binary into Items\n// Take the single email item from upstream, emit one item per binary\n// entry. Each emitted item has:\n// - json: { binary_key, filename, content_type, size, ...email context }\n// - binary: { attachment: <single binary> }\n// The fixed key \"attachment\" lets the next node's binaryPropertyName\n// (also \"attachment\") work uniformly for every split item.\n// The original binary key (attachment_0, attachment_1, ...) is preserved\n// on $json.binary_key so the aggregate step can restore it under the\n// same key for the upload tool.\n//\n// Mode: runOnceForAllItems — process the (single) input item once\n// and return multiple output items. runOnceForEachItem cannot be\n// used to emit N items from one input: its validator expects a\n// single item return and treats an array return as a malformed\n// item (the error \"A 'json' property isn't an object [item 0]\").\nconst inputItems = $input.all();\nconst out = [];\nfor (const input of inputItems) {\n const email = input.json || {};\n const bin = input.binary || {};\n for (const key of Object.keys(bin)) {\n const b = bin[key];\n if (!b) continue;\n out.push({\n json: {\n binary_key: key,\n filename: b.fileName || 'attachment',\n content_type: b.mimeType || 'application/octet-stream',\n size: b.fileSize || 0,\n // Email context for the aggregate step\n _email_from_email: email.from_email || '',\n _email_from_name: email.from_name || '',\n _email_subject: email.subject || '',\n _email_body_plain: email.body_plain || '',\n _email_body_html: email.body_html || '',\n _email_message_id: email.message_id || '',\n _email_in_reply_to: email.in_reply_to || '',\n _email_received_at: email.received_at || '',\n _email_is_reply: !!email.is_reply,\n _email_route: email.route || 'new',\n _email_uid: email.uid || '',\n _email_mailbox: email.mailbox || 'INBOX',\n _email_mcp_server: email['mcp server'] || '',\n _email_agent_api_key: email.agent_api_key || ''\n },\n binary: { attachment: b }\n });\n }\n}\nif (out.length === 0) {\n // No attachments: emit one empty placeholder so the chain continues.\n // Aggregate handles 0 items, but we want at least one item flowing.\n out.push({ json: { binary_key: null, filename: '', content_type: '', size: 0 }, binary: {} });\n}\nreturn out;"
|
||
|
|
},
|
||
|
|
"id": "e2faee52-1cf3-4af4-931d-8dbd760371b6",
|
||
|
|
"name": "Split Binary into Items",
|
||
|
|
"type": "n8n-nodes-base.code",
|
||
|
|
"typeVersion": 2,
|
||
|
|
"position": [
|
||
|
|
1344,
|
||
|
|
192
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"operation": "pdf",
|
||
|
|
"binaryPropertyName": "attachment",
|
||
|
|
"options": {
|
||
|
|
"joinPages": true
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"id": "7ce098f9-aa6b-4def-919d-1607e79baed9",
|
||
|
|
"name": "Extract from File (PDF)",
|
||
|
|
"type": "n8n-nodes-base.extractFromFile",
|
||
|
|
"typeVersion": 1.1,
|
||
|
|
"position": [
|
||
|
|
1568,
|
||
|
|
192
|
||
|
|
],
|
||
|
|
"onError": "continueRegularOutput"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"jsCode": "// Aggregate + Enrich Attachments\nconst items = $input.all();\nif (items.length === 0) {\n return { json: { attachments: [], has_attachments: false }, binary: {} };\n}\n\nconst first = items[0].json;\nconst MAX_TEXT_PER_ATTACHMENT = 10 * 1024;\nconst attachments = [];\nconst binary = {};\n\nfor (const item of items) {\n const origKey = item.json.binary_key;\n const raw = (item.json && typeof item.json.text === 'string') ? item.json.text : '';\n const truncated = raw.length > MAX_TEXT_PER_ATTACHMENT\n ? raw.slice(0, MAX_TEXT_PER_ATTACHMENT) + '\\n\\n[... PDF text truncated at 10KB ...]'\n : raw;\n attachments.push({\n binary_key: origKey,\n filename: item.json.filename || 'attachment',\n content_type: item.json.content_type || 'application/octet-stream',\n size: item.json.size || 0,\n extracted_text: truncated,\n num_pages: item.json.numpages || undefined\n });\n if (item.binary && item.binary.attachment) {\n binary[origKey] = item.binary.attachment;\n }\n}\n\nreturn {\n json: {\n from_email: first._email_from_email,\n from_name: first._email_from_name,\n subject: first._email_subject,\n body_plain: first._email_body_plain,\n body_html: first._email_body_html,\n message_id: first._email_message_id,\n in_reply_to: first._email_in_reply_to,\n received_at: first._email_received_at,\n is_reply: !!first._email_is_reply,\n route: first._email_route,\n uid: first._email_uid,\n mailbox: first._email_mailbox,\n 'mcp server': first._email_mcp_server,\n agent_api_key: first._email_agent_api_key,\n attachments: attachments,\n has_attachments: attachments.length > 0\n },\n binary: binary\n};"
|
||
|
|
},
|
||
|
|
"id": "61fbc910-f6a9-4d95-9ad3-556d04fa53df",
|
||
|
|
"name": "Aggregate + Enrich",
|
||
|
|
"type": "n8n-nodes-base.code",
|
||
|
|
"typeVersion": 2,
|
||
|
|
"position": [
|
||
|
|
1792,
|
||
|
|
192
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"promptType": "define",
|
||
|
|
"text": "=From: {{ $json.from_name }} <{{ $json.from_email }}>\nSubject: {{ $json.subject }}\nMessage-ID: {{ $json.message_id }}\nDate: {{ $json.received_at }}\nAttachments: {{ ($json.attachments || []).length }} file(s):\n{{ JSON.stringify(($json.attachments || []).map(function(a) { return { binary_key: a.binary_key, filename: a.filename, content_type: a.content_type, size: a.size, extracted_text_preview: a.extracted_text ? a.extracted_text.slice(0, 2000) : '(no text extracted)' }; }), null, 2) }}\n\nBody:\n{{ $json.body_plain || $json.body_html || '(no body)' }}",
|
||
|
|
"options": {
|
||
|
|
"systemMessage": "You are a purchase-order parser for WFDASH.\n\nYou have two tools:\n- create_quote_tool: create a new quote row in PocketBase. Use it once per inbound email, after extracting line items.\n- upload_attachment_tool: upload a single attachment to an existing quote. Use it once per attachment in the email, AFTER create_quote_tool returns a quote_id.\n\nWorkflow:\n1. Read the email body (in your user message). For each attachment, also look at the extracted_text_preview field — that is the first 2KB of the PDF text. If the email body is short or empty, the line items are almost certainly in the PDF.\n2. Extract the buyer/end-customer company name (company_hint). Empty string if unclear.\n3. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.\n4. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase.\n5. Capture the quote_id from the tool result.\n6. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.\n7. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=...\n\nImportant:\n- Empty body / no items is OK — create the quote anyway with an empty extracted_items array.\n- attachments metadata only (no base64) — the binary lives in the workflow's binary store; only upload_attachment_tool knows how to read it.",
|
||
|
|
"maxIterations": 20,
|
||
|
|
"returnIntermediateSteps": false,
|
||
|
|
"passthroughBinaryImages": false
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"id": "7b847e86-5ede-4cd6-a5ea-a506dc187d5b",
|
||
|
|
"name": "AI Agent: Create Quote From Email",
|
||
|
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||
|
|
"typeVersion": 3.1,
|
||
|
|
"position": [
|
||
|
|
2080,
|
||
|
|
192
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"model": "Minimax-M2.7",
|
||
|
|
"options": {
|
||
|
|
"hideThinking": true,
|
||
|
|
"temperature": 0.1,
|
||
|
|
"timeout": 60000,
|
||
|
|
"maxRetries": 2
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"id": "f3605bf8-39a5-4aad-8af7-a05e0ba818d9",
|
||
|
|
"name": "Minimax Chat Model",
|
||
|
|
"type": "@n8n/n8n-nodes-langchain.lmChatMinimax",
|
||
|
|
"typeVersion": 1,
|
||
|
|
"position": [
|
||
|
|
2016,
|
||
|
|
416
|
||
|
|
],
|
||
|
|
"credentials": {
|
||
|
|
"minimaxApi": {
|
||
|
|
"id": "kn97KHCrkyTm3v2g",
|
||
|
|
"name": "MiniMax account"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"description": "Create a new quote in PocketBase from the inbound email. Use this first, after extracting line items from the body or the PDF attachment text. Call once per inbound email.\n\nThe tool reads the email metadata from the upstream workflow context automatically. The LLM only needs to pass the two fields the agent has just produced:\n- company_hint: string (empty string if unclear)\n- extracted_items: array of {description, quantity, unit?, notes?} (verbatim; empty array if no items)\n\nDo NOT pass from_email, from_name, subject, body_*, message_id, in_reply_to, or received_at from the LLM side.",
|
||
|
|
"jsCode": "// create_quote_tool\nlet __q = query;\nif (typeof __q === 'string') {\n try { __q = JSON.parse(__q); } catch (e) { __q = {}; }\n}\nif (!__q || typeof __q !== 'object') __q = {};\nlet a = __q;\nif (typeof a.input === 'string') {\n try { a = JSON.parse(a.input); } catch (e) { a = __q; }\n}\nif (!a || typeof a !== 'object') a = {};\n\nconst __upstream = $input.first() || {};\nconst __upstreamJson = __upstream.json || {};\n\nconst args = {\n from_email: String(a.from_email || __upstreamJson.from_email || ''),\n from_name: String(a.from_name || __upstreamJson.from_name || ''),\n subject: String(a.subject || __upstreamJson.subject || '(no subject)'),\n body_plain: String(a.body_plain || __upstreamJson.body_plain || ''),\n body_html: String(a.body_html || __upstreamJson.body_html || ''),\n message_id: String(a.message_id || __upstreamJson.message_id || ''),\n in_reply_to: String(a.in_reply_to || __upstreamJson.in_reply_to || ''),\n received_at: String(a.received_at || __upstreamJson.received_at || ''),\n company_hint: String(a.company_hint || __upstreamJson.company_hint || ''),\n extracted_items: Array.isArray(a.extracted_items) ? a.extracted_items : [],\n attachments: []\n};\n\nconst bearer = String(__upstreamJson.agent_api_key || '');\nconst mcpBase = String(__upstreamJson['mcp server'] || __upstreamJson.mcp_url || 'https://zolau-196-39-76-18.run.pinggy-free.link');\nconst mcpUrl = mcpBase.replace(/\\/$/, '') + '/api/mcp';\nconst authToken = bearer || '123456';\n\nlet res;\ntry {\n res = await this.helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + authToken },\n body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'quotes.create_from_email', arguments: args } },\n json: true, timeout: 60000\n });\n} catch (e) {\n return 'error: http threw: ' + (e && e.message ? e.message : e);\n}\n\nconst text = (res && res.result && res.result.content && res.result.content[0] && res.result.content[0].text) || '{}';\nlet parsed = {};\ntry { parsed = JSON.parse(text); } catch (e) { parsed = {}; }\n\nif (parsed && parsed.error) {\n return 'error: ' + JSON.stringify(parsed.error) + ' args_sent=' + JSON.stringify(args);\n}\nreturn 'quote_id=' + (parsed.quote && parsed.quote.id ? parsed.quote.id : 'NONE') +\n ' quote_number=' + (parsed.quote_number || 'NONE') +\n ' items=' + (typeof parsed.items === 'number' ? parsed.items : '?') +\n ' deduped=' + (parsed.deduped ? 'true' : 'false');"
|
||
|
|
},
|
||
|
|
"id": "ee43616e-c80a-492b-9bbe-e83697c3d568",
|
||
|
|
"name": "create_quote_tool",
|
||
|
|
"type": "@n8n/n8n-nodes-langchain.toolCode",
|
||
|
|
"typeVersion": 1.3,
|
||
|
|
"position": [
|
||
|
|
2192,
|
||
|
|
416
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"parameters": {
|
||
|
|
"description": "Upload a single attachment to an existing quote. Call once per attachment after create_quote_tool returns a quote_id. Pass the binary_key from the email attachments list so the tool can read the file from the workflow binary store.\n\nInput fields: quote_id (required), binary_key (required), filename (required), content_type, size, message_id.",
|
||
|
|
"jsCode": "// upload_attachment_tool\n\n// Pull the first binary key from an item; honour an explicit one if valid.\nfunction pickBinaryKey(item, preferred) {\n if (!item || !item.binary) return '';\n if (preferred && item.binary[preferred]) return preferred;\n const keys = Object.keys(item.binary);\n return keys.length ? keys[0] : '';\n}\n\nlet bin = null;\nlet resolvedKey = '';\n\n// Prefer whatever is wired in upstream (Split Binary into Items feeds this tool).\ntry {\n const upstream = $input.first();\n resolvedKey = pickBinaryKey(upstream, binaryKey);\n if (resolvedKey) bin = upstream.binary[resolvedKey];\n} catch (e) { bin = null; }\n\n// Belt-and-suspenders: fall back to the IMAP Quote Mailbox original payload.\nif (!bin) {\n try {\n const orig = $('IMAP Quote Mailbox').first();\n resolvedKey = resolvedKey || pickBinaryKey(orig, binaryKey);\n if (resolvedKey && orig && orig.binary) bin = orig.binary[resolvedKey];\n } catch (e) { bin = null; }\n}\nif (!bin) return 'error: binary not found on upstream item';\n\n// Backfill the metadata the LLM often sends blank.\nconst meta = (bin && (bin.fileName || bin.filename)) || {};\nconst resolvedFilename = (filename && filename !== 'attachment') ? filename : (bin.fileName || bin.filename || 'attachment');\nconst resolvedContentType = (contentType && contentType !== 'application/octet-stream') ? contentType : (bin.mimeType || bin.contentType || 'application/octet-stream');\nconst resolvedSize = size > 0 ? size : ((bin.fileSize != null) ? bin.fileSize : (bin.data ? bin.data.length : 0));\nconst resolvedMessageId = messageId || ((($input.first() || {}).json || {}).messageId || '');\n\nlet base64 = '';\ntry {\n if (Buffer.isBuffer(bin.data)) {\n base64 = bin.data.toString('base64');\n } else if (typeof bin.data === 'string') {\n base64 = Buffer.from(bin.data, 'binary').toString('base64');\n } else {\n base64 = Buffer.from(bin.data || []).toString('base64');\n }\n} catch (e) {\n return 'error: base64 conversion failed: ' + (e && e.message ? e.message : e);\n}\n\nconst __upstream = $input.first() || {};\nconst __upstreamJson = __upstream.json || {};\nconst bearer = String(__upstreamJson.agent_api_key || '');\nconst mcpBase = String(__upstreamJson['mcp server'] || __upstreamJson.mcp_url || 'https://zolau-196-39-76-18.run.pinggy-free.link');\nconst mcpUrl = mcpBase.replace(/\\/$/, '') + '/api/mcp';\nconst authToken = bearer || '123456';\n\nconst res = await this.helpers.httpRequest({\n method: 'POST', url: mcpUrl,\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + authToken },\n body: { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'attachments.upload_for_quote', arguments: {\n quote_id: quoteId,\n filename: resolvedFilename,\n content_type: resolvedContentType,\n size: resolvedSize,\n base64,\n message_id: resolvedMessageId,\n } } },\n json: true, timeout: 60000\n});\n\nconst text = (res && res.result && res.result.content && res.result.content[0] && res.result.content[0].text) || '{}';\nlet parsed = {};\ntry { parsed = JSON.parse(text); } catch (e) { parsed = {}; }\n\nreturn 'uploaded=' + resolvedFilename + ' id=' + (parsed.attachment && parsed.attachment.id ? parsed.attachment.id : 'NONE') +\n ' kind=' + (parsed.attachment && parsed.attachment.kind ? parsed.attachment.kind : '?');\n"
|
||
|
|
},
|
||
|
|
"id": "3a4f3d08-dd8e-427f-9809-c222bf4cf3f6",
|
||
|
|
"name": "upload_attachment_tool",
|
||
|
|
"type": "@n8n/n8n-nodes-langchain.toolCode",
|
||
|
|
"typeVersion": 1.3,
|
||
|
|
"position": [
|
||
|
|
2400,
|
||
|
|
416
|
||
|
|
]
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"pinData": {},
|
||
|
|
"connections": {
|
||
|
|
"IMAP Quote Mailbox": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Edit Fields",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Edit Fields": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Parse Email",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Parse Email": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Drop Blacklist / Auto-replies",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Drop Blacklist / Auto-replies": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Is Reply?",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Is Reply?": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "MCP: quotes.append_reply",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
],
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Carry Email Ctx",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"MCP: quotes.append_reply": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Parse MCP Reply Result",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Parse MCP Reply Result": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Route Reply Result",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Carry Email Ctx": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Split Binary into Items",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Split Binary into Items": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Extract from File (PDF)",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Extract from File (PDF)": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "Aggregate + Enrich",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Aggregate + Enrich": {
|
||
|
|
"main": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "AI Agent: Create Quote From Email",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"Minimax Chat Model": {
|
||
|
|
"ai_languageModel": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "AI Agent: Create Quote From Email",
|
||
|
|
"type": "ai_languageModel",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"create_quote_tool": {
|
||
|
|
"ai_tool": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "AI Agent: Create Quote From Email",
|
||
|
|
"type": "ai_tool",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"upload_attachment_tool": {
|
||
|
|
"ai_tool": [
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"node": "AI Agent: Create Quote From Email",
|
||
|
|
"type": "ai_tool",
|
||
|
|
"index": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
]
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"active": true,
|
||
|
|
"settings": {
|
||
|
|
"executionOrder": "v1",
|
||
|
|
"binaryMode": "separate",
|
||
|
|
"availableInMCP": true
|
||
|
|
},
|
||
|
|
"versionId": "7654b6fd-a059-4ddb-9c81-5da41d78770e",
|
||
|
|
"meta": {
|
||
|
|
"aiBuilderAssisted": true,
|
||
|
|
"builderVariant": "mcp",
|
||
|
|
"instanceId": "05bcfa3b50919141d3600718ff4b7b2b52a3883525b05573a27ba5ef437ac7fb"
|
||
|
|
},
|
||
|
|
"nodeGroups": [],
|
||
|
|
"id": "VOrEd4OQKO0KMnJj",
|
||
|
|
"tags": []
|
||
|
|
}
|