WFDASH-WEBAPP/n8n_workfows/SNAPSHOT-WFDASH-v4-from-live-2026-06-20.json
SJCoetz 33591959c2 feat: add quote email intake and atomic numbering
This commit implements the full inbound quote email processing workflow and atomic quote numbering:
- Add PocketBase collections for monthly atomic quote counters, email attachments, and threaded replies
- Add idempotent migration script to set up new collections and add deduplication field to existing quotes table
- Implement `/api/v1/quotes/next-number` endpoint for properly formatted Q-YYYY-MM-NNN quote numbers
- Add MCP tools for idempotent quote creation, reply threading, and message ID lookup
- Add end-to-end and concurrent smoke tests for pipeline validation
- Update TypeScript types for new quote-related entities
- Update project documentation and package.json scripts
- Mark quote email intake as complete in the parity backlog
- Add n8n v2 workflows for automated email monitoring and error alerts
2026-06-22 13:58:24 +02:00

4641 lines
No EOL
184 KiB
JSON

{
"id": "eXSh623OjByIcip8",
"name": "WFDASH \u2014 Quote Email Monitor (v4 direct-to-PB)",
"active": true,
"isArchived": false,
"versionId": "79b6c9c8-2599-4a2f-867f-9d6ad5758046",
"activeVersionId": "79b6c9c8-2599-4a2f-867f-9d6ad5758046",
"triggerCount": 2,
"createdAt": "2026-06-14T20:19:24.183Z",
"updatedAt": "2026-06-16T23:05:43.968Z",
"settings": {
"executionOrder": "v1",
"availableInMCP": true,
"binaryMode": "separate"
},
"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": "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
}
]
]
},
"Webhook Test Trigger": {
"main": [
[
{
"node": "Webhook Body Unwrap",
"type": "main",
"index": 0
}
]
]
},
"Webhook Body Unwrap": {
"main": [
[
{
"node": "Parse Email",
"type": "main",
"index": 0
}
]
]
}
},
"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
]
},
{
"parameters": {
"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};",
"mode": "runOnceForEachItem",
"language": "javaScript"
},
"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"
}
}
},
"parameters": {
"url": "={{ $json.pb_url }}/api/collections/_superusers/auth-with-password"
}
},
"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 }), binary: ($binary || {}) };\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}), binary: ($binary || {}) };"
},
"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 \u2014 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.\n//\n// Fallback: in v4 the PocketBase Admin Auth HTTP call between Parse\n// Email and here strips the binary. If $binary is empty, fall back to\n// the Parse Email output, which captured the original IMAP binary.\nconst __bin = ($binary && Object.keys($binary).length > 0)\n ? $binary\n : ($('Parse Email').item.binary || {});\nreturn { json: Object.assign({}, $json || {}), binary: __bin };\n"
},
"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 \u2014 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;",
"language": "javaScript",
"mode": "runOnceForAllItems"
},
"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\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 _peItem = ($('Parse Email').first && $('Parse Email').first()) || $('Parse Email').item;\nconst _peJson = (_peItem && _peItem.json) || {};\nconst _pbaItem = (($('Parse PB Auth').first && $('Parse PB Auth').first()) || $('Parse PB Auth').item);\nconst _pbaJson = (_pbaItem && _pbaItem.json) || {};\nconst _pcItem = (($('PocketBase Connection').first && $('PocketBase Connection').first()) || $('PocketBase Connection').item);\nconst _pcJson = (_pcItem && _pcItem.json) || {};\nconst haveEmailOnFirst = !!(first._email_from_email || first.from_email);\nconst emailCtx = haveEmailOnFirst ? first : {\n _email_from_email: _peJson.from_email || '',\n _email_from_name: _peJson.from_name || '',\n _email_subject: _peJson.subject || '',\n _email_body_plain: _peJson.body_plain || '',\n _email_body_html: _peJson.body_html || '',\n _email_message_id: _peJson.message_id || '',\n _email_in_reply_to: _peJson.in_reply_to || '',\n _email_received_at: _peJson.received_at || '',\n _email_is_reply: !!_peJson.is_reply,\n _email_route: _peJson.route || 'new',\n _email_uid: _peJson.uid || '',\n _email_mailbox: _peJson.mailbox || 'INBOX',\n pb_url: _peJson.pb_url || _pbaJson.pb_url || _pcJson.pb_url || '',\n pb_token: _peJson.pb_token || _pbaJson.pb_token || _pcJson.pb_token || '',\n pb_admin_id: _peJson.pb_admin_id || _pbaJson.pb_admin_id || _pcJson.pb_admin_id || ''\n};\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\n// Authoritative source: Split by Type emits one item per attachment\n// with binary_key / filename / content_type / size AND the binary.\nlet splitByTypeItems = [];\ntry {\n const _st = $('Split by Type');\n if (_st) {\n if (typeof _st.all === 'function') {\n splitByTypeItems = _st.all();\n } else if (_st.item) {\n splitByTypeItems = [_st.item];\n }\n }\n} catch (e) {}\nconst metaByKey = new Map();\nfor (const it of splitByTypeItems) {\n if (it && it.json && it.json.binary_key != null) {\n metaByKey.set(it.json.binary_key, it);\n }\n}\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 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 '';\n}\n\nfor (const item of items) {\n const origKey = item.json.binary_key;\n const op = item.json.extracted_op || 'none';\n if (origKey == null && !metaByKey.get(origKey)) continue;\n const meta = (origKey != null) ? metaByKey.get(origKey) : null;\n const metaJson = (meta && meta.json) || {};\n const filename = metaJson.filename || item.json.filename || 'attachment';\n const contentType = metaJson.content_type || item.json.content_type || 'application/octet-stream';\n const size = Number(metaJson.size || item.json.size || 0);\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, filename, content_type: contentType, size,\n extracted_text: truncated\n });\n let b = null;\n if (item.binary && item.binary[origKey]) b = item.binary[origKey];\n else if (item.binary && item.binary.attachment) b = item.binary.attachment;\n else if (meta && meta.binary && meta.binary.attachment) b = meta.binary.attachment;\n else if (meta && meta.binary && meta.binary[origKey]) b = meta.binary[origKey];\n if (b) binary[origKey] = b;\n}\n\nfor (const [key, meta] of metaByKey.entries()) {\n if (attachments.some(a => a.binary_key === key)) continue;\n const mj = meta.json || {};\n attachments.push({\n binary_key: key, filename: mj.filename || 'attachment',\n content_type: mj.content_type || 'application/octet-stream',\n size: Number(mj.size || 0), extracted_text: ''\n });\n if (meta.binary && meta.binary.attachment) binary[key] = meta.binary.attachment;\n}\n\nreturn {\n json: {\n from_email: emailCtx._email_from_email || '',\n from_name: emailCtx._email_from_name || '',\n subject: emailCtx._email_subject || '',\n body_plain: emailCtx._email_body_plain || '',\n body_html: emailCtx._email_body_html || '',\n message_id: emailCtx._email_message_id || '',\n in_reply_to: emailCtx._email_in_reply_to || '',\n received_at: emailCtx._email_received_at || '',\n is_reply: !!emailCtx._email_is_reply,\n route: emailCtx._email_route || 'new',\n uid: emailCtx._email_uid || '',\n mailbox: emailCtx._email_mailbox || 'INBOX',\n pb_url: emailCtx.pb_url || '',\n pb_token: emailCtx.pb_token || '',\n pb_admin_id: emailCtx.pb_admin_id || '',\n attachments: attachments,\n has_attachments: attachments.length > 0\n },\n binary: binary\n};\n",
"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.\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 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\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 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};"
},
"language": "javaScript",
"mode": "runOnceForAllItems"
},
"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 }) };",
"parameters": {
"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 }) };"
},
"language": "javaScript"
},
"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
]
},
{
"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)\nconst now = new Date();\nconst year = now.getUTCFullYear();\nconst month = now.getUTCMonth() + 1;\n\nfunction synthesiseMessageId(sender, subj, date) {\n const input = (sender || '') + '|' + (subj || '') + '|' + (date || '');\n let b64;\n try { b64 = btoa(input); } catch (e) { b64 = Buffer.from(input, 'utf8').toString('base64'); }\n return 'synth-' + b64.replace(/=+$/, '') + '@wfdash.local';\n}\n\nconst realMessageId = String($json.message_id || '').trim();\nconst effectiveMessageId = realMessageId || synthesiseMessageId(\n $json.from_email || '', $json.subject || '', $json.received_at || $now.toISO()\n);\n\n// Pull pb_url / pb_token from Normalise Extracted Text (authoritative),\n// with fallbacks to Parse PB Auth and PocketBase Connection.\nlet pbUrl = '', pbToken = '', pbAdminId = '';\ntry {\n const _nx = $('Normalise Extracted Text');\n if (_nx) {\n const _nxItem = (typeof _nx.first === 'function' && _nx.first()) || _nx.item;\n const _nxJson = (_nxItem && _nxItem.json) || {};\n pbUrl = String(_nxJson.pb_url || '');\n pbToken = String(_nxJson.pb_token || '');\n pbAdminId = String(_nxJson.pb_admin_id || '');\n }\n} catch (e) {}\nif (!pbUrl || !pbToken) {\n try {\n const _pba = $('Parse PB Auth');\n const _pbaItem = (typeof _pba.first === 'function' && _pba.first()) || _pba.item;\n const _pbaJson = (_pbaItem && _pbaItem.json) || {};\n pbUrl = pbUrl || String(_pbaJson.pb_url || '');\n pbToken = pbToken || String(_pbaJson.pb_token || '');\n pbAdminId = pbAdminId || String(_pbaJson.pb_admin_id || '');\n } catch (e) {}\n}\nif (!pbUrl || !pbToken) {\n try {\n const _pc = $('PocketBase Connection');\n const _pcItem = (typeof _pc.first === 'function' && _pc.first()) || _pc.item;\n const _pcJson = (_pcItem && _pcItem.json) || {};\n pbUrl = pbUrl || String(_pcJson.pb_url || '');\n pbToken = pbToken || String(_pcJson.pb_token || '');\n pbAdminId = pbAdminId || String(_pcJson.pb_admin_id || '');\n } catch (e) {}\n}\n\nconst upstream = $('Plan: Find or Create Company');\nconst upstreamItem = (typeof upstream.first === 'function' && upstream.first()) || upstream.item;\nconst upstreamCtx = (upstreamItem && upstreamItem.json) || {};\n\nreturn { json: Object.assign({}, upstreamCtx, $json, {\n pb_url: pbUrl, pb_token: pbToken, pb_admin_id: pbAdminId,\n _counter_year: year, _counter_month: month,\n _effective_message_id: effectiveMessageId,\n _counter_filter: 'year=' + year + ' && month=' + month\n}) };\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//\n// IMPORTANT: in v4 the Plan: Create Quote Items chain runs after the\n// LLM and Create Quote Item and emits a per-line-item item, which does\n// NOT carry the email's attachments. We pull them from the upstream\n// Normalise Extracted Text output via a cross-node ref so the upload\n// path has everything it needs.\n// Pull attachments from the upstream Normalise Extracted Text output.\n// Try multiple cross-node accessors because n8n's behavior varies\n// depending on how many runs the upstream node had and the mode of\n// this Code node.\nlet _nxJson = {};\ntry {\n const _nx = $('Normalise Extracted Text');\n if (_nx) {\n if (typeof _nx.all === 'function') {\n const _all = _nx.all();\n for (const it of _all) {\n if (it && it.json && Array.isArray(it.json.attachments) && it.json.attachments.length > 0) {\n _nxJson = it.json;\n break;\n }\n }\n }\n if (!_nxJson.attachments && typeof _nx.first === 'function') {\n const _f = _nx.first();\n if (_f && _f.json) _nxJson = _f.json;\n }\n if (!_nxJson.attachments && _nx.item) {\n _nxJson = _nx.item.json || {};\n }\n }\n} catch (e) {\n // cross-node ref failed \u2014 fall through with empty\n}\nconst upstreamAtts = Array.isArray(_nxJson.attachments) ? _nxJson.attachments : [];\nconst upstreamPbUrl = String(_nxJson.pb_url || '');\nconst upstreamPbToken = String(_nxJson.pb_token || '');\n\nconst atts = upstreamAtts.filter(a => a.binary_key != null && a.filename);\nconst quoteId = String($json.quote_id || $json.quote || '');\nconst messageId = String($json.message_id || $json._effective_message_id || '');\nconst pbUrl = String($json.pb_url || upstreamPbUrl);\nconst pbToken = String($json.pb_token || upstreamPbToken);\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}));\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": "={{ $('Plan: Upload Attachments').item.json.pb_url }}/api/collections/quotes/records/{{ $('Plan: Upload Attachments').item.json.quote_id || $('Plan: Upload Attachments').item.json.quote }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('Plan: Upload Attachments').item.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"
},
{
"parameters": {
"httpMethod": "POST",
"path": "wf-test-quote-email-monitor",
"responseMode": "lastNode",
"options": {}
},
"id": "7b4f81c3-367e-427f-9632-33259aa8b47b",
"name": "Webhook Test Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
400,
480
],
"webhookId": "7b4f81c3-367e-427f-9632-33259aa8b47b"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Webhook Body Unwrap \u2014 translate Webhook output into the IMAP-shape\n// that the rest of the workflow expects. The Webhook node wraps the\n// POST body under $json.body; the IMAP trigger puts the email fields\n// on $json directly. We unwrap and also decode an optional base64\n// attachment from $json.body._attachment_b64 into $binary so the\n// attachment upload path is exercised in tests.\nconst body = ($json && $json.body) ? $json.body : $json;\nif (!body || typeof body !== 'object') {\n return { json: {}, binary: {} };\n}\nconst out = Object.assign({}, body);\n// Optional base64 attachment for the attachment-upload test path.\nconst bin = {};\nif (out._attachment_b64) {\n const key = out._attachment_key || 'attachment_0';\n bin[key] = {\n data: String(out._attachment_b64),\n fileName: String(out._attachment_filename || 'attachment.bin'),\n mimeType: String(out._attachment_content_type || 'application/octet-stream'),\n fileSize: Number(out._attachment_size || 0)\n };\n delete out._attachment_b64;\n delete out._attachment_key;\n delete out._attachment_filename;\n delete out._attachment_content_type;\n delete out._attachment_size;\n}\nreturn { json: out, binary: bin };\n"
},
"id": "7dead453-0e56-49e2-b851-5b07960bd2e6",
"name": "Webhook Body Unwrap",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
400,
864
]
}
],
"activeVersion": {
"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
]
},
{
"parameters": {
"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};",
"mode": "runOnceForEachItem",
"language": "javaScript"
},
"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"
}
}
},
"parameters": {
"url": "={{ $json.pb_url }}/api/collections/_superusers/auth-with-password"
}
},
"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 }), binary: ($binary || {}) };\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}), binary: ($binary || {}) };"
},
"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 \u2014 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.\n//\n// Fallback: in v4 the PocketBase Admin Auth HTTP call between Parse\n// Email and here strips the binary. If $binary is empty, fall back to\n// the Parse Email output, which captured the original IMAP binary.\nconst __bin = ($binary && Object.keys($binary).length > 0)\n ? $binary\n : ($('Parse Email').item.binary || {});\nreturn { json: Object.assign({}, $json || {}), binary: __bin };\n"
},
"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 \u2014 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;",
"language": "javaScript",
"mode": "runOnceForAllItems"
},
"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\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 _peItem = ($('Parse Email').first && $('Parse Email').first()) || $('Parse Email').item;\nconst _peJson = (_peItem && _peItem.json) || {};\nconst _pbaItem = (($('Parse PB Auth').first && $('Parse PB Auth').first()) || $('Parse PB Auth').item);\nconst _pbaJson = (_pbaItem && _pbaItem.json) || {};\nconst _pcItem = (($('PocketBase Connection').first && $('PocketBase Connection').first()) || $('PocketBase Connection').item);\nconst _pcJson = (_pcItem && _pcItem.json) || {};\nconst haveEmailOnFirst = !!(first._email_from_email || first.from_email);\nconst emailCtx = haveEmailOnFirst ? first : {\n _email_from_email: _peJson.from_email || '',\n _email_from_name: _peJson.from_name || '',\n _email_subject: _peJson.subject || '',\n _email_body_plain: _peJson.body_plain || '',\n _email_body_html: _peJson.body_html || '',\n _email_message_id: _peJson.message_id || '',\n _email_in_reply_to: _peJson.in_reply_to || '',\n _email_received_at: _peJson.received_at || '',\n _email_is_reply: !!_peJson.is_reply,\n _email_route: _peJson.route || 'new',\n _email_uid: _peJson.uid || '',\n _email_mailbox: _peJson.mailbox || 'INBOX',\n pb_url: _peJson.pb_url || _pbaJson.pb_url || _pcJson.pb_url || '',\n pb_token: _peJson.pb_token || _pbaJson.pb_token || _pcJson.pb_token || '',\n pb_admin_id: _peJson.pb_admin_id || _pbaJson.pb_admin_id || _pcJson.pb_admin_id || ''\n};\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\n// Authoritative source: Split by Type emits one item per attachment\n// with binary_key / filename / content_type / size AND the binary.\nlet splitByTypeItems = [];\ntry {\n const _st = $('Split by Type');\n if (_st) {\n if (typeof _st.all === 'function') {\n splitByTypeItems = _st.all();\n } else if (_st.item) {\n splitByTypeItems = [_st.item];\n }\n }\n} catch (e) {}\nconst metaByKey = new Map();\nfor (const it of splitByTypeItems) {\n if (it && it.json && it.json.binary_key != null) {\n metaByKey.set(it.json.binary_key, it);\n }\n}\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 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 '';\n}\n\nfor (const item of items) {\n const origKey = item.json.binary_key;\n const op = item.json.extracted_op || 'none';\n if (origKey == null && !metaByKey.get(origKey)) continue;\n const meta = (origKey != null) ? metaByKey.get(origKey) : null;\n const metaJson = (meta && meta.json) || {};\n const filename = metaJson.filename || item.json.filename || 'attachment';\n const contentType = metaJson.content_type || item.json.content_type || 'application/octet-stream';\n const size = Number(metaJson.size || item.json.size || 0);\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, filename, content_type: contentType, size,\n extracted_text: truncated\n });\n let b = null;\n if (item.binary && item.binary[origKey]) b = item.binary[origKey];\n else if (item.binary && item.binary.attachment) b = item.binary.attachment;\n else if (meta && meta.binary && meta.binary.attachment) b = meta.binary.attachment;\n else if (meta && meta.binary && meta.binary[origKey]) b = meta.binary[origKey];\n if (b) binary[origKey] = b;\n}\n\nfor (const [key, meta] of metaByKey.entries()) {\n if (attachments.some(a => a.binary_key === key)) continue;\n const mj = meta.json || {};\n attachments.push({\n binary_key: key, filename: mj.filename || 'attachment',\n content_type: mj.content_type || 'application/octet-stream',\n size: Number(mj.size || 0), extracted_text: ''\n });\n if (meta.binary && meta.binary.attachment) binary[key] = meta.binary.attachment;\n}\n\nreturn {\n json: {\n from_email: emailCtx._email_from_email || '',\n from_name: emailCtx._email_from_name || '',\n subject: emailCtx._email_subject || '',\n body_plain: emailCtx._email_body_plain || '',\n body_html: emailCtx._email_body_html || '',\n message_id: emailCtx._email_message_id || '',\n in_reply_to: emailCtx._email_in_reply_to || '',\n received_at: emailCtx._email_received_at || '',\n is_reply: !!emailCtx._email_is_reply,\n route: emailCtx._email_route || 'new',\n uid: emailCtx._email_uid || '',\n mailbox: emailCtx._email_mailbox || 'INBOX',\n pb_url: emailCtx.pb_url || '',\n pb_token: emailCtx.pb_token || '',\n pb_admin_id: emailCtx.pb_admin_id || '',\n attachments: attachments,\n has_attachments: attachments.length > 0\n },\n binary: binary\n};\n",
"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.\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 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\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 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};"
},
"language": "javaScript",
"mode": "runOnceForAllItems"
},
"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 }) };",
"parameters": {
"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 }) };"
},
"language": "javaScript"
},
"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
]
},
{
"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)\nconst now = new Date();\nconst year = now.getUTCFullYear();\nconst month = now.getUTCMonth() + 1;\n\nfunction synthesiseMessageId(sender, subj, date) {\n const input = (sender || '') + '|' + (subj || '') + '|' + (date || '');\n let b64;\n try { b64 = btoa(input); } catch (e) { b64 = Buffer.from(input, 'utf8').toString('base64'); }\n return 'synth-' + b64.replace(/=+$/, '') + '@wfdash.local';\n}\n\nconst realMessageId = String($json.message_id || '').trim();\nconst effectiveMessageId = realMessageId || synthesiseMessageId(\n $json.from_email || '', $json.subject || '', $json.received_at || $now.toISO()\n);\n\n// Pull pb_url / pb_token from Normalise Extracted Text (authoritative),\n// with fallbacks to Parse PB Auth and PocketBase Connection.\nlet pbUrl = '', pbToken = '', pbAdminId = '';\ntry {\n const _nx = $('Normalise Extracted Text');\n if (_nx) {\n const _nxItem = (typeof _nx.first === 'function' && _nx.first()) || _nx.item;\n const _nxJson = (_nxItem && _nxItem.json) || {};\n pbUrl = String(_nxJson.pb_url || '');\n pbToken = String(_nxJson.pb_token || '');\n pbAdminId = String(_nxJson.pb_admin_id || '');\n }\n} catch (e) {}\nif (!pbUrl || !pbToken) {\n try {\n const _pba = $('Parse PB Auth');\n const _pbaItem = (typeof _pba.first === 'function' && _pba.first()) || _pba.item;\n const _pbaJson = (_pbaItem && _pbaItem.json) || {};\n pbUrl = pbUrl || String(_pbaJson.pb_url || '');\n pbToken = pbToken || String(_pbaJson.pb_token || '');\n pbAdminId = pbAdminId || String(_pbaJson.pb_admin_id || '');\n } catch (e) {}\n}\nif (!pbUrl || !pbToken) {\n try {\n const _pc = $('PocketBase Connection');\n const _pcItem = (typeof _pc.first === 'function' && _pc.first()) || _pc.item;\n const _pcJson = (_pcItem && _pcItem.json) || {};\n pbUrl = pbUrl || String(_pcJson.pb_url || '');\n pbToken = pbToken || String(_pcJson.pb_token || '');\n pbAdminId = pbAdminId || String(_pcJson.pb_admin_id || '');\n } catch (e) {}\n}\n\nconst upstream = $('Plan: Find or Create Company');\nconst upstreamItem = (typeof upstream.first === 'function' && upstream.first()) || upstream.item;\nconst upstreamCtx = (upstreamItem && upstreamItem.json) || {};\n\nreturn { json: Object.assign({}, upstreamCtx, $json, {\n pb_url: pbUrl, pb_token: pbToken, pb_admin_id: pbAdminId,\n _counter_year: year, _counter_month: month,\n _effective_message_id: effectiveMessageId,\n _counter_filter: 'year=' + year + ' && month=' + month\n}) };\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//\n// IMPORTANT: in v4 the Plan: Create Quote Items chain runs after the\n// LLM and Create Quote Item and emits a per-line-item item, which does\n// NOT carry the email's attachments. We pull them from the upstream\n// Normalise Extracted Text output via a cross-node ref so the upload\n// path has everything it needs.\n// Pull attachments from the upstream Normalise Extracted Text output.\n// Try multiple cross-node accessors because n8n's behavior varies\n// depending on how many runs the upstream node had and the mode of\n// this Code node.\nlet _nxJson = {};\ntry {\n const _nx = $('Normalise Extracted Text');\n if (_nx) {\n if (typeof _nx.all === 'function') {\n const _all = _nx.all();\n for (const it of _all) {\n if (it && it.json && Array.isArray(it.json.attachments) && it.json.attachments.length > 0) {\n _nxJson = it.json;\n break;\n }\n }\n }\n if (!_nxJson.attachments && typeof _nx.first === 'function') {\n const _f = _nx.first();\n if (_f && _f.json) _nxJson = _f.json;\n }\n if (!_nxJson.attachments && _nx.item) {\n _nxJson = _nx.item.json || {};\n }\n }\n} catch (e) {\n // cross-node ref failed \u2014 fall through with empty\n}\nconst upstreamAtts = Array.isArray(_nxJson.attachments) ? _nxJson.attachments : [];\nconst upstreamPbUrl = String(_nxJson.pb_url || '');\nconst upstreamPbToken = String(_nxJson.pb_token || '');\n\nconst atts = upstreamAtts.filter(a => a.binary_key != null && a.filename);\nconst quoteId = String($json.quote_id || $json.quote || '');\nconst messageId = String($json.message_id || $json._effective_message_id || '');\nconst pbUrl = String($json.pb_url || upstreamPbUrl);\nconst pbToken = String($json.pb_token || upstreamPbToken);\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}));\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": "={{ $('Plan: Upload Attachments').item.json.pb_url }}/api/collections/quotes/records/{{ $('Plan: Upload Attachments').item.json.quote_id || $('Plan: Upload Attachments').item.json.quote }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('Plan: Upload Attachments').item.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"
},
{
"parameters": {
"httpMethod": "POST",
"path": "wf-test-quote-email-monitor",
"responseMode": "lastNode",
"options": {}
},
"id": "7b4f81c3-367e-427f-9632-33259aa8b47b",
"name": "Webhook Test Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
400,
480
],
"webhookId": "7b4f81c3-367e-427f-9632-33259aa8b47b"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Webhook Body Unwrap \u2014 translate Webhook output into the IMAP-shape\n// that the rest of the workflow expects. The Webhook node wraps the\n// POST body under $json.body; the IMAP trigger puts the email fields\n// on $json directly. We unwrap and also decode an optional base64\n// attachment from $json.body._attachment_b64 into $binary so the\n// attachment upload path is exercised in tests.\nconst body = ($json && $json.body) ? $json.body : $json;\nif (!body || typeof body !== 'object') {\n return { json: {}, binary: {} };\n}\nconst out = Object.assign({}, body);\n// Optional base64 attachment for the attachment-upload test path.\nconst bin = {};\nif (out._attachment_b64) {\n const key = out._attachment_key || 'attachment_0';\n bin[key] = {\n data: String(out._attachment_b64),\n fileName: String(out._attachment_filename || 'attachment.bin'),\n mimeType: String(out._attachment_content_type || 'application/octet-stream'),\n fileSize: Number(out._attachment_size || 0)\n };\n delete out._attachment_b64;\n delete out._attachment_key;\n delete out._attachment_filename;\n delete out._attachment_content_type;\n delete out._attachment_size;\n}\nreturn { json: out, binary: bin };\n"
},
"id": "7dead453-0e56-49e2-b851-5b07960bd2e6",
"name": "Webhook Body Unwrap",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
400,
864
]
}
],
"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": "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
}
]
]
},
"Webhook Test Trigger": {
"main": [
[
{
"node": "Webhook Body Unwrap",
"type": "main",
"index": 0
}
]
]
},
"Webhook Body Unwrap": {
"main": [
[
{
"node": "Parse Email",
"type": "main",
"index": 0
}
]
]
}
}
},
"tags": [],
"meta": {
"aiBuilderAssisted": true,
"builderVariant": "mcp"
},
"parentFolderId": null,
"description": "Path A direct-to-PocketBase, no MCP, no AI tool calls, no binary round-trip, no $env vars. Set node holds PB URL + admin email/password, then a one-time POST /api/admins/auth-with-password mints a JWT used as Bearer auth.",
"scopes": [
"execution:reveal",
"workflow:create",
"workflow:delete",
"workflow:disableRedaction",
"workflow:enableRedaction",
"workflow:execute",
"workflow:execute-chat",
"workflow:export",
"workflow:import",
"workflow:list",
"workflow:move",
"workflow:publish",
"workflow:read",
"workflow:share",
"workflow:unpublish",
"workflow:unshare",
"workflow:update"
],
"canExecute": true
}