WFDASH-WEBAPP/n8n_workfows/WFDASH — Quote Email Monitor (v4 direct-to-PB).json

2266 lines
No EOL
79 KiB
JSON

{
"name": "WFDASH — Quote Email Monitor (v4 direct-to-PB)",
"nodes": [
{
"parameters": {
"format": "resolved",
"options": {
"customEmailConfig": "[\"UNSEEN\"]",
"forceReconnect": 60,
"trackLastMessageId": true
}
},
"id": "322ed529-7da0-4331-ab8e-9bfa084c282d",
"name": "IMAP Quote Mailbox",
"type": "n8n-nodes-base.emailReadImap",
"typeVersion": 2.1,
"position": [
0,
480
],
"credentials": {
"imap": {
"id": "OxBkyAvgByqMElJG",
"name": "IMAP account"
}
}
},
{
"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 PLAIN_TRUNC_NOTE = String.fromCharCode(10) + String.fromCharCode(10) + '[... body truncated at 50KB; full body in IMAP mailbox ...]';\nconst bodyPlain = trimmedPlain.length > PLAIN_LIMIT\n ? trimmedPlain.slice(0, PLAIN_LIMIT) + PLAIN_TRUNC_NOTE\n : (trimmedPlain || '(no body)');\n\nconst rawBodyHtml = String(item.textHtml || '');\nconst trimmedHtml = rawBodyHtml.trim();\nconst HTML_TRUNC_NOTE = String.fromCharCode(10) + '<!-- body truncated at 200KB; full body in IMAP mailbox -->';\nconst bodyHtml = trimmedHtml.length > HTML_LIMIT\n ? trimmedHtml.slice(0, HTML_LIMIT) + HTML_TRUNC_NOTE\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": "caa0d1d4-a79a-40d1-badd-2bba84712121",
"name": "Parse Email",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
224,
480
]
},
{
"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": "72f557c0-7e0b-4ddf-825a-6f377f0237fb",
"name": "Drop Blacklist / Auto-replies",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
448,
480
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "pb-url",
"name": "pb_url",
"value": "https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io",
"type": "string"
},
{
"id": "pb-admin-email",
"name": "pb_admin_email",
"value": "david.els1@outlook.com",
"type": "string"
},
{
"id": "pb-admin-pass",
"name": "pb_admin_password",
"value": "1234567890",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "be789a81-cc71-44fb-bbf2-4faad0434c88",
"name": "PocketBase Connection",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
672,
464
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.pb_url }}/api/collections/_superusers/auth-with-password",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"identity\": $json.pb_admin_email, \"password\": $json.pb_admin_password } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "3c7b3d78-f8db-4c5b-a13c-06569f525621",
"name": "PocketBase Admin Auth",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
896,
464
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse PB Auth response\n// After POST /api/admins/auth-with-password, the response is\n// { token, admin: {...} }. Pass the token through on $json.pb_token\n// so every downstream HTTP node can use it as Bearer auth.\nconst body = $json || {};\nconst token = String(body.token || '');\nif (!token) {\n return { json: Object.assign({}, $('PocketBase Connection').item.json, {\n pb_token: '',\n pb_auth_error: 'auth returned no token: ' + JSON.stringify(body)\n }) };\n}\nreturn { json: Object.assign({}, $('PocketBase Connection').item.json, {\n pb_token: token,\n pb_admin_id: (body.admin && body.admin.id) ? body.admin.id : ''\n}) };"
},
"id": "6e1f9349-0489-46a6-9e97-653e2e46202c",
"name": "Parse PB Auth",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
464
]
},
{
"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": "a235c00d-1246-4d01-9418-4649b94cf6fc",
"name": "Is Reply?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
1344,
464
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Encode Reply Filters\n// Pre-encode the two PB filters used by the reply-branch HTTP nodes.\nconst inReplyTo = String($json.in_reply_to || '').trim();\nconst subject = String($json.subject || '').trim();\nreturn { json: Object.assign({}, $json, {\n // in_reply_to filter: exact match on email_message_id\n _reply_filter_in_reply_to: inReplyTo ? 'email_message_id=' + JSON.stringify(inReplyTo) : 'id=\"\"',\n // subject fallback filter: contains Q-YYYY-MM-NNN (PB's ~ is contains)\n _reply_filter_subject: 'quote_number~' + JSON.stringify(subject)\n}) };"
},
"id": "85b15e0f-fc92-4e63-9bee-3897d5ff3420",
"name": "Encode Reply Filters",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
0
]
},
{
"parameters": {
"url": "={{ $json.pb_url }}/api/collections/quotes/records?filter={{ $json._reply_filter_in_reply_to }}&perPage=1",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "118d5476-65e3-47dd-90f7-976779e3a069",
"name": "Find Target Quote (by In-Reply-To)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1792,
0
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Decide whether the first lookup (by In-Reply-To) found a match.\n// If yes, _reply_quote_id is set; if no, it's empty and the next\n// node (Find Target Quote by Subject) will try the fallback.\nconst body = $json || {};\nconst items = Array.isArray(body.items) ? body.items : [];\nconst first = items[0] || null;\nreturn { json: Object.assign({}, $('Parse Email').item.json, {\n _reply_quote_id: first ? (first.id || '') : ''\n}) };"
},
"id": "c5fce964-64f1-4c22-8335-75c03bd73174",
"name": "Decide Reply Lookup",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2016,
0
]
},
{
"parameters": {
"url": "={{ $json.pb_url }}/api/collections/quotes/records?filter={{ $json._reply_filter_subject }}&perPage=1",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "294b0c58-557d-401e-9850-c747f2557acb",
"name": "Find Target Quote (by Subject)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2240,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "has-match",
"leftValue": "={{ $json._reply_quote_id }}",
"operator": {
"type": "string",
"operation": "notEquals"
},
"rightValue": ""
}
],
"combinator": "and"
},
"options": {}
},
"id": "bb736306-8ffc-4779-82b2-e8861c5a1527",
"name": "Route Reply Result (matched?)",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
2464,
0
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.pb_url }}/api/collections/quote_replies/records",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"quote\": $json._reply_quote_id, \"from_email\": $json.from_email, \"from_name\": $json.from_name, \"subject\": $json.subject, \"body_plain\": $json.body_plain, \"body_html\": $json.body_html, \"received_at\": $json.received_at, \"email_message_id\": $json.message_id, \"in_reply_to\": $json.in_reply_to, \"quoted_text\": \"\" } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "1d85fce8-7567-481f-877e-761af692fda5",
"name": "Insert quote_replies",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2752,
0
]
},
{
"parameters": {
"method": "PATCH",
"url": "={{ $json.pb_url }}/api/collections/quotes/records/{{ $json._reply_quote_id }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"has_new_reply\": true}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "e80a7602-5b72-457a-a8ae-72b3d4f0405c",
"name": "Flip has_new_reply",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3040,
0
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Carry Email Ctx — passes the full parsed email through and\n// preserves the binary so downstream nodes (Split by Type, then the\n// Extract from File nodes) can read attachments by key.\nconst __bin = $binary || {};\nreturn { json: Object.assign({}, $json || {}), binary: __bin };"
},
"id": "4b9b66a0-8a1a-4a55-9bd0-3eb8232e16aa",
"name": "Carry Email Ctx",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
864
]
},
{
"parameters": {
"jsCode": "// Split by Type\n// Take the (single) email item from upstream, emit one item per\n// binary entry. Each emitted item has:\n// - json: { binary_key, filename, content_type, size, extracted_op, ...email ctx }\n// - binary: { attachment: <single binary> }\n//\n// mode: runOnceForAllItems — process the (single) input item once\n// and return multiple output items.\nconst inputItems = $input.all();\nconst out = [];\n\nfunction opForContentType(ct) {\n if (!ct) return 'none';\n const c = String(ct).toLowerCase();\n if (c === 'application/pdf') return 'pdf';\n if (c === 'text/csv') return 'csv';\n if (c === 'application/vnd.ms-excel') return 'xls';\n if (c === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return 'xlsx';\n if (c === 'text/html') return 'html';\n if (c === 'application/json') return 'json';\n if (c === 'text/plain') return 'text';\n return 'none';\n}\n\n// Fields to carry forward on every emitted item, including the\n// no-attachment placeholder. Normalise reads these off items[0] to\n// rebuild the top-level email context, and downstream HTTP nodes\n// need pb_url / pb_token / from_email / from_name / message_id / etc.\nfunction emailCtxFromItem(input) {\n const email = input.json || {};\n return {\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 // PB auth context must be preserved on every emitted item so the\n // downstream HTTP nodes can build URLs and Bearer headers.\n pb_url: email.pb_url || '',\n pb_token: email.pb_token || '',\n pb_admin_id: email.pb_admin_id || ''\n };\n}\n\nfor (const input of inputItems) {\n const email = input.json || {};\n const bin = input.binary || {};\n let emittedForThisEmail = 0;\n for (const key of Object.keys(bin)) {\n const b = bin[key];\n if (!b) continue;\n const ct = b.mimeType || 'application/octet-stream';\n out.push({\n json: Object.assign(emailCtxFromItem(input), {\n binary_key: key,\n filename: b.fileName || 'attachment',\n content_type: ct,\n size: b.fileSize || 0,\n extracted_op: opForContentType(ct)\n }),\n binary: { attachment: b }\n });\n emittedForThisEmail++;\n }\n\n // If the email has no attachments, emit one placeholder so the chain\n // continues (the Switch's \"none\" branch will route it past the\n // extracts, and the Normalise step will just see 0 attachments).\n // The placeholder carries the full email context AND the PB auth\n // context so Normalise and downstream HTTP nodes have everything\n // they need.\n if (emittedForThisEmail === 0) {\n out.push({\n json: Object.assign(emailCtxFromItem(input), {\n binary_key: null,\n filename: '',\n content_type: '',\n size: 0,\n extracted_op: 'none'\n }),\n binary: {}\n });\n }\n}\nreturn out;"
},
"id": "05e67b7e-ddc5-491b-aa5f-fb5dddd5498e",
"name": "Split by Type",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1792,
864
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "pdf"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "pdf"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "csv"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "csv"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "xls"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "xls"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "xlsx"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "xlsx"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "html"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "html"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "json"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "json"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "text"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "text"
}
]
},
"options": {
"fallbackOutput": "extra",
"renameFallbackOutput": "none"
}
},
"id": "89bc7a9b-e69b-4831-9082-91040f4e3c61",
"name": "Route by extracted_op",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.4,
"position": [
2016,
784
]
},
{
"parameters": {
"operation": "pdf",
"binaryPropertyName": "attachment",
"options": {
"joinPages": true
}
},
"id": "5975547f-90ee-4411-aac0-b3e49850ef3f",
"name": "Extract PDF",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
2240,
192
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"binaryPropertyName": "attachment",
"options": {
"headerRow": true
}
},
"id": "23c733ec-abb9-456d-acb2-709649cf96f5",
"name": "Extract CSV",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
2240,
576
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xls",
"binaryPropertyName": "attachment",
"options": {
"sheetName": "Sheet"
}
},
"id": "ee6d5239-5b71-4443-8ef1-a8235927a7cb",
"name": "Extract XLS",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
2240,
768
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "attachment",
"options": {
"sheetName": "Sheet"
}
},
"id": "a1faa24f-c1eb-4ddb-8bd4-125752dbbdbc",
"name": "Extract XLSX",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
2240,
960
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "html",
"binaryPropertyName": "attachment",
"options": {}
},
"id": "f2b6933d-5b6b-4094-bc95-92dfab292d4e",
"name": "Extract HTML",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
2240,
1152
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "fromJson",
"binaryPropertyName": "attachment",
"options": {}
},
"id": "4f658496-7a2c-4df2-8507-746f5b0f2f72",
"name": "Extract JSON",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
2240,
1344
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "text",
"binaryPropertyName": "attachment",
"options": {}
},
"id": "80419aa0-378d-4b5c-97f8-e63e164b10fe",
"name": "Extract Text",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
2240,
1536
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Normalise Extracted Text\n// Re-aggregate all items back to one item per email. For each\n// attachment, put the extracted text on attachments[i].extracted_text.\n// Restore binaries under original keys for the upload step. Pass\n// through pb_url / pb_token / from_email etc. so downstream HTTP\n// nodes can build URLs and Bearer headers.\nconst NL = String.fromCharCode(10);\nconst items = $input.all();\nif (items.length === 0) {\n return { json: { attachments: [], has_attachments: false }, binary: {} };\n}\n\nconst first = items[0].json || {};\nconst firstInput = items[0] || {};\nconst MAX_TEXT_PER_ATTACHMENT = 10 * 1024;\nconst TRUNC_NOTE = NL + NL + '[... text truncated at 10KB; full text in quote attachment in PocketBase ...]';\nconst attachments = [];\nconst binary = {};\n\nfunction normaliseText(item, op) {\n if (op === 'pdf' || op === 'text' || op === 'html') {\n const t = (item.json && typeof item.json.text === 'string') ? item.json.text : '';\n return t;\n }\n if (op === 'csv' || op === 'xls' || op === 'xlsx') {\n // Extract from File emits one item per row when headerRow=true.\n // Aggregate them into a CSV string.\n const rows = Array.isArray(item.json) ? item.json : (item.json && Array.isArray(item.json.rows) ? item.json.rows : (item.json ? [item.json] : []));\n if (rows.length === 0) return '';\n const headers = Object.keys(rows[0] || {});\n const lines = [headers.join(',')];\n for (const r of rows) {\n lines.push(headers.map(h => {\n const v = r[h];\n if (v == null) return '';\n const s = String(v);\n return s.includes(',') || s.includes('\"') || s.includes(NL) ? '\"' + s.replace(/\"/g, '\"\"') + '\"' : s;\n }).join(','));\n }\n return lines.join(NL);\n }\n if (op === 'json') {\n return JSON.stringify(item.json || {}, null, 2);\n }\n return ''; // none / unknown\n}\n\nfor (const item of items) {\n const origKey = item.json.binary_key;\n const op = item.json.extracted_op || 'none';\n const raw = normaliseText(item, op);\n const truncated = raw.length > MAX_TEXT_PER_ATTACHMENT\n ? raw.slice(0, MAX_TEXT_PER_ATTACHMENT) + TRUNC_NOTE\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 });\n if (item.binary && item.binary.attachment) {\n binary[origKey] = item.binary.attachment;\n }\n}\n\n// Rebuild top-level email context. Include pb_url / pb_token so the\n// downstream HTTP nodes can build URLs and Bearer headers.\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 || 'new',\n uid: first._email_uid || '',\n mailbox: first._email_mailbox || 'INBOX',\n // PB auth context — MUST be on the top-level output for downstream\n // HTTP nodes to read.\n pb_url: first.pb_url || '',\n pb_token: first.pb_token || '',\n pb_admin_id: first.pb_admin_id || '',\n attachments: attachments,\n has_attachments: attachments.some(a => a.binary_key != null) && attachments.some(a => a.content_type !== 'application/octet-stream' || a.filename)\n },\n binary: binary\n};"
},
"id": "a332928d-f077-4c31-82ed-4347d3f80776",
"name": "Normalise Extracted Text",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2256,
384
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Build Payload\n// Compose the LLM user message: email metadata + extracted text +\n// body. The LLM only emits company_hint and items from this.\nconst NL = String.fromCharCode(10);\nconst atts = ($json.attachments || []).filter(a => a.binary_key != null);\nconst textBlock = atts\n .filter(a => a.extracted_text)\n .map(a => '--- ' + a.filename + ' (' + a.content_type + ') ---' + NL + a.extracted_text)\n .join(NL + NL);\nconst userMessage = [\n 'From: ' + ($json.from_name || '') + ' <' + ($json.from_email || '') + '>',\n 'Subject: ' + ($json.subject || ''),\n 'Message-ID: ' + ($json.message_id || ''),\n 'Date: ' + ($json.received_at || ''),\n 'Attachments: ' + atts.length + ' file(s)',\n textBlock,\n '',\n 'Body:',\n $json.body_plain || $json.body_html || '(no body)'\n].join(NL);\nreturn { json: Object.assign({}, $json, { _llm_user_message: userMessage }) };"
},
"id": "c872e883-1691-4874-b8d3-ab693b03563a",
"name": "Build Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2464,
384
]
},
{
"parameters": {
"text": "={{ $json._llm_user_message }}",
"schemaType": "fromJson",
"jsonSchemaExample": "{\"company_hint\":\"Acme Mining\",\"items\":[{\"description\":\"20mm cable ties\",\"quantity\":5,\"unit\":\"pack\",\"notes\":\"\"}]}",
"options": {
"systemPromptTemplate": "You are a purchase-order parser. Extract the buyer/end-customer company name (or empty string if unclear) and the line items as an array. Each item: description (string), quantity (number, default 1), unit (string, optional), notes (string, optional). Look in the email body AND in the extracted text from each attachment. Do not invent items, prices, or quantities. If there are no line items, return an empty array."
}
},
"id": "6c1934ac-5df4-4eba-a270-d77cb39f7f78",
"name": "Extract Items (LLM)",
"type": "@n8n/n8n-nodes-langchain.informationExtractor",
"typeVersion": 1.2,
"position": [
2688,
384
]
},
{
"parameters": {
"model": "={{ 'Minimax-M2.7' }}",
"options": {
"hideThinking": true,
"temperature": 0.1,
"timeout": 60000,
"maxRetries": 2
}
},
"id": "3996a775-e139-4801-8aee-0e5a4ae863a2",
"name": "Minimax Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatMinimax",
"typeVersion": 1,
"position": [
2688,
608
],
"credentials": {
"minimaxApi": {
"id": "kn97KHCrkyTm3v2g",
"name": "MiniMax account"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Merge LLM Output into Email Ctx\n// Information Extractor returns { output: { company_hint, items } }.\n// Merge it onto the upstream email context (so the downstream HTTP\n// nodes see items + company_hint on $json).\nconst upstream = $('Build Payload').item.json || {};\nconst llmOut = ($json.output && typeof $json.output === 'object') ? $json.output : {};\nconst out = ($json && $json.output) || $json || {};\nreturn { json: Object.assign({}, upstream, {\n company_hint: (out.company_hint != null) ? String(out.company_hint) : '',\n items: Array.isArray(out.items) ? out.items : []\n}) };"
},
"id": "d3edf62b-cf86-42d4-a9ef-7306af612ac5",
"name": "Merge LLM Output",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3040,
384
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Find or Create Customer\n// Decide which customer this email belongs to. Look up by email; if\n// not found, create one. The LLM's company_hint goes onto the\n// customer's \"company\" relation. The two field names read from PB\n// are 'email' and 'customer' (per docs/pocketbase/customers.collection.json).\nconst SENTINEL_EMAIL = 'unknown-sender@no-from.wfdash.local';\n\nconst fromEmail = String($json.from_email || '').toLowerCase().trim();\nconst fromName = String($json.from_name || '').trim();\nconst companyHint = String($json.company_hint || $json._company_hint || '').trim();\nconst subject = String($json.subject || '').trim();\nconst receivedAt = String($json.received_at || $now.toISO());\n\n// Decide the email to look up: empty from_email -> sentinel.\nconst lookupEmail = fromEmail || SENTINEL_EMAIL;\n\n// Pre-encode the PocketBase filter so the HTTP node's url can use\n// the encoded filter field. The filter is: email=\"<email>\" (PB exact match).\nconst customerFilter = 'email=' + JSON.stringify(lookupEmail);\n\n// The HTTP lookups happen in the next nodes; this Code node just\n// emits the lookup plan.\nreturn { json: Object.assign({}, $json, {\n _lookup_email: lookupEmail,\n _from_email_resolved: fromEmail || SENTINEL_EMAIL,\n _from_name_resolved: fromName || (lookupEmail.split('@')[0] || 'Unknown Sender'),\n _customer_company_hint: companyHint || (lookupEmail.split('@')[1] || '').toLowerCase(),\n _customer_dateadded: receivedAt,\n _customer_description: 'Auto-created from email intake.',\n _customer_filter: customerFilter\n}) };"
},
"id": "c9386f72-c0bb-49ad-9f22-5dcdddc99f8a",
"name": "Plan: Find or Create Customer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3264,
384
]
},
{
"parameters": {
"url": "={{ $json.pb_url }}/api/collections/customers/records?filter={{ $json._customer_filter }}&perPage=1",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "12e5987d-d50c-414f-b4dd-06027df3a979",
"name": "Lookup Customer by Email",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3488,
384
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse Customer Lookup Result\n// After GET /customers?filter=email=...&perPage=1: if items.length\n// > 0, use items[0].id; else flag to create.\n// IMPORTANT: merge from the real upstream (Plan: Find or Create\n// Customer) so pb_url, pb_token, _customer_company_hint, etc.\n// survive. Build Payload doesn't have them.\nconst body = $json || {};\nconst items = Array.isArray(body.items) ? body.items : [];\nconst first = items[0] || null;\nconst upstream = $('Plan: Find or Create Customer').item.json || {};\nreturn { json: Object.assign({}, upstream, {\n _customer_id: first ? (first.id || first) : null,\n _customer_existing: !!first\n}) };"
},
"id": "e9c08d7a-a47d-432f-bc29-2359be773c92",
"name": "Parse Customer Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3712,
384
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "no-customer-id",
"leftValue": "={{ $json._customer_id }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": ""
}
],
"combinator": "and"
},
"options": {}
},
"id": "5efd088a-975f-4c95-8b33-0325af6b20b0",
"name": "Is Customer New?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
3920,
384
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.pb_url }}/api/collections/customers/records",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"customer\": $json._from_name_resolved, \"email\": $json._from_email_resolved, \"company\": $json._customer_company_hint, \"dateadded\": $json._customer_dateadded, \"description\": $json._customer_description } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "518df203-19b8-4c64-b32e-947d2e50c5e8",
"name": "Create Customer",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
4160,
288
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Find or Create Company\n// Read the LLM's company_hint. If empty, we skip the lookup/create\n// and the quote will have company=null. The HTTP GET/POST happens in\n// the next nodes; this Code node just decides what to look up.\nconst hint = String($json.company_hint || $json._company_hint || '').trim();\n// Pre-encode the PB filter for the company lookup: company=\"<hint>\"\nconst companyFilter = hint ? 'company=' + JSON.stringify(hint) : '';\nreturn { json: Object.assign({}, $json, {\n _company_name: hint,\n _company_normalized: hint.toLowerCase(),\n _company_filter: companyFilter\n}) };"
},
"id": "cee76bc4-3cce-41db-b87c-14dfbfd340ff",
"name": "Plan: Find or Create Company",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4176,
480
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "has-hint",
"leftValue": "={{ $json._company_name }}",
"operator": {
"type": "string",
"operation": "notEquals"
},
"rightValue": ""
}
],
"combinator": "and"
},
"options": {}
},
"id": "38dc1f80-1fa0-4fb9-9424-7f9425b06861",
"name": "Skip Company if no hint",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
4352,
480
]
},
{
"parameters": {
"url": "={{ $json.pb_url }}/api/collections/companies/records?filter={{ $json._company_filter }}&perPage=1",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "df53c9db-351d-40c6-ad17-72610fc7dd38",
"name": "Lookup Company by Name",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
4608,
384
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse Company Lookup Result\n// After GET /companies?filter=company=...&perPage=1: if items.length\n// > 0, use items[0].id; else flag to create.\n// IMPORTANT: merge from the real upstream (Skip Company if no hint)\n// so pb_url, pb_token, _customer_id, etc. survive.\nconst body = $json || {};\nconst items = Array.isArray(body.items) ? body.items : [];\nconst first = items[0] || null;\nconst upstream = $('Skip Company if no hint').item.json || {};\nreturn { json: Object.assign({}, upstream, {\n _company_id: first ? (first.id || first) : null,\n _company_existing: !!first\n}) };"
},
"id": "80ccc0ec-1d3a-4860-b59c-d76cdcc9e7ec",
"name": "Parse Company Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4832,
384
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "no-company-id",
"leftValue": "={{ $json._company_id }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": ""
}
],
"combinator": "and"
},
"options": {}
},
"id": "d6f7b1f5-2859-495a-8d45-0fb8f3e10885",
"name": "Is Company New?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
5056,
384
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.pb_url }}/api/collections/companies/records",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"company\": $json._company_name, \"normalized\": $json._company_normalized } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "88f58101-e10e-4657-aac4-09b91938ce45",
"name": "Create Company",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5280,
384
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Generate Quote Number (Code side)\n// Decide year/month and the message-id fallback. The atomic\n// counter dance happens in the next HTTP nodes:\n// - GET /quote_counters?filter=year=YYYY && month=MM\n// - if found: PATCH with {\"last_seq+\": 1}\n// - if 404: POST with { year, month, last_seq: 1 } and re-PATCH if\n// a concurrent insert wins the (year, month) unique index.\nconst now = new Date();\nconst year = now.getUTCFullYear();\nconst month = now.getUTCMonth() + 1;\n\n// Synthesise a message-id if the email didn't have one. This matches\n// the format in src/lib/server/quotes-service.ts: synthesizeMessageId.\nfunction synthesiseMessageId(sender, subj, date) {\n const input = (sender || '') + '|' + (subj || '') + '|' + (date || '');\n let b64;\n try { b64 = btoa(input); } catch (e) {\n b64 = Buffer.from(input, 'utf8').toString('base64');\n }\n return 'synth-' + b64.replace(/=+$/, '') + '@wfdash.local';\n}\n\nconst realMessageId = String($json.message_id || '').trim();\nconst effectiveMessageId = realMessageId || synthesiseMessageId(\n $json.from_email || '',\n $json.subject || '',\n $json.received_at || $now.toISO()\n);\n\n// IMPORTANT: pull pb_url, pb_token, _customer_id, _company_id, etc.\n// from the upstream Plan: Find or Create Company (a Code node that\n// preserved everything). $json here is whichever HTTP response just\n// ran (Lookup Company OR Create Company), which has lost the\n// context we need to make the next PB call.\nconst ctx = $('Plan: Find or Create Company').item.json || {};\n\nreturn { json: Object.assign({}, ctx, $json, {\n _counter_year: year,\n _counter_month: month,\n _effective_message_id: effectiveMessageId,\n _counter_filter: 'year=' + year + ' && month=' + month\n}) };"
},
"id": "fadc0c02-2e1c-461b-b947-91a6dd22bd39",
"name": "Plan: Generate Quote Number",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4608,
576
]
},
{
"parameters": {
"url": "={{ $json.pb_url }}/api/collections/quote_counters/records?filter={{ $json._counter_filter }}&perPage=1",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "373b2224-412a-46b3-b1ba-1b10d9c269d4",
"name": "Lookup Counter (year, month)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
4832,
576
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse Counter\n// After GET /quote_counters?filter=year=YYYY && month=MM: if\n// items.length > 0, return its id; else flag to create.\n// IMPORTANT: merge from the real upstream (Plan: Generate Quote\n// Number) so pb_url, pb_token, _customer_id, _company_id, etc.\n// survive. Build Payload doesn't have them.\nconst body = $json || {};\nconst items = Array.isArray(body.items) ? body.items : [];\nconst first = items[0] || null;\nconst upstream = $('Plan: Generate Quote Number').item.json || {};\nreturn { json: Object.assign({}, upstream, {\n _counter_id: first ? (first.id || first) : null,\n _counter_existing: !!first\n}) };"
},
"id": "5bbf8e3a-8249-4619-98c2-4a85b47ec00d",
"name": "Parse Counter Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5056,
576
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "no-counter-id",
"leftValue": "={{ $json._counter_id }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": ""
}
],
"combinator": "and"
},
"options": {}
},
"id": "d3647590-3bc1-401d-aa6d-b759dbad9a85",
"name": "Is Counter New?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
5280,
576
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.pb_url }}/api/collections/quote_counters/records",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"year\": $json._counter_year, \"month\": $json._counter_month, \"last_seq\": 1 } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "b943d65b-d021-45c0-b09c-942867cfd4b5",
"name": "Create Counter (year, month)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5504,
384
]
},
{
"parameters": {
"method": "PATCH",
"url": "={{ $json.pb_url }}/api/collections/quote_counters/records/{{ $json._counter_id }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"last_seq+\": 1}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "f3125693-a71e-4b93-b6d7-5042357e4c39",
"name": "Increment Counter (last_seq+)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5504,
768
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Format Final Quote Number\n// Build the Q-YYYY-MM-NNN string from the counter response.\n// IMPORTANT: we have to merge fields from MULTIPLE upstream nodes\n// because the counter PATCH response replaces $json with a thin\n// object that doesn't carry _customer_id, _company_id, attachments,\n// etc. We pull:\n// - the new last_seq from $json (the immediate PATCH response)\n// - the rest of the context from $('Parse Counter Result').item.json\nconst body = $json || {};\nconst lastSeq = Number(body.last_seq || body.lastSeq || 0);\nconst upstream = $('Parse Counter Result').item.json || {};\nconst year = Number(upstream._counter_year || (new Date().getUTCFullYear()));\nconst month = Number(upstream._counter_month || (new Date().getUTCMonth() + 1));\nconst quoteNumber = 'Q-' + year + '-' + String(month).padStart(2, '0') + '-' + String(lastSeq).padStart(3, '0');\nreturn { json: Object.assign({}, upstream, {\n _counter_last_seq: lastSeq,\n _counter_id: body.id || upstream._counter_id,\n quote_number: quoteNumber\n}) };"
},
"id": "9eabb4c3-6ba2-4164-b538-58748f1b88a3",
"name": "Format Quote Number",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5504,
560
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.pb_url }}/api/collections/quotes/records",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"quote_number\": $json.quote_number, \"customer\": $json._customer_id, \"company\": $json._company_id || null, \"description\": $json.subject, \"status\": \"new\", \"notes\": \"\", \"email_sender\": $json.from_email, \"email_subject\": $json.subject, \"email_body\": $json.body_plain || $json.body_html || \"\", \"has_attachments\": $json.has_attachments, \"email_message_id\": $json._effective_message_id, \"company_letterhead\": \"CNL\" } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "6b4d4358-41b4-4506-9575-ae72f38fbf0c",
"name": "Create Quote",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5728,
560
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse Quote Create Result\n// After POST /quotes, return the new id + the FULL upstream\n// context (so that pb_url, pb_token, _customer_id, _company_id,\n// attachments, items, quote_number, etc. all survive).\n// The downstream Create Quote Items and Upload Attachments loops\n// read pb_url/pb_token/items/attachments/quote_id from this.\nconst body = $json || {};\nconst upstream = $('Format Quote Number').item.json || {};\nreturn { json: Object.assign({}, upstream, {\n quote_id: body.id || null,\n quote_number: upstream.quote_number || (body.quote_number || ''),\n items: upstream.items || [],\n attachments: upstream.attachments || []\n}) };"
},
"id": "ac3cc93a-c5a1-4ded-8d81-171bc306c8ee",
"name": "Parse Quote Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5952,
560
]
},
{
"parameters": {
"jsCode": "// Create Quote Items (Code side)\n// Take the LLM's items array, emit one item per line item, each\n// carrying the resolved quote_id and quote_number. The downstream\n// HTTP POST node creates one quote_items row per emitted item.\n// IMPORTANT: carry pb_url, pb_token through on every emitted\n// item so the downstream HTTP node can build the URL.\nconst items = Array.isArray($json.items) ? $json.items : [];\nconst quoteId = String($json.quote_id || '');\nconst quoteNumber = String($json.quote_number || '');\nconst pbUrl = String($json.pb_url || '');\nconst pbToken = String($json.pb_token || '');\n\nif (!quoteId) {\n return [{ json: { _skip: true, reason: 'no quote_id', items_count: items.length } }];\n}\n\nif (items.length === 0) {\n return [{ json: { _skip: true, reason: 'no items from LLM', items_count: 0 } }];\n}\n\nreturn items.map((it, idx) => ({\n json: {\n pb_url: pbUrl,\n pb_token: pbToken,\n quote: quoteId,\n description: String(it.description || ''),\n quote_number: quoteNumber,\n quantity: Number(it.quantity || 1),\n cost_price: 0,\n selling_price: 0,\n markup: 0,\n notes: String(it.notes || ''),\n is_processed: false,\n included_in_quote: false,\n _item_index: idx\n }\n}));"
},
"id": "c2ef22a7-5d76-4440-83ba-d08a48af75c0",
"name": "Plan: Create Quote Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
6176,
560
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.pb_url }}/api/collections/quote_items/records",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { \"quote\": $json.quote, \"description\": $json.description, \"quote_number\": $json.quote_number, \"quantity\": $json.quantity, \"cost_price\": 0, \"selling_price\": 0, \"markup\": 0, \"notes\": $json.notes, \"is_processed\": false, \"included_in_quote\": false } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "afec4c42-1910-41f7-9b4c-f3067078153f",
"name": "Create Quote Item",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
6400,
560
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Upload Attachments (Code side)\n// Take the email's attachments list (one entry per file), emit one\n// item per attachment, each carrying the resolved quote_id. The\n// downstream HTTP POST node uploads one file as multipart with the\n// binary referenced by binary_key.\n// IMPORTANT: carry pb_url, pb_token through on every emitted\n// item so the downstream HTTP node can build the URL.\nconst atts = ($json.attachments || []).filter(a => a.binary_key != null && a.filename);\nconst quoteId = String($json.quote_id || '');\nconst messageId = String($json.message_id || $json._effective_message_id || '');\nconst pbUrl = String($json.pb_url || '');\nconst pbToken = String($json.pb_token || '');\n\nif (!quoteId || atts.length === 0) {\n return [{ json: { _skip: true, reason: !quoteId ? 'no quote_id' : 'no attachments', count: atts.length, pb_url: pbUrl, pb_token: pbToken } }];\n}\n\nfunction kindFor(contentType, filename) {\n const ct = String(contentType || '').toLowerCase();\n const fn = String(filename || '').toLowerCase();\n if (ct === 'application/pdf' || fn.endsWith('.pdf')) return 'pdf';\n if (ct.startsWith('image/') || /\\\\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';\n if (/\\\\.(docx?|xlsx?|csv|ods|rtf)$/.test(fn) ||\n ct === 'application/msword' ||\n ct === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||\n ct === 'application/vnd.ms-excel' ||\n ct === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||\n ct === 'text/csv') return 'doc';\n return 'other';\n}\n\nreturn atts.map(a => ({\n json: {\n pb_url: pbUrl,\n pb_token: pbToken,\n quote: quoteId,\n filename: a.filename,\n content_type: a.content_type || 'application/octet-stream',\n size_bytes: a.size || 0,\n email_message_id: messageId,\n kind: kindFor(a.content_type, a.filename),\n binary_key: a.binary_key,\n _uploaded_at: $now.toISO()\n }\n}));"
},
"id": "3061905c-2e7b-465f-bfaa-08dd1c88056a",
"name": "Plan: Upload Attachments",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
6624,
560
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.pb_url }}/api/collections/quote_attachments/records",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
}
]
},
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"name": "quote",
"value": "={{ $json.quote }}"
},
{
"name": "filename",
"value": "={{ $json.filename }}"
},
{
"name": "content_type",
"value": "={{ $json.content_type }}"
},
{
"name": "size_bytes",
"value": "={{ $json.size_bytes }}"
},
{
"name": "email_message_id",
"value": "={{ $json.email_message_id }}"
},
{
"name": "kind",
"value": "={{ $json.kind }}"
},
{
"name": "uploaded_at",
"value": "={{ $json._uploaded_at }}"
},
{
"name": "file",
"value": "={{ $binary[$json.binary_key] }}"
}
]
},
"options": {
"response": {
"response": {
"neverError": true,
"responseFormat": "json"
}
}
}
},
"id": "797afd29-91be-425c-bba6-f2be8feca214",
"name": "Upload Attachment",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
6848,
560
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"method": "PATCH",
"url": "={{ $json.pb_url }}/api/collections/quotes/records/{{ $json.quote_id }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.pb_token }}"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"has_attachments\": true}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"id": "0b6e8f48-a88d-40d0-a8e4-b045b03cf324",
"name": "Mark has_attachments",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
7072,
560
],
"onError": "continueRegularOutput"
}
],
"pinData": {},
"connections": {
"IMAP Quote Mailbox": {
"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": "PocketBase Connection",
"type": "main",
"index": 0
}
]
]
},
"PocketBase Connection": {
"main": [
[
{
"node": "PocketBase Admin Auth",
"type": "main",
"index": 0
}
]
]
},
"PocketBase Admin Auth": {
"main": [
[
{
"node": "Parse PB Auth",
"type": "main",
"index": 0
}
]
]
},
"Parse PB Auth": {
"main": [
[
{
"node": "Is Reply?",
"type": "main",
"index": 0
}
]
]
},
"Is Reply?": {
"main": [
[
{
"node": "Encode Reply Filters",
"type": "main",
"index": 0
}
],
[
{
"node": "Carry Email Ctx",
"type": "main",
"index": 0
}
]
]
},
"Encode Reply Filters": {
"main": [
[
{
"node": "Find Target Quote (by In-Reply-To)",
"type": "main",
"index": 0
}
]
]
},
"Find Target Quote (by In-Reply-To)": {
"main": [
[
{
"node": "Decide Reply Lookup",
"type": "main",
"index": 0
}
]
]
},
"Decide Reply Lookup": {
"main": [
[
{
"node": "Find Target Quote (by Subject)",
"type": "main",
"index": 0
}
]
]
},
"Find Target Quote (by Subject)": {
"main": [
[
{
"node": "Route Reply Result (matched?)",
"type": "main",
"index": 0
}
]
]
},
"Route Reply Result (matched?)": {
"main": [
[
{
"node": "Insert quote_replies",
"type": "main",
"index": 0
}
]
]
},
"Insert quote_replies": {
"main": [
[
{
"node": "Flip has_new_reply",
"type": "main",
"index": 0
}
]
]
},
"Carry Email Ctx": {
"main": [
[
{
"node": "Split by Type",
"type": "main",
"index": 0
}
]
]
},
"Split by Type": {
"main": [
[
{
"node": "Route by extracted_op",
"type": "main",
"index": 0
}
]
]
},
"Route by extracted_op": {
"main": [
[
{
"node": "Extract PDF",
"type": "main",
"index": 0
},
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract CSV",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract XLS",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract XLSX",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract HTML",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract JSON",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract Text",
"type": "main",
"index": 0
}
],
[
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
]
]
},
"Normalise Extracted Text": {
"main": [
[
{
"node": "Build Payload",
"type": "main",
"index": 0
}
]
]
},
"Build Payload": {
"main": [
[
{
"node": "Extract Items (LLM)",
"type": "main",
"index": 0
}
]
]
},
"Extract Items (LLM)": {
"main": [
[
{
"node": "Merge LLM Output",
"type": "main",
"index": 0
}
]
]
},
"Minimax Chat Model": {
"ai_languageModel": [
[
{
"node": "Extract Items (LLM)",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Merge LLM Output": {
"main": [
[
{
"node": "Plan: Find or Create Customer",
"type": "main",
"index": 0
}
]
]
},
"Plan: Find or Create Customer": {
"main": [
[
{
"node": "Lookup Customer by Email",
"type": "main",
"index": 0
}
]
]
},
"Lookup Customer by Email": {
"main": [
[
{
"node": "Parse Customer Result",
"type": "main",
"index": 0
}
]
]
},
"Parse Customer Result": {
"main": [
[
{
"node": "Is Customer New?",
"type": "main",
"index": 0
}
]
]
},
"Is Customer New?": {
"main": [
[
{
"node": "Create Customer",
"type": "main",
"index": 0
}
],
[
{
"node": "Plan: Find or Create Company",
"type": "main",
"index": 0
}
]
]
},
"Plan: Find or Create Company": {
"main": [
[
{
"node": "Skip Company if no hint",
"type": "main",
"index": 0
}
]
]
},
"Skip Company if no hint": {
"main": [
[
{
"node": "Lookup Company by Name",
"type": "main",
"index": 0
}
],
[
{
"node": "Plan: Generate Quote Number",
"type": "main",
"index": 0
}
]
]
},
"Lookup Company by Name": {
"main": [
[
{
"node": "Parse Company Result",
"type": "main",
"index": 0
}
]
]
},
"Parse Company Result": {
"main": [
[
{
"node": "Is Company New?",
"type": "main",
"index": 0
}
]
]
},
"Is Company New?": {
"main": [
[
{
"node": "Create Company",
"type": "main",
"index": 0
}
],
[
{
"node": "Plan: Generate Quote Number",
"type": "main",
"index": 0
}
]
]
},
"Plan: Generate Quote Number": {
"main": [
[
{
"node": "Lookup Counter (year, month)",
"type": "main",
"index": 0
}
]
]
},
"Lookup Counter (year, month)": {
"main": [
[
{
"node": "Parse Counter Result",
"type": "main",
"index": 0
}
]
]
},
"Parse Counter Result": {
"main": [
[
{
"node": "Is Counter New?",
"type": "main",
"index": 0
}
]
]
},
"Is Counter New?": {
"main": [
[
{
"node": "Create Counter (year, month)",
"type": "main",
"index": 0
}
],
[
{
"node": "Increment Counter (last_seq+)",
"type": "main",
"index": 0
}
]
]
},
"Format Quote Number": {
"main": [
[
{
"node": "Create Quote",
"type": "main",
"index": 0
}
]
]
},
"Create Quote": {
"main": [
[
{
"node": "Parse Quote Result",
"type": "main",
"index": 0
}
]
]
},
"Parse Quote Result": {
"main": [
[
{
"node": "Plan: Create Quote Items",
"type": "main",
"index": 0
}
]
]
},
"Plan: Create Quote Items": {
"main": [
[
{
"node": "Create Quote Item",
"type": "main",
"index": 0
}
]
]
},
"Create Quote Item": {
"main": [
[
{
"node": "Plan: Upload Attachments",
"type": "main",
"index": 0
}
]
]
},
"Plan: Upload Attachments": {
"main": [
[
{
"node": "Upload Attachment",
"type": "main",
"index": 0
}
]
]
},
"Upload Attachment": {
"main": [
[
{
"node": "Mark has_attachments",
"type": "main",
"index": 0
}
]
]
},
"Extract PDF": {
"main": [
[
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
]
]
},
"Extract CSV": {
"main": [
[
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
]
]
},
"Extract XLS": {
"main": [
[
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
]
]
},
"Extract XLSX": {
"main": [
[
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
]
]
},
"Extract HTML": {
"main": [
[
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
]
]
},
"Extract JSON": {
"main": [
[
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
]
]
},
"Extract Text": {
"main": [
[
{
"node": "Normalise Extracted Text",
"type": "main",
"index": 0
}
]
]
},
"Create Customer": {
"main": [
[
{
"node": "Plan: Find or Create Company",
"type": "main",
"index": 0
}
]
]
},
"Create Company": {
"main": [
[
{
"node": "Plan: Generate Quote Number",
"type": "main",
"index": 0
}
]
]
},
"Increment Counter (last_seq+)": {
"main": [
[
{
"node": "Format Quote Number",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true
},
"versionId": "a64f5ae0-4686-4675-b568-5ad7392d2934",
"meta": {
"aiBuilderAssisted": true,
"builderVariant": "mcp",
"instanceId": "05bcfa3b50919141d3600718ff4b7b2b52a3883525b05573a27ba5ef437ac7fb"
},
"nodeGroups": [],
"id": "eXSh623OjByIcip8",
"tags": []
}