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