WFDASH-WEBAPP/n8n_workfows/WFDASH — Quote Email Monitor (v5 catch-and-create).PRE-FIX-REPLY-BRANCH-AUTH.json
Dave cd359b75a9 feat(quotes): split attachments into internal vs outbound with operator upload UI
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).

Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.

API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`

MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.

Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.

Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
  (`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
  `email_outbound_attachment_ids`, downloads each from
  `/api/files/quotes/<id>/<token>`, and emits
  `options.attachments: [{name, content, type}]` consumed by
  `Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
  `retryQuoteEmail` can find failed sends (it only accepts the
  latter). Previously failed sends were stranded.

Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
  pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
  (/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
  and current `Q202609246` formats; canonicalised digits-only for
  PB `~` substring match.

Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.

Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.

Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes

Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
2026-09-09 13:43:56 +02:00

5270 lines
No EOL
225 KiB
JSON

{
"updatedAt": "2026-09-09T09:07:17.373Z",
"createdAt": "2026-06-14T20:19:24.183Z",
"id": "eXSh623OjByIcip8",
"name": "WFDASH \u2014 Quote Email Monitor (v5 catch-and-create)",
"description": "Catch and Create: uploads attachments to PB immediately after auth (while binary is fresh), then PATCHes them to link to the quote after quote creation. Fixes v4's silent binary-stripping bug.",
"active": true,
"isArchived": false,
"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": [
224,
928
],
"credentials": {
"imap": {
"id": "OxBkyAvgByqMElJG",
"name": "GIELIE IMAP"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse Email (v5 \u2014 preserves _attachment_record_ids from upstream Catch and Create)\n// Merges attachment IDs from input (set by Catch and Create Uploader) into\n// the email context so they survive the rest of the chain.\nconst item = $json || {};\n\n// Pull attachment IDs that Catch and Create Uploader attached upstream.\nlet attIds = [];\nlet attRecords = [];\nlet attCount = 0;\ntry { attIds = ($('Catch and Create Uploader').first().json && $('Catch and Create Uploader').first().json._attachment_record_ids) || []; } catch(e) {}\ntry { attRecords = ($('Catch and Create Uploader').first().json && $('Catch and Create Uploader').first().json._attachment_records) || []; } catch(e) {}\nattCount = Array.isArray(attIds) ? attIds.length : 0;\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)';\n\n// === BULLETPROOF QUOTE-NUMBER DETECTION (FIX 2026-09-09) ===\n// The previous regex `/Q-\\d{4}-\\d{2}-\\d{3}/` required literal dashes\n// between year/month/seq, but every real outbound subject uses the\n// dashless format `QUOTE-Q202609246 REF -`. Replies were falling\n// through to the \"new\" branch and creating duplicate quotes.\n//\n// New regex accepts both legacy (`Q-2026-09-246`) and current\n// (`Q202609246`) shapes, with optional dashes between groups.\nconst quoteNumberPattern = /Q-?\\d{4}-?\\d{2}-?\\d{3}/;\nconst quoteNumberMatch = subject.match(quoteNumberPattern);\n\n// Canonicalise to digits-only \u2014 this is what we feed to PB's\n// `quote_number~` (contains) so it matches whether PB stores the\n// value as `Q202609246` or `Q-2026-09-246`.\nconst quoteNumberRaw = quoteNumberMatch ? quoteNumberMatch[0] : '';\nconst quoteNumberCanonical = quoteNumberRaw\n ? quoteNumberRaw.replace(/[^0-9]/g, '')\n : '';\n\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: attCount > 0 || attachmentsMeta.length > 0,\n is_reply: isReply,\n route: isReply ? 'reply' : 'new',\n uid: uid,\n mailbox: mailbox,\n _attachment_record_ids: attIds,\n _attachment_records: attRecords,\n _attachment_count: attCount,\n // NEW: quote-number fields used by Encode Reply Filters for the\n // bulletproof subject-fallback filter. Canonical is digits-only so\n // it matches both `Q202609246` and `Q-2026-09-246` via PB's\n // `quote_number~` substring operator.\n quote_number_raw: quoteNumberRaw,\n quote_number_canonical: quoteNumberCanonical,\n },\n binary: $binary || {}\n};\n"
},
"id": "caa0d1d4-a79a-40d1-badd-2bba84712121",
"name": "Parse Email",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
672,
832
]
},
{
"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": [
896,
832
]
},
{
"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": [
1120,
832
]
},
{
"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": [
0,
0
],
"disabled": true
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse PB Auth (v5 simplified \u2014 auth happens inline upstream)\n// Collect Attachment IDs already carries pb_token + email context.\n// We just enrich with pb_admin_id and forward.\nlet ctx = {};\ntry { ctx = $('Collect Attachment IDs').first().json || {}; } catch(e) {}\nif (!ctx.pb_token) {\n try { ctx = $('PocketBase Connection').item.json || {}; } catch(e) {}\n}\nif (!ctx.pb_token) {\n throw new Error('No pb_token on item \u2014 auth inline must have failed');\n}\nreturn {\n json: Object.assign({}, ctx, { pb_admin_id: ctx.pb_admin_id || '' }),\n binary: $binary || {}\n};\n"
},
"id": "6e1f9349-0489-46a6-9e97-653e2e46202c",
"name": "Parse PB Auth",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
0,
224
],
"disabled": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"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,
832
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Encode Reply Filters\n// Pre-encode the two PB filters used by the reply-branch HTTP nodes.\n//\n// FIX 2026-09-09 (3 changes):\n// 1. Subject filter no longer passes the WHOLE subject to PB's\n// `quote_number~` (contains). Uses the digits-only canonical form\n// extracted by Parse Email (`quote_number_canonical`, e.g.\n// \"202609246\") so PB's substring matches whether PB stores\n// `Q202609246` or `Q-2026-09-246`.\n// 2. If Parse Email couldn't pull a canonical number (no Q-digit\n// pattern in subject), the filter is set to a guaranteed no-match\n// sentinel so PB returns zero records instead of \"all records\".\n// 3. In-Reply-To uses a SUBSTRING match on the BARE UUID (no `<>`,\n// no `@domain`). Reasons:\n// (a) PB only accepts lowercase `%3c`/`%3e` for `<`/`>`. URL\n// encoders (incl. n8n's HTTP client) sometimes emit uppercase\n// percent escapes, breaking the filter silently.\n// (b) Parse Email strips `<>` from message-id headers per RFC\n// 5322. PB stores them with brackets. A substring match on\n// the bracket-less value works either way.\n// (c) The UUID portion of a message-id is the only universally\n// unique segment. `@domain` differs between senders; angle\n// brackets differ between clients; the UUID is canonical.\n// The UUID regex tolerates all common message-id formats.\n//\n// FIX 2026-09-09 (4th change \u2014 auth fix): the empty `in_reply_to`\n// fallback used to be `''` (empty filter string). PB interprets an\n// empty filter as \"no filter\" and would return the first record in\n// the collection with `perPage=1`, which is the opposite of the\n// intended \"no match\" behaviour. Now uses the same no-match sentinel\n// pattern as the subject fallback: `id=\"__NO_MATCH__\"`, which is\n// guaranteed not to match any record (record ids are 15-char PB base62\n// strings).\nconst inReplyToRaw = String($json.in_reply_to || '').trim();\nconst quoteNum = String($json.quote_number_canonical || '').trim();\n\n// Pull the bare UUID out of the message-id. Tolerates brackets, @domain,\n// and capital hex. Falls back to the full bracket-less string when no\n// UUID-shaped token is present (e.g. weird custom message-ids).\nconst uuidMatch = inReplyToRaw.match(/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/);\nconst inReplySubstr = uuidMatch ? uuidMatch[0] : inReplyToRaw;\n\n// In-Reply-To: substring match on the UUID (or fallback to bracket-less).\n// Missing/empty \u2192 sentinel that no record's id equals.\nconst inReplyFilter = inReplySubstr\n ? 'email_message_id~' + JSON.stringify(inReplySubstr)\n : 'id=' + JSON.stringify('__NO_MATCH__');\n\n// Subject fallback: substring on digits-only canonical quote number.\n// Empty canonical \u2192 sentinel that no quote_number equals.\nconst subjectFilter = quoteNum\n ? 'quote_number~' + JSON.stringify(quoteNum)\n : 'quote_number=' + JSON.stringify('__NO_MATCH__');\n\nreturn { json: Object.assign({}, $json, {\n _reply_filter_in_reply_to: inReplyFilter,\n _reply_filter_subject: subjectFilter,\n // Keep the bracketless form downstream in case any other node needs it.\n in_reply_to_canonical: inReplyToRaw,\n}) };\n"
},
"id": "85b15e0f-fc92-4e63-9bee-3897d5ff3420",
"name": "Encode Reply Filters",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
448
]
},
{
"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,
448
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Decide Reply Lookup\n// Reads the In-Reply-To PB response and decides whether we have a match.\n//\n// FIX 2026-09-09: the previous version spread `$('Parse Email').item.json`,\n// which DROPPED `pb_url`, `pb_token`, AND `_reply_filter_subject` that the\n// next node (Find Target Quote by Subject) needs to build its URL. The\n// Subject lookup then crashed with \"Invalid URL\" because `pb_url` was\n// undefined. This was masked previously because the regex bug prevented\n// replies from ever reaching this branch.\n//\n// We now spread `$('Encode Reply Filters').first().json`, which carries\n// the full upstream context (pb_url, pb_token, message_id, etc.) PLUS\n// the filter strings the next HTTP node needs.\nconst body = $json || {};\nconst items = Array.isArray(body.items) ? body.items : [];\nconst first = items[0] || null;\n\nlet upstream = {};\ntry {\n upstream = $('Encode Reply Filters').first().json || {};\n} catch (e) {\n // Encode Reply Filters not reachable \u2014 fall back to Parse Email\n upstream = $('Parse Email').item.json || {};\n}\n\nreturn { json: Object.assign({}, upstream, {\n _reply_quote_id: first ? (first.id || '') : '',\n // Stash the In-Reply-To response so any debug step downstream can see\n // whether the lookup actually succeeded.\n _in_reply_lookup_total: typeof body.totalItems === 'number' ? body.totalItems : -1,\n}) };\n"
},
"id": "c5fce964-64f1-4c22-8335-75c03bd73174",
"name": "Decide Reply Lookup",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2016,
448
]
},
{
"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,
448
]
},
{
"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,
448
]
},
{
"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": [
2688,
448
]
},
{
"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": [
2976,
448
]
},
{
"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,
1216
]
},
{
"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;"
},
"id": "05e67b7e-ddc5-491b-aa5f-fb5dddd5498e",
"name": "Split by Type",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1792,
1216
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "pdf",
"id": "f665e9eb-45fd-4e35-9add-da8eb6a7200b"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "pdf"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "csv",
"id": "7273cbe5-dcd7-401a-9981-eae39e558a60"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "csv"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "xls",
"id": "317d8798-b0c7-40e3-8304-dfd9ea03ad45"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "xls"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "xlsx",
"id": "827396dc-aa0b-4fc1-92e2-c05fe0bf4a9a"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "xlsx"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "html",
"id": "2c5bb0e4-eb83-4c51-a71b-38567d394b5b"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "html"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "json",
"id": "859f1744-c5d7-439d-8c74-43ca98b30785"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "json"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "text",
"id": "80eec99a-0d96-46c0-88a9-f6ec7d2fc07e"
}
],
"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,
1120
]
},
{
"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,
736
],
"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,
1024
],
"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,
1216
],
"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,
1408
],
"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,
1600
],
"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,
1792
],
"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,
1984
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Normalise Extracted Text (v5 \u2014 reads all context from input items)\n// In v4 this node had to merge fields from MULTIPLE upstream nodes\n// (Parse Email, Parse PB Auth, PocketBase Connection) because each only\n// carried part of the context. In v5, Catch and Create Uploader + Parse\n// Email + PocketBase Connection all merge context into the items before\n// they reach us \u2014 so we just read from items[0].json and we're done.\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 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 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.from_email || '',\n from_name: first.from_name || '',\n subject: first.subject || '',\n body_plain: first.body_plain || '',\n body_html: first.body_html || '',\n message_id: first.message_id || '',\n in_reply_to: first.in_reply_to || '',\n received_at: first.received_at || '',\n is_reply: !!first.is_reply,\n route: first.route || 'new',\n uid: first.uid || '',\n mailbox: first.mailbox || 'INBOX',\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: first.has_attachments !== undefined ? first.has_attachments : (attachments.some(a => a.binary_key != null)),\n // Catch and Create Uploader outputs \u2014 must survive through this node\n _attachment_record_ids: first._attachment_record_ids || [],\n _attachment_records: first._attachment_records || [],\n _attachment_count: first._attachment_count || 0,\n },\n binary: binary\n};\n"
},
"id": "a332928d-f077-4c31-82ed-4347d3f80776",
"name": "Normalise Extracted Text",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2464,
1312
]
},
{
"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": [
2688,
1312
]
},
{
"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": [
2912,
1312
]
},
{
"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": [
2912,
1536
],
"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": [
3264,
1312
]
},
{
"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": [
3488,
1312
]
},
{
"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": [
3680,
1424
],
"disabled": true
},
{
"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": [
3936,
1312
]
},
{
"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": [
4160,
1312
]
},
{
"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": [
4384,
1472
],
"disabled": true
},
{
"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": [
4608,
1312
]
},
{
"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": [
4832,
1312
]
},
{
"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": [
5056,
1248
]
},
{
"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": [
5280,
1248
]
},
{
"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": [
5504,
1248
]
},
{
"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": [
5744,
976
]
},
{
"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": [
5888,
1568
]
},
{
"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": [
6176,
1312
]
},
{
"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": [
6400,
1312
]
},
{
"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": [
6624,
1312
]
},
{
"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": [
6848,
1408
]
},
{
"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": [
7072,
1408
]
},
{
"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": [
7296,
1408
]
},
{
"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": [
7520,
1408
]
},
{
"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": [
7744,
1408
]
},
{
"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": [
7968,
1408
],
"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": [
8192,
1600
],
"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": "Upload Attachment",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
8416,
1600
],
"disabled": true,
"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": [
8640,
1504
],
"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": [
0,
736
],
"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": [
224,
736
]
},
{
"parameters": {
"jsCode": "// Link Attachments to Quote (FIXED mode \u2014 uses $input.all())\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) ? ctx._attachment_record_ids : [];\n\nif (!quoteId) {\n return [{ json: Object.assign({}, ctx, { _skip: true, reason: 'no quote_id' }) }];\n}\n\nif (ids.length === 0) {\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": "v5-link-attachments-to-quote",
"name": "Link Attachments to Quote",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
8192,
1408
]
},
{
"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": "v5-patch-attachment-set-quote",
"name": "PATCH Attachment (set quote)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
8416,
1408
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Catch and Create Uploader v19 \u2014 debug PB response\nconst inputs = $input.all();\nif (inputs.length === 0) {\n return [{ json: { _attachment_record_ids: [], has_attachments: false }, binary: {} }];\n}\n\nconst emailItem = inputs[0];\nconst emailJson = emailItem.json || {};\nconst emailBinary = emailItem.binary || {};\n\nconst PB_URL = 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';\nconst PB_EMAIL = 'david.els1@outlook.com';\nconst PB_PASSWORD = '1234567890';\n\nfunction pbNow() {\n const d = new Date();\n return d.getUTCFullYear() + '-' +\n String(d.getUTCMonth() + 1).padStart(2, '0') + '-' +\n String(d.getUTCDate()).padStart(2, '0') + ' ' +\n String(d.getUTCHours()).padStart(2, '0') + ':' +\n String(d.getUTCMinutes()).padStart(2, '0') + ':' +\n String(d.getUTCSeconds()).padStart(2, '0') + '.' +\n String(d.getUTCMilliseconds()).padStart(3, '0') + 'Z';\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/')) return 'image';\n if (/\\.(docx?|xlsx?|csv)$/.test(fn)) return 'doc';\n return 'other';\n}\n\nconst debug = [];\n\nlet pbToken = '';\ntry {\n const authResp = await this.helpers.httpRequest({\n method: 'POST', url: PB_URL + '/api/collections/_superusers/auth-with-password',\n headers: { 'Content-Type': 'application/json' },\n body: { identity: PB_EMAIL, password: PB_PASSWORD },\n json: true, timeout: 15000,\n });\n pbToken = String(authResp.token || '');\n} catch (authErr) {\n throw new Error('PB auth failed: ' + authErr.message);\n}\n\nconst attachmentIds = [];\nconst attachmentDetails = [];\n\nfor (const key of Object.keys(emailBinary)) {\n const b = emailBinary[key];\n if (!b || !b.data) { debug.push({skip: 'no data'}); continue; }\n \n const filename = String(b.fileName || 'attachment');\n const contentType = String(b.mimeType || 'application/octet-stream');\n const sizeBytes = Number(b.fileSize || 0);\n \n const boundary = '----n8nFormBoundary' + Math.random().toString(36).substring(2);\n const parts = [];\n \n const fields = {\n filename: filename,\n content_type: contentType,\n size_bytes: String(sizeBytes),\n email_message_id: String(emailJson.message_id || emailJson._effective_message_id || ''),\n uploaded_at: pbNow(),\n kind: kindFor(contentType, filename),\n };\n \n for (const [fname, value] of Object.entries(fields)) {\n parts.push(Buffer.from('--' + boundary + '\\r\\n' +\n 'Content-Disposition: form-data; name=\"' + fname + '\"\\r\\n\\r\\n' +\n value + '\\r\\n', 'utf8'));\n }\n \n const fileBuf = Buffer.from(b.data, 'base64');\n parts.push(Buffer.from('--' + boundary + '\\r\\n' +\n 'Content-Disposition: form-data; name=\"file\"; filename=\"' + filename + '\"\\r\\n' +\n 'Content-Type: ' + contentType + '\\r\\n\\r\\n', 'utf8'));\n const postFile = Buffer.from('\\r\\n--' + boundary + '--\\r\\n', 'utf8');\n const body = Buffer.concat([...parts, fileBuf, postFile]);\n \n debug.push({key, bodyLen: body.length, boundary: boundary});\n \n try {\n const uploadResp = await this.helpers.httpRequest({\n method: 'POST',\n url: PB_URL + '/api/collections/quote_attachments/records',\n headers: {\n 'Authorization': 'Bearer ' + pbToken,\n 'Content-Type': 'multipart/form-data; boundary=' + boundary,\n },\n body: body,\n json: true,\n timeout: 30000,\n });\n \n debug.push({upload: 'ok', resp: JSON.stringify(uploadResp).slice(0, 500)});\n if (uploadResp && uploadResp.id) {\n attachmentIds.push(String(uploadResp.id));\n attachmentDetails.push({id: String(uploadResp.id), filename, content_type: contentType, size_bytes: sizeBytes, kind: kindFor(contentType, filename)});\n }\n } catch (uploadErr) {\n debug.push({upload: 'fail', err: String(uploadErr.message || uploadErr).slice(0, 500)});\n }\n}\n\nreturn [{\n json: Object.assign({}, emailJson, {\n _attachment_record_ids: attachmentIds,\n _attachment_records: attachmentDetails,\n _attachment_count: attachmentIds.length,\n has_attachments: attachmentIds.length > 0,\n pb_url: PB_URL,\n pb_token: pbToken,\n pb_admin_id: '',\n _debug: debug,\n }),\n binary: emailBinary,\n}];\n"
},
"id": "661f02e5-1009-479d-afc7-681fffd4d57a",
"name": "Catch and Create Uploader",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
448,
832
]
},
{
"parameters": {
"parameters": {},
"requestOptions": {}
},
"id": "d2cfecc1-f8b8-4bbb-a368-07a5651eee14",
"name": "PB Upload Test",
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
640,
-16
],
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"resource": "quote_attachments",
"operation": "create",
"parameters": {
"allElements": true
},
"bodyType": [
"binaryData"
],
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
1136,
1104
],
"id": "3e3f8dbf-41b9-471b-8668-da8432dafe61",
"name": "Create quote_attachments",
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"resource": "customers",
"parameters": {
"allElements": true
},
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
3696,
1120
],
"id": "829f1111-4d83-4bfe-9d2c-2576bee89102",
"name": "Search customers",
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"resource": "customers",
"operation": "create",
"parameters": {},
"bodyType": [],
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
4384,
1136
],
"id": "dfe8a810-dea6-4402-bd00-cdec079a59ff",
"name": "Create customers",
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"resource": "quote_counters",
"operation": "create",
"parameters": {
"allElements": true
},
"bodyType": [
"bodyJson"
],
"bodyJson": "={{ { \"year\": $json._counter_year, \"month\": $json._counter_month, \"last_seq\": 1 } }}",
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
6848,
1216
],
"id": "b30ab459-501b-4d45-829c-dd82a1747598",
"name": "Create quote_counters",
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// PB Auth Inline (v5 \u2014 reply-branch auth)\n// Authenticate with PB admin once, set pb_token + pb_admin_id\n// on the shared context. Both the reply branch (Find Target\n// Quote, Insert quote_replies, Flip has_new_reply) and the\n// create branch (Carry Email Ctx \u2192 downstream HTTP nodes)\n// consume $json.pb_token.\n//\n// The create branch ALSO calls auth inline inside Catch and\n// Create Uploader for its own attachment uploads. That's\n// intentional \u2014 its token is overwritten downstream anyway\n// and the duplicate call is cheap against local PB.\n//\n// On auth failure: throw so the operator sees the failure in\n// the n8n UI rather than silently producing an empty token\n// and a confusing 401 from the next HTTP node.\n\nconst input = $json || {};\nconst pbUrl = String(input.pb_url || '').trim();\nconst pbEmail = String(input.pb_admin_email || '').trim();\nconst pbPassword = String(input.pb_admin_password || '').trim();\n\nif (!pbUrl) throw new Error('PB Auth Inline: pb_url missing on input');\nif (!pbEmail) throw new Error('PB Auth Inline: pb_admin_email missing on input');\nif (!pbPassword) throw new Error('PB Auth Inline: pb_admin_password missing on input');\n\nconst subj = String(input.subject || '(no subject)');\nconst fromEmail = String(input.from_email || '(unknown sender)');\n\nlet authResp;\ntry {\n authResp = await this.helpers.httpRequest({\n method: 'POST',\n url: pbUrl.replace(/\\/$/, '') + '/api/collections/_superusers/auth-with-password',\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\n body: { identity: pbEmail, password: pbPassword },\n json: true,\n timeout: 10000,\n });\n} catch (e) {\n throw new Error(\n 'PB Auth Inline: HTTP call to ' + pbUrl + ' failed for \"' + subj + '\" from ' + fromEmail + ': ' +\n (e.message || String(e))\n );\n}\n\nconst token = authResp && typeof authResp.token === 'string' ? authResp.token : '';\nif (!token) {\n throw new Error(\n 'PB Auth Inline: ' + pbUrl + ' returned no token for \"' + subj + '\" from ' + fromEmail +\n ' (response: ' + JSON.stringify(authResp).slice(0, 200) + ')'\n );\n}\n\nconst adminId = authResp.admin && typeof authResp.admin.id === 'string' ? authResp.admin.id : '';\n\nreturn {\n json: Object.assign({}, input, {\n pb_token: token,\n pb_admin_id: adminId,\n _pb_auth_ok: true,\n }),\n};\n"
},
"id": "pb-auth-inline-2026-09-09",
"name": "PB Auth Inline",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1232,
832
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Apply Subject Result\n// Merges the PB response from Find Target Quote (by Subject)\n// with the wrapper context built by Decide Reply Lookup.\n//\n// The HTTP Request node that ran just before this one\n// REPLACES $json with the PB response (e.g. {items: [...]}),\n// dropping _reply_quote_id, pb_token, message_id, etc.\n// Without this merge, Route Reply Result evaluates an empty\n// _reply_quote_id and never fires the matched branch, even\n// when the subject fallback found the right quote.\n//\n// Behaviour:\n// - items.length > 0: override _reply_quote_id with items[0].id\n// (subject fallback matched; In-Reply-To did not).\n// - items.length === 0: keep _reply_quote_id from Decide Reply\n// Lookup (which used the In-Reply-To result, if any).\n// - Always preserve pb_token, message_id, in_reply_to, from_email,\n// from_name, subject, body_plain, body_html, received_at so the\n// downstream Insert quote_replies + Flip has_new_reply HTTP\n// nodes have the auth token and email context they need.\n\nconst resp = $json || {};\nconst items = Array.isArray(resp.items) ? resp.items : [];\nconst subjectHit = items[0] || null;\n\n// Pull the wrapper context that Decide Reply Lookup assembled.\n// Fall back to Encode Reply Filters, then to Parse Email, in case\n// upstream topology ever changes.\nlet wrapper = {};\nconst sources = [\n () => { try { return $('Decide Reply Lookup').first().json || {}; } catch (e) { return {}; } },\n () => { try { return $('Encode Reply Filters').first().json || {}; } catch (e) { return {}; } },\n () => { try { return $('Parse Email').item.json || {}; } catch (e) { return {}; } },\n];\nfor (const get of sources) {\n wrapper = get();\n if (wrapper && (wrapper.pb_token || wrapper.message_id || wrapper.from_email)) break;\n}\n\nconst existingQuoteId = String(wrapper._reply_quote_id || '');\nconst subjectQuoteId = subjectHit && subjectHit.id ? String(subjectHit.id) : '';\n\nreturn { json: Object.assign({}, wrapper, {\n // Subject match wins over empty In-Reply-To result; preserve\n // any non-empty In-Reply-To result if subject returned nothing.\n _reply_quote_id: subjectQuoteId || existingQuoteId,\n _subject_lookup_total: typeof resp.totalItems === 'number' ? resp.totalItems : -1,\n _subject_lookup_matched: !!subjectHit,\n}) };\n"
},
"id": "apply-subject-result-2026-09-09",
"name": "Apply Subject Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2352,
448
]
}
],
"connections": {
"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": "PB Auth Inline",
"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": "Apply Subject Result",
"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
},
{
"node": "Search customers",
"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 customers",
"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 quote_counters",
"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
}
]
]
},
"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": [
[]
]
},
"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
}
]
]
},
"Create Quote Item": {
"main": [
[
{
"node": "Link Attachments to Quote",
"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
}
]
]
},
"IMAP Quote Mailbox": {
"main": [
[
{
"node": "Catch and Create Uploader",
"type": "main",
"index": 0
}
]
]
},
"Webhook Body Unwrap": {
"main": [
[
{
"node": "Catch and Create Uploader",
"type": "main",
"index": 0
}
]
]
},
"Catch and Create Uploader": {
"main": [
[
{
"node": "Parse Email",
"type": "main",
"index": 0
}
]
]
},
"Search customers": {
"main": [
[
{
"node": "Parse Customer Result",
"type": "main",
"index": 0
}
]
]
},
"Create customers": {
"main": [
[
{
"node": "Plan: Find or Create Company",
"type": "main",
"index": 0
}
]
]
},
"PB Auth Inline": {
"main": [
[
{
"node": "Is Reply?",
"type": "main",
"index": 0
}
]
]
},
"Apply Subject Result": {
"main": [
[
{
"node": "Route Reply Result (matched?)",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"availableInMCP": true,
"binaryMode": "separate"
},
"staticData": {
"node:IMAP Quote Mailbox": {
"lastMessageUid": 16
}
},
"meta": {
"aiBuilderAssisted": true,
"builderVariant": "mcp",
"templateCredsSetupCompleted": true
},
"nodeGroups": [],
"pinData": {
"Webhook Test Trigger": [
{
"json": {
"headers": {
"host": "n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io",
"user-agent": "curl/8.7.1",
"content-length": "1340",
"accept": "*/*",
"content-type": "application/json",
"x-forwarded-for": "196.39.76.18",
"x-forwarded-host": "n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io",
"x-forwarded-port": "80",
"x-forwarded-proto": "http",
"x-forwarded-server": "f8810bfd22e4",
"x-real-ip": "196.39.76.18",
"accept-encoding": "gzip"
},
"params": {},
"query": {},
"body": {
"from_email": "sales@vendortest.example.com",
"from_name": "Vendor Test Sales",
"subject": "RFQ-V19-1781989291956",
"message_id": "<wfdash-v19-1781989291956@vendortest.example.com>",
"in_reply_to": "",
"body_plain": "V19 test.",
"body_html": "<p>V19 test.</p>",
"received_at": "2026-06-21T01:00:00.000Z",
"uid": "1781989291956",
"mailbox": "INBOX",
"_attachment_b64": "JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFI+PmVuZG9iago0IDAgb2JqPDwvTGVuZ3RoIDg4Pj5zdHJlYW0KQlQgL0YxIDI0IFRmIDEwMCA3MDAgVGQgKFdGREFTSCBURVNUIFJGUSkgVGoKMCAtMzAgVGQgKExpbmUgMTogMjBtbSBjYWJsZSB0aWVzIHggNSBwYWNrcykgVGoKMCAtMzAgVGQgKExpbmUgMjogSW5kdXN0cmlhbCBkcmlsbCBiaXRzIHggMikgVGogRVQKZW5kc3RyZWFtIGVuZG9iagp4cmVmCjAgNQowMDAwMDAwMDAwIDY1NTM1IGYKMDAwMDAwMDAwOSAwMDAwMCBuCjAwMDAwMDAwNTMgMDAwMDAgbgowMDAwMDAwMDk4IDAwMDAwIG4KMDAwMDAwMDE1NiAwMDAwMCBuCnRyYWlsZXI8PC9TaXplIDUvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgoyOTYKJSVFT0YK",
"_attachment_key": "attachment_0",
"_attachment_filename": "rfq_v19_1781989291956.pdf",
"_attachment_content_type": "application/pdf",
"_attachment_size": 522,
"_effective_message_id": "<wfdash-v19-1781989291956@vendortest.example.com>"
},
"webhookUrl": "http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io/webhook/wf-test-quote-email-monitor",
"executionMode": "production"
},
"pairedItem": {
"item": 0
}
}
],
"IMAP Quote Mailbox": [
{
"json": {
"headers": {
"delivered-to": "Delivered-To: gielie006@gmail.com",
"received": "Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41])\r\n by mx.google.com with SMTPS id 5a478bee46e88-30c1ba95e8asor899575eec.10.2026.06.20.15.01.30\r\n for <gielie006@gmail.com>\r\n (Google Transport Security);\r\n Sat, 20 Jun 2026 15:01:30 -0700 (PDT)",
"x-received": "X-Received: by 2002:a05:7022:41a2:b0:136:e639:9c05 with SMTP id\r\n a92af1059eb24-139a217155dmr5329161c88.31.1781992889288; Sat, 20 Jun 2026\r\n 15:01:29 -0700 (PDT)",
"arc-seal": "ARC-Seal: i=1; a=rsa-sha256; t=1781992890; cv=none;\r\n d=google.com; s=arc-20240605;\r\n b=lJxijKX09IV088+zz52M40HxGy4PmQQS2+my66FmR6bgXTyZHIK6BjbzswgsZgVDHh\r\n MGHhtuO8Bu7q/h3Z9XABgldF6bAcP4YJLrSr4CpOqvEmIYH5axV8VJ2AnTYyec6LOzAm\r\n q9Zl3q4NK+i5yKQCFtcinVnPoiosbNb0yxYqEwZ6kOgdY99TzkscQuIx2ynQFeD2juLo\r\n o8rAH6IRPUqrtB6AKOE4sX+R5RyrftbKHGqGpdE0LU/YIgBTLd4i2EwWTx6ByPe8VGVa\r\n KCmdFkd7BfBTltteWOn0ZPWx6ZF9JKfofEKQd13vbu5JYS03nGOb0gm+PkNhXzc9SODg\r\n hSbQ==",
"arc-message-signature": "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;\r\n h=to:subject:message-id:date:from:mime-version:dkim-signature;\r\n bh=p3FfuyzR/3NV3phhNsPWI/qqb7dPYp4GL+LtgLoVApA=;\r\n fh=+Zlf7MdKt8mYY5rmS5Y6n9jearWZTUmwQ+TLtw7LHU0=;\r\n b=BqmY1kHDy+h8Ilds96z3Gba1nMiipViCPQvMJ6s0qH2IQVbxjeqOQTKzggwtay+vzb\r\n VlfuCNMYLIN+lKgRQTFPXD57K1PyJmsiymLDldTTADYx//asZGXmHUqSxzE5wbzB/Oz4\r\n ZKPrLeuLtwphD63DPOgcP1okH6Kkce4R5BcjG6oFFJCAIATP+rWqeo3PmIbnhUsiodcW\r\n so41U/ETLtyQMYew949zT6TjH93NVJ6pns4tl87Ie/XEjayAo5qS32Q0OLdD5p0ojkh6\r\n zIPJQXkEEVVXuYhpQK/bi6ayaQdOcpQBR51iVgCRLg5LAx3lDNCNSv0iptUEWjUMyCfW\r\n k1+w==;\r\n dara=google.com",
"arc-authentication-results": "ARC-Authentication-Results: i=1; mx.google.com; arc=none",
"return-path": "Return-Path: <davidels1986@gmail.com>",
"received-spf": "Received-SPF: pass (google.com: domain of davidels1986@gmail.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;",
"authentication-results": "Authentication-Results: mx.google.com;\r\n dkim=pass header.i=@gmail.com header.s=20251104 header.b=TzC2jCiV;\r\n arc=pass (i=1);\r\n spf=pass (google.com: domain of davidels1986@gmail.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=davidels1986@gmail.com;\r\n dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;\r\n dara=pass header.i=@gmail.com",
"dkim-signature": "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=gmail.com; s=20251104; t=1781992890; x=1782597690; dara=google.com;\r\n h=to:subject:message-id:date:from:mime-version:from:to:cc:subject\r\n :date:message-id:reply-to;\r\n bh=p3FfuyzR/3NV3phhNsPWI/qqb7dPYp4GL+LtgLoVApA=;\r\n b=TzC2jCiVsQu15QHwXf5auEBcN+Jye2R9Nq7H8o+8xq0tQWsw7XtcwZaBj/CXD4Qw7w\r\n 8xKa5IsT+pXlSbHIptGvrNfI3Yb85fnjySszmEEZ4lotwtPbrA2bjYc9qJ9DakTSitmt\r\n gAXbCuWN9XcUCFwVe8hc8NAn2x7fL6DtsyOroD0GwHt7EmPDeq0oV8KsN6oM5cUIAhzD\r\n VI8376gAYYiHZ6uyaig//CuG0YBltnI2zXOkln82fRKgPpIKuLKPAmHKbJOlabDnDXcQ\r\n qvSARCRgTrZJPGfKeQHODrrHggNBFkB/4QurzNRgkwBxsyd92o4s5PB6emn8vruUVPk2\r\n 2fig==",
"x-google-dkim-signature": "X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=1e100.net; s=20251104; t=1781992890; x=1782597690;\r\n h=to:subject:message-id:date:from:mime-version:x-gm-gg\r\n :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to;\r\n bh=p3FfuyzR/3NV3phhNsPWI/qqb7dPYp4GL+LtgLoVApA=;\r\n b=D/LDfGcjIvIdlr33pG+GxZ/7dYbzF3nBcwapwGJB9AxdX1Q15McBxhyvw4ozTQjCmR\r\n wGs/VkVvHg7GHgvshmU/8AjlqOlWtId06SDv3DzNRqzWGoS7JuI23j82eW2vAGQnqI6Z\r\n KfNI4ZUic0rKOUDWXOyPTgYmUGkBdSCcXIc386vvDLdnOyNgOxgDjFDNyn2eQZGkC47+\r\n zVwrO4amr8sBFWr7yjegHnBNbcJpQZfZ3QunQBsoUBz1RO1KWhIAqrlJR502KosTrsgT\r\n pgZqmQkKez40TJN/ha8J7i/oUl7Ss2c/dDml7tdJuPp7gt8/PVlAczw33RoodRjKELZF\r\n j+ew==",
"x-gm-message-state": "X-Gm-Message-State: AOJu0YxN7Sajfeg74S6jNEymS7tsZfyFRCgDYW0EYEIrl9GihLApfLsW\r\n\taOgbQkwDQx6gQLmk7nKfHqIMBZmKHNkx8l9UwVVsr4xBLWoMsWVNDh2ochot1EiJxicYNn5vbeL\r\n\t8BjnsIh0l8jXnqFdd5d8gjZ4sAvJ20DyrZA==",
"x-gm-gg": "X-Gm-Gg: AfdE7ckVNpXl2eafyAevQu2XpS4rS43dRmqsd5Wo3/fzZ32Scbk5oikhr6EuFQSbT+B\r\n\tbHhdvlxiYyYBA4H0Q1xJ5UfZYklNoHnnKaiFTRN9NQo2strcZkEjebsQAXRzI4ARtoMkILkCOTe\r\n\tQ7g20WUsabGvFhrK8tK60LJ+GeAia9BH+h3oGBZNAZRDYCx/gpcVwb/HnmX6/xZZ/vqSdzgFp71\r\n\t4e8DfnUudsWKuNVqV6MrsZ6Fe1YDuhePVWYKjAeEASyMlFOGH0TnSV/sp+C8n6diSzttg0=",
"mime-version": "MIME-Version: 1.0",
"from": "From: David Els <davidels1986@gmail.com>",
"date": "Date: Sun, 21 Jun 2026 00:01:17 +0200",
"x-gm-features": "X-Gm-Features: AVVi8CcNH-snzEAGkxqeHEHojjdKDlLvH82d4dmJEAYFhcTs-mmZy-0AHtQKQcs",
"message-id": "Message-ID: <CAON4yNner-2XYX=_udzLrFD8o+o+9CJnD_3QzHpqC1o1BfTa+A@mail.gmail.com>",
"subject": "Subject: quote request",
"to": "To: gielie006@gmail.com",
"content-type": "Content-Type: multipart/mixed; boundary=\"0000000000002916c30654b68d5d\""
},
"html": "<div dir=\"ltr\">do a quote on the attached for Mark Wilkie at harmony</div>\n",
"text": "do a quote on the attached for Mark Wilkie at harmony\n",
"textAsHtml": "<p>do a quote on the attached for Mark Wilkie at harmony</p>",
"subject": "quote request",
"date": "2026-06-20T22:01:17.000Z",
"to": {
"value": [
{
"address": "gielie006@gmail.com",
"name": ""
}
],
"html": "<span class=\"mp_address_group\"><a href=\"mailto:gielie006@gmail.com\" class=\"mp_address_email\">gielie006@gmail.com</a></span>",
"text": "gielie006@gmail.com"
},
"from": {
"value": [
{
"address": "davidels1986@gmail.com",
"name": "David Els"
}
],
"html": "<span class=\"mp_address_group\"><span class=\"mp_address_name\">David Els</span> &lt;<a href=\"mailto:davidels1986@gmail.com\" class=\"mp_address_email\">davidels1986@gmail.com</a>&gt;</span>",
"text": "\"David Els\" <davidels1986@gmail.com>"
},
"messageId": "<CAON4yNner-2XYX=_udzLrFD8o+o+9CJnD_3QzHpqC1o1BfTa+A@mail.gmail.com>",
"attributes": {
"uid": 17
}
},
"binary": {
"attachment_0": {
"mimeType": "application/pdf",
"fileType": "pdf",
"fileExtension": "pdf",
"data": "database",
"fileName": "PO_PO-20260604-004.pdf",
"id": "database:d4e21fb2-1a4d-47d5-9d29-be4165c74f25",
"fileSize": "19.7 kB",
"bytes": 19657
}
},
"pairedItem": {
"item": 0
}
}
]
},
"versionId": "5d462d41-8841-4c6d-ac32-31395a225eda",
"activeVersionId": "5d462d41-8841-4c6d-ac32-31395a225eda",
"versionCounter": 177,
"triggerCount": 2,
"sourceWorkflowId": null,
"shared": [
{
"updatedAt": "2026-06-14T20:19:24.183Z",
"createdAt": "2026-06-14T20:19:24.183Z",
"role": "workflow:owner",
"workflowId": "eXSh623OjByIcip8",
"projectId": "UhHq8eAZFxiYmQxH",
"project": {
"updatedAt": "2026-06-11T15:00:27.533Z",
"createdAt": "2026-06-11T14:30:25.543Z",
"id": "UhHq8eAZFxiYmQxH",
"name": "David Els <david.els1@outlook.com>",
"type": "personal",
"icon": null,
"description": null,
"customTelemetryTags": [],
"creatorId": "4f770df2-bd2f-4436-b153-b27004efc7b6"
}
}
],
"tags": [],
"activeVersion": {
"updatedAt": "2026-09-09T09:07:17.381Z",
"createdAt": "2026-09-09T09:07:17.381Z",
"versionId": "5d462d41-8841-4c6d-ac32-31395a225eda",
"workflowId": "eXSh623OjByIcip8",
"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": [
224,
928
],
"credentials": {
"imap": {
"id": "OxBkyAvgByqMElJG",
"name": "GIELIE IMAP"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse Email (v5 \u2014 preserves _attachment_record_ids from upstream Catch and Create)\n// Merges attachment IDs from input (set by Catch and Create Uploader) into\n// the email context so they survive the rest of the chain.\nconst item = $json || {};\n\n// Pull attachment IDs that Catch and Create Uploader attached upstream.\nlet attIds = [];\nlet attRecords = [];\nlet attCount = 0;\ntry { attIds = ($('Catch and Create Uploader').first().json && $('Catch and Create Uploader').first().json._attachment_record_ids) || []; } catch(e) {}\ntry { attRecords = ($('Catch and Create Uploader').first().json && $('Catch and Create Uploader').first().json._attachment_records) || []; } catch(e) {}\nattCount = Array.isArray(attIds) ? attIds.length : 0;\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)';\n\n// === BULLETPROOF QUOTE-NUMBER DETECTION (FIX 2026-09-09) ===\n// The previous regex `/Q-\\d{4}-\\d{2}-\\d{3}/` required literal dashes\n// between year/month/seq, but every real outbound subject uses the\n// dashless format `QUOTE-Q202609246 REF -`. Replies were falling\n// through to the \"new\" branch and creating duplicate quotes.\n//\n// New regex accepts both legacy (`Q-2026-09-246`) and current\n// (`Q202609246`) shapes, with optional dashes between groups.\nconst quoteNumberPattern = /Q-?\\d{4}-?\\d{2}-?\\d{3}/;\nconst quoteNumberMatch = subject.match(quoteNumberPattern);\n\n// Canonicalise to digits-only \u2014 this is what we feed to PB's\n// `quote_number~` (contains) so it matches whether PB stores the\n// value as `Q202609246` or `Q-2026-09-246`.\nconst quoteNumberRaw = quoteNumberMatch ? quoteNumberMatch[0] : '';\nconst quoteNumberCanonical = quoteNumberRaw\n ? quoteNumberRaw.replace(/[^0-9]/g, '')\n : '';\n\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: attCount > 0 || attachmentsMeta.length > 0,\n is_reply: isReply,\n route: isReply ? 'reply' : 'new',\n uid: uid,\n mailbox: mailbox,\n _attachment_record_ids: attIds,\n _attachment_records: attRecords,\n _attachment_count: attCount,\n // NEW: quote-number fields used by Encode Reply Filters for the\n // bulletproof subject-fallback filter. Canonical is digits-only so\n // it matches both `Q202609246` and `Q-2026-09-246` via PB's\n // `quote_number~` substring operator.\n quote_number_raw: quoteNumberRaw,\n quote_number_canonical: quoteNumberCanonical,\n },\n binary: $binary || {}\n};\n"
},
"id": "caa0d1d4-a79a-40d1-badd-2bba84712121",
"name": "Parse Email",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
672,
832
]
},
{
"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": [
896,
832
]
},
{
"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": [
1120,
832
]
},
{
"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": [
0,
0
],
"disabled": true
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Parse PB Auth (v5 simplified \u2014 auth happens inline upstream)\n// Collect Attachment IDs already carries pb_token + email context.\n// We just enrich with pb_admin_id and forward.\nlet ctx = {};\ntry { ctx = $('Collect Attachment IDs').first().json || {}; } catch(e) {}\nif (!ctx.pb_token) {\n try { ctx = $('PocketBase Connection').item.json || {}; } catch(e) {}\n}\nif (!ctx.pb_token) {\n throw new Error('No pb_token on item \u2014 auth inline must have failed');\n}\nreturn {\n json: Object.assign({}, ctx, { pb_admin_id: ctx.pb_admin_id || '' }),\n binary: $binary || {}\n};\n"
},
"id": "6e1f9349-0489-46a6-9e97-653e2e46202c",
"name": "Parse PB Auth",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
0,
224
],
"disabled": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"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,
832
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Encode Reply Filters\n// Pre-encode the two PB filters used by the reply-branch HTTP nodes.\n//\n// FIX 2026-09-09 (3 changes):\n// 1. Subject filter no longer passes the WHOLE subject to PB's\n// `quote_number~` (contains). Uses the digits-only canonical form\n// extracted by Parse Email (`quote_number_canonical`, e.g.\n// \"202609246\") so PB's substring matches whether PB stores\n// `Q202609246` or `Q-2026-09-246`.\n// 2. If Parse Email couldn't pull a canonical number (no Q-digit\n// pattern in subject), the filter is set to a guaranteed no-match\n// sentinel so PB returns zero records instead of \"all records\".\n// 3. In-Reply-To uses a SUBSTRING match on the BARE UUID (no `<>`,\n// no `@domain`). Reasons:\n// (a) PB only accepts lowercase `%3c`/`%3e` for `<`/`>`. URL\n// encoders (incl. n8n's HTTP client) sometimes emit uppercase\n// percent escapes, breaking the filter silently.\n// (b) Parse Email strips `<>` from message-id headers per RFC\n// 5322. PB stores them with brackets. A substring match on\n// the bracket-less value works either way.\n// (c) The UUID portion of a message-id is the only universally\n// unique segment. `@domain` differs between senders; angle\n// brackets differ between clients; the UUID is canonical.\n// The UUID regex tolerates all common message-id formats.\n//\n// FIX 2026-09-09 (4th change \u2014 auth fix): the empty `in_reply_to`\n// fallback used to be `''` (empty filter string). PB interprets an\n// empty filter as \"no filter\" and would return the first record in\n// the collection with `perPage=1`, which is the opposite of the\n// intended \"no match\" behaviour. Now uses the same no-match sentinel\n// pattern as the subject fallback: `id=\"__NO_MATCH__\"`, which is\n// guaranteed not to match any record (record ids are 15-char PB base62\n// strings).\nconst inReplyToRaw = String($json.in_reply_to || '').trim();\nconst quoteNum = String($json.quote_number_canonical || '').trim();\n\n// Pull the bare UUID out of the message-id. Tolerates brackets, @domain,\n// and capital hex. Falls back to the full bracket-less string when no\n// UUID-shaped token is present (e.g. weird custom message-ids).\nconst uuidMatch = inReplyToRaw.match(/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/);\nconst inReplySubstr = uuidMatch ? uuidMatch[0] : inReplyToRaw;\n\n// In-Reply-To: substring match on the UUID (or fallback to bracket-less).\n// Missing/empty \u2192 sentinel that no record's id equals.\nconst inReplyFilter = inReplySubstr\n ? 'email_message_id~' + JSON.stringify(inReplySubstr)\n : 'id=' + JSON.stringify('__NO_MATCH__');\n\n// Subject fallback: substring on digits-only canonical quote number.\n// Empty canonical \u2192 sentinel that no quote_number equals.\nconst subjectFilter = quoteNum\n ? 'quote_number~' + JSON.stringify(quoteNum)\n : 'quote_number=' + JSON.stringify('__NO_MATCH__');\n\nreturn { json: Object.assign({}, $json, {\n _reply_filter_in_reply_to: inReplyFilter,\n _reply_filter_subject: subjectFilter,\n // Keep the bracketless form downstream in case any other node needs it.\n in_reply_to_canonical: inReplyToRaw,\n}) };\n"
},
"id": "85b15e0f-fc92-4e63-9bee-3897d5ff3420",
"name": "Encode Reply Filters",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
448
]
},
{
"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,
448
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Decide Reply Lookup\n// Reads the In-Reply-To PB response and decides whether we have a match.\n//\n// FIX 2026-09-09: the previous version spread `$('Parse Email').item.json`,\n// which DROPPED `pb_url`, `pb_token`, AND `_reply_filter_subject` that the\n// next node (Find Target Quote by Subject) needs to build its URL. The\n// Subject lookup then crashed with \"Invalid URL\" because `pb_url` was\n// undefined. This was masked previously because the regex bug prevented\n// replies from ever reaching this branch.\n//\n// We now spread `$('Encode Reply Filters').first().json`, which carries\n// the full upstream context (pb_url, pb_token, message_id, etc.) PLUS\n// the filter strings the next HTTP node needs.\nconst body = $json || {};\nconst items = Array.isArray(body.items) ? body.items : [];\nconst first = items[0] || null;\n\nlet upstream = {};\ntry {\n upstream = $('Encode Reply Filters').first().json || {};\n} catch (e) {\n // Encode Reply Filters not reachable \u2014 fall back to Parse Email\n upstream = $('Parse Email').item.json || {};\n}\n\nreturn { json: Object.assign({}, upstream, {\n _reply_quote_id: first ? (first.id || '') : '',\n // Stash the In-Reply-To response so any debug step downstream can see\n // whether the lookup actually succeeded.\n _in_reply_lookup_total: typeof body.totalItems === 'number' ? body.totalItems : -1,\n}) };\n"
},
"id": "c5fce964-64f1-4c22-8335-75c03bd73174",
"name": "Decide Reply Lookup",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2016,
448
]
},
{
"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,
448
]
},
{
"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,
448
]
},
{
"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": [
2688,
448
]
},
{
"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": [
2976,
448
]
},
{
"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,
1216
]
},
{
"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;"
},
"id": "05e67b7e-ddc5-491b-aa5f-fb5dddd5498e",
"name": "Split by Type",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1792,
1216
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "pdf",
"id": "f665e9eb-45fd-4e35-9add-da8eb6a7200b"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "pdf"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "csv",
"id": "7273cbe5-dcd7-401a-9981-eae39e558a60"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "csv"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "xls",
"id": "317d8798-b0c7-40e3-8304-dfd9ea03ad45"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "xls"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "xlsx",
"id": "827396dc-aa0b-4fc1-92e2-c05fe0bf4a9a"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "xlsx"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "html",
"id": "2c5bb0e4-eb83-4c51-a71b-38567d394b5b"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "html"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "json",
"id": "859f1744-c5d7-439d-8c74-43ca98b30785"
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "json"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"leftValue": "={{ $json.extracted_op }}",
"operator": {
"type": "string",
"operation": "equals"
},
"rightValue": "text",
"id": "80eec99a-0d96-46c0-88a9-f6ec7d2fc07e"
}
],
"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,
1120
]
},
{
"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,
736
],
"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,
1024
],
"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,
1216
],
"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,
1408
],
"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,
1600
],
"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,
1792
],
"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,
1984
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Normalise Extracted Text (v5 \u2014 reads all context from input items)\n// In v4 this node had to merge fields from MULTIPLE upstream nodes\n// (Parse Email, Parse PB Auth, PocketBase Connection) because each only\n// carried part of the context. In v5, Catch and Create Uploader + Parse\n// Email + PocketBase Connection all merge context into the items before\n// they reach us \u2014 so we just read from items[0].json and we're done.\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 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 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.from_email || '',\n from_name: first.from_name || '',\n subject: first.subject || '',\n body_plain: first.body_plain || '',\n body_html: first.body_html || '',\n message_id: first.message_id || '',\n in_reply_to: first.in_reply_to || '',\n received_at: first.received_at || '',\n is_reply: !!first.is_reply,\n route: first.route || 'new',\n uid: first.uid || '',\n mailbox: first.mailbox || 'INBOX',\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: first.has_attachments !== undefined ? first.has_attachments : (attachments.some(a => a.binary_key != null)),\n // Catch and Create Uploader outputs \u2014 must survive through this node\n _attachment_record_ids: first._attachment_record_ids || [],\n _attachment_records: first._attachment_records || [],\n _attachment_count: first._attachment_count || 0,\n },\n binary: binary\n};\n"
},
"id": "a332928d-f077-4c31-82ed-4347d3f80776",
"name": "Normalise Extracted Text",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2464,
1312
]
},
{
"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": [
2688,
1312
]
},
{
"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": [
2912,
1312
]
},
{
"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": [
2912,
1536
],
"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": [
3264,
1312
]
},
{
"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": [
3488,
1312
]
},
{
"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": [
3680,
1424
],
"disabled": true
},
{
"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": [
3936,
1312
]
},
{
"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": [
4160,
1312
]
},
{
"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": [
4384,
1472
],
"disabled": true
},
{
"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": [
4608,
1312
]
},
{
"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": [
4832,
1312
]
},
{
"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": [
5056,
1248
]
},
{
"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": [
5280,
1248
]
},
{
"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": [
5504,
1248
]
},
{
"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": [
5744,
976
]
},
{
"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": [
5888,
1568
]
},
{
"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": [
6176,
1312
]
},
{
"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": [
6400,
1312
]
},
{
"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": [
6624,
1312
]
},
{
"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": [
6848,
1408
]
},
{
"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": [
7072,
1408
]
},
{
"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": [
7296,
1408
]
},
{
"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": [
7520,
1408
]
},
{
"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": [
7744,
1408
]
},
{
"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": [
7968,
1408
],
"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": [
8192,
1600
],
"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": "Upload Attachment",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
8416,
1600
],
"disabled": true,
"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": [
8640,
1504
],
"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": [
0,
736
],
"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": [
224,
736
]
},
{
"parameters": {
"jsCode": "// Link Attachments to Quote (FIXED mode \u2014 uses $input.all())\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) ? ctx._attachment_record_ids : [];\n\nif (!quoteId) {\n return [{ json: Object.assign({}, ctx, { _skip: true, reason: 'no quote_id' }) }];\n}\n\nif (ids.length === 0) {\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": "v5-link-attachments-to-quote",
"name": "Link Attachments to Quote",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
8192,
1408
]
},
{
"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": "v5-patch-attachment-set-quote",
"name": "PATCH Attachment (set quote)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
8416,
1408
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Catch and Create Uploader v19 \u2014 debug PB response\nconst inputs = $input.all();\nif (inputs.length === 0) {\n return [{ json: { _attachment_record_ids: [], has_attachments: false }, binary: {} }];\n}\n\nconst emailItem = inputs[0];\nconst emailJson = emailItem.json || {};\nconst emailBinary = emailItem.binary || {};\n\nconst PB_URL = 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';\nconst PB_EMAIL = 'david.els1@outlook.com';\nconst PB_PASSWORD = '1234567890';\n\nfunction pbNow() {\n const d = new Date();\n return d.getUTCFullYear() + '-' +\n String(d.getUTCMonth() + 1).padStart(2, '0') + '-' +\n String(d.getUTCDate()).padStart(2, '0') + ' ' +\n String(d.getUTCHours()).padStart(2, '0') + ':' +\n String(d.getUTCMinutes()).padStart(2, '0') + ':' +\n String(d.getUTCSeconds()).padStart(2, '0') + '.' +\n String(d.getUTCMilliseconds()).padStart(3, '0') + 'Z';\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/')) return 'image';\n if (/\\.(docx?|xlsx?|csv)$/.test(fn)) return 'doc';\n return 'other';\n}\n\nconst debug = [];\n\nlet pbToken = '';\ntry {\n const authResp = await this.helpers.httpRequest({\n method: 'POST', url: PB_URL + '/api/collections/_superusers/auth-with-password',\n headers: { 'Content-Type': 'application/json' },\n body: { identity: PB_EMAIL, password: PB_PASSWORD },\n json: true, timeout: 15000,\n });\n pbToken = String(authResp.token || '');\n} catch (authErr) {\n throw new Error('PB auth failed: ' + authErr.message);\n}\n\nconst attachmentIds = [];\nconst attachmentDetails = [];\n\nfor (const key of Object.keys(emailBinary)) {\n const b = emailBinary[key];\n if (!b || !b.data) { debug.push({skip: 'no data'}); continue; }\n \n const filename = String(b.fileName || 'attachment');\n const contentType = String(b.mimeType || 'application/octet-stream');\n const sizeBytes = Number(b.fileSize || 0);\n \n const boundary = '----n8nFormBoundary' + Math.random().toString(36).substring(2);\n const parts = [];\n \n const fields = {\n filename: filename,\n content_type: contentType,\n size_bytes: String(sizeBytes),\n email_message_id: String(emailJson.message_id || emailJson._effective_message_id || ''),\n uploaded_at: pbNow(),\n kind: kindFor(contentType, filename),\n };\n \n for (const [fname, value] of Object.entries(fields)) {\n parts.push(Buffer.from('--' + boundary + '\\r\\n' +\n 'Content-Disposition: form-data; name=\"' + fname + '\"\\r\\n\\r\\n' +\n value + '\\r\\n', 'utf8'));\n }\n \n const fileBuf = Buffer.from(b.data, 'base64');\n parts.push(Buffer.from('--' + boundary + '\\r\\n' +\n 'Content-Disposition: form-data; name=\"file\"; filename=\"' + filename + '\"\\r\\n' +\n 'Content-Type: ' + contentType + '\\r\\n\\r\\n', 'utf8'));\n const postFile = Buffer.from('\\r\\n--' + boundary + '--\\r\\n', 'utf8');\n const body = Buffer.concat([...parts, fileBuf, postFile]);\n \n debug.push({key, bodyLen: body.length, boundary: boundary});\n \n try {\n const uploadResp = await this.helpers.httpRequest({\n method: 'POST',\n url: PB_URL + '/api/collections/quote_attachments/records',\n headers: {\n 'Authorization': 'Bearer ' + pbToken,\n 'Content-Type': 'multipart/form-data; boundary=' + boundary,\n },\n body: body,\n json: true,\n timeout: 30000,\n });\n \n debug.push({upload: 'ok', resp: JSON.stringify(uploadResp).slice(0, 500)});\n if (uploadResp && uploadResp.id) {\n attachmentIds.push(String(uploadResp.id));\n attachmentDetails.push({id: String(uploadResp.id), filename, content_type: contentType, size_bytes: sizeBytes, kind: kindFor(contentType, filename)});\n }\n } catch (uploadErr) {\n debug.push({upload: 'fail', err: String(uploadErr.message || uploadErr).slice(0, 500)});\n }\n}\n\nreturn [{\n json: Object.assign({}, emailJson, {\n _attachment_record_ids: attachmentIds,\n _attachment_records: attachmentDetails,\n _attachment_count: attachmentIds.length,\n has_attachments: attachmentIds.length > 0,\n pb_url: PB_URL,\n pb_token: pbToken,\n pb_admin_id: '',\n _debug: debug,\n }),\n binary: emailBinary,\n}];\n"
},
"id": "661f02e5-1009-479d-afc7-681fffd4d57a",
"name": "Catch and Create Uploader",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
448,
832
]
},
{
"parameters": {
"parameters": {},
"requestOptions": {}
},
"id": "d2cfecc1-f8b8-4bbb-a368-07a5651eee14",
"name": "PB Upload Test",
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
640,
-16
],
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"resource": "quote_attachments",
"operation": "create",
"parameters": {
"allElements": true
},
"bodyType": [
"binaryData"
],
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
1136,
1104
],
"id": "3e3f8dbf-41b9-471b-8668-da8432dafe61",
"name": "Create quote_attachments",
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"resource": "customers",
"parameters": {
"allElements": true
},
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
3696,
1120
],
"id": "829f1111-4d83-4bfe-9d2c-2576bee89102",
"name": "Search customers",
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"resource": "customers",
"operation": "create",
"parameters": {},
"bodyType": [],
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
4384,
1136
],
"id": "dfe8a810-dea6-4402-bd00-cdec079a59ff",
"name": "Create customers",
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"resource": "quote_counters",
"operation": "create",
"parameters": {
"allElements": true
},
"bodyType": [
"bodyJson"
],
"bodyJson": "={{ { \"year\": $json._counter_year, \"month\": $json._counter_month, \"last_seq\": 1 } }}",
"requestOptions": {}
},
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
"typeVersion": 1,
"position": [
6848,
1216
],
"id": "b30ab459-501b-4d45-829c-dd82a1747598",
"name": "Create quote_counters",
"credentials": {
"pocketbaseHttpApi": {
"id": "hLwB1QPIJEWsuVyW",
"name": "PocketBase HTTP account"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// PB Auth Inline (v5 \u2014 reply-branch auth)\n// Authenticate with PB admin once, set pb_token + pb_admin_id\n// on the shared context. Both the reply branch (Find Target\n// Quote, Insert quote_replies, Flip has_new_reply) and the\n// create branch (Carry Email Ctx \u2192 downstream HTTP nodes)\n// consume $json.pb_token.\n//\n// The create branch ALSO calls auth inline inside Catch and\n// Create Uploader for its own attachment uploads. That's\n// intentional \u2014 its token is overwritten downstream anyway\n// and the duplicate call is cheap against local PB.\n//\n// On auth failure: throw so the operator sees the failure in\n// the n8n UI rather than silently producing an empty token\n// and a confusing 401 from the next HTTP node.\n\nconst input = $json || {};\nconst pbUrl = String(input.pb_url || '').trim();\nconst pbEmail = String(input.pb_admin_email || '').trim();\nconst pbPassword = String(input.pb_admin_password || '').trim();\n\nif (!pbUrl) throw new Error('PB Auth Inline: pb_url missing on input');\nif (!pbEmail) throw new Error('PB Auth Inline: pb_admin_email missing on input');\nif (!pbPassword) throw new Error('PB Auth Inline: pb_admin_password missing on input');\n\nconst subj = String(input.subject || '(no subject)');\nconst fromEmail = String(input.from_email || '(unknown sender)');\n\nlet authResp;\ntry {\n authResp = await this.helpers.httpRequest({\n method: 'POST',\n url: pbUrl.replace(/\\/$/, '') + '/api/collections/_superusers/auth-with-password',\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\n body: { identity: pbEmail, password: pbPassword },\n json: true,\n timeout: 10000,\n });\n} catch (e) {\n throw new Error(\n 'PB Auth Inline: HTTP call to ' + pbUrl + ' failed for \"' + subj + '\" from ' + fromEmail + ': ' +\n (e.message || String(e))\n );\n}\n\nconst token = authResp && typeof authResp.token === 'string' ? authResp.token : '';\nif (!token) {\n throw new Error(\n 'PB Auth Inline: ' + pbUrl + ' returned no token for \"' + subj + '\" from ' + fromEmail +\n ' (response: ' + JSON.stringify(authResp).slice(0, 200) + ')'\n );\n}\n\nconst adminId = authResp.admin && typeof authResp.admin.id === 'string' ? authResp.admin.id : '';\n\nreturn {\n json: Object.assign({}, input, {\n pb_token: token,\n pb_admin_id: adminId,\n _pb_auth_ok: true,\n }),\n};\n"
},
"id": "pb-auth-inline-2026-09-09",
"name": "PB Auth Inline",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1232,
832
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Apply Subject Result\n// Merges the PB response from Find Target Quote (by Subject)\n// with the wrapper context built by Decide Reply Lookup.\n//\n// The HTTP Request node that ran just before this one\n// REPLACES $json with the PB response (e.g. {items: [...]}),\n// dropping _reply_quote_id, pb_token, message_id, etc.\n// Without this merge, Route Reply Result evaluates an empty\n// _reply_quote_id and never fires the matched branch, even\n// when the subject fallback found the right quote.\n//\n// Behaviour:\n// - items.length > 0: override _reply_quote_id with items[0].id\n// (subject fallback matched; In-Reply-To did not).\n// - items.length === 0: keep _reply_quote_id from Decide Reply\n// Lookup (which used the In-Reply-To result, if any).\n// - Always preserve pb_token, message_id, in_reply_to, from_email,\n// from_name, subject, body_plain, body_html, received_at so the\n// downstream Insert quote_replies + Flip has_new_reply HTTP\n// nodes have the auth token and email context they need.\n\nconst resp = $json || {};\nconst items = Array.isArray(resp.items) ? resp.items : [];\nconst subjectHit = items[0] || null;\n\n// Pull the wrapper context that Decide Reply Lookup assembled.\n// Fall back to Encode Reply Filters, then to Parse Email, in case\n// upstream topology ever changes.\nlet wrapper = {};\nconst sources = [\n () => { try { return $('Decide Reply Lookup').first().json || {}; } catch (e) { return {}; } },\n () => { try { return $('Encode Reply Filters').first().json || {}; } catch (e) { return {}; } },\n () => { try { return $('Parse Email').item.json || {}; } catch (e) { return {}; } },\n];\nfor (const get of sources) {\n wrapper = get();\n if (wrapper && (wrapper.pb_token || wrapper.message_id || wrapper.from_email)) break;\n}\n\nconst existingQuoteId = String(wrapper._reply_quote_id || '');\nconst subjectQuoteId = subjectHit && subjectHit.id ? String(subjectHit.id) : '';\n\nreturn { json: Object.assign({}, wrapper, {\n // Subject match wins over empty In-Reply-To result; preserve\n // any non-empty In-Reply-To result if subject returned nothing.\n _reply_quote_id: subjectQuoteId || existingQuoteId,\n _subject_lookup_total: typeof resp.totalItems === 'number' ? resp.totalItems : -1,\n _subject_lookup_matched: !!subjectHit,\n}) };\n"
},
"id": "apply-subject-result-2026-09-09",
"name": "Apply Subject Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2352,
448
]
}
],
"connections": {
"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": "PB Auth Inline",
"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": "Apply Subject Result",
"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
},
{
"node": "Search customers",
"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 customers",
"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 quote_counters",
"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
}
]
]
},
"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": [
[]
]
},
"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
}
]
]
},
"Create Quote Item": {
"main": [
[
{
"node": "Link Attachments to Quote",
"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
}
]
]
},
"IMAP Quote Mailbox": {
"main": [
[
{
"node": "Catch and Create Uploader",
"type": "main",
"index": 0
}
]
]
},
"Webhook Body Unwrap": {
"main": [
[
{
"node": "Catch and Create Uploader",
"type": "main",
"index": 0
}
]
]
},
"Catch and Create Uploader": {
"main": [
[
{
"node": "Parse Email",
"type": "main",
"index": 0
}
]
]
},
"Search customers": {
"main": [
[
{
"node": "Parse Customer Result",
"type": "main",
"index": 0
}
]
]
},
"Create customers": {
"main": [
[
{
"node": "Plan: Find or Create Company",
"type": "main",
"index": 0
}
]
]
},
"PB Auth Inline": {
"main": [
[
{
"node": "Is Reply?",
"type": "main",
"index": 0
}
]
]
},
"Apply Subject Result": {
"main": [
[
{
"node": "Route Reply Result (matched?)",
"type": "main",
"index": 0
}
]
]
}
},
"nodeGroups": [],
"authors": "David Els",
"name": null,
"description": null,
"autosaved": false,
"workflowPublishHistory": [
{
"createdAt": "2026-09-09T09:07:18.039Z",
"id": 891,
"workflowId": "eXSh623OjByIcip8",
"versionId": "5d462d41-8841-4c6d-ac32-31395a225eda",
"event": "deactivated",
"userId": "4f770df2-bd2f-4436-b153-b27004efc7b6"
},
{
"createdAt": "2026-09-09T09:07:20.335Z",
"id": 892,
"workflowId": "eXSh623OjByIcip8",
"versionId": "5d462d41-8841-4c6d-ac32-31395a225eda",
"event": "activated",
"userId": "4f770df2-bd2f-4436-b153-b27004efc7b6"
}
]
}
}