This commit addresses the silent letterhead-flip bug reported on 2026-09-09, where the operator's selected letterhead was not being saved, leading to incorrect letterhead being used in subsequent PDF generations. The operator's choice is now persisted in the quote row during PDF generation, ensuring that the correct letterhead is used for future auto-regens and email sends. Changes include: - Updated the `generateQuotePdf` function to include the operator's letterhead choice in the database write. - Adjusted the API response to surface the persisted letterhead for UI synchronization. - Added tests to verify that the letterhead choice is correctly saved and not overwritten by empty or invalid inputs. This fix enhances the reliability of the quote generation process and improves user experience by maintaining the operator's selections.
811 lines
29 KiB
JSON
811 lines
29 KiB
JSON
{
|
||
"name": "WFDASH — Quote Outbound Send (push-payload + outbound-attachments)",
|
||
"description": "Sends approved quote emails with the auto-generated PDF AND any operator-selected outbound attachments (quotes.outbound_attachments). Triggered by Next.js flipping the quote to status='sending'. The Build attachments list Code node merges the PDF binary with each operator-selected outbound token (validated server-side in sendQuoteEmail and persisted as email_outbound_attachment_ids JSON), downloading the outbound files from PB. SMTP failure now flips the quote to mail_failed (was send_failed, which the retry path didn't recognise).",
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"fromEmail": "=quotes@wfsales.co.za",
|
||
"toEmail": "={{ $('View customers').item.json.email }}",
|
||
"subject": "=QUOTE-{{ $('Search quotes').item.json.quote_number }} REF - {{ $('Search quotes').item.json.quote_reference }}",
|
||
"html": "={{ $('Search quotes').item.json.email_message }}",
|
||
"options": {
|
||
"appendAttribution": false,
|
||
"ccEmail": "={{ $('View users1').item.json.email }}",
|
||
"bccEmail": "=",
|
||
"replyTo": "quotes@wfsales.co.za",
|
||
"attachments": "={{ $json.attachments && $json.attachments.length ? $json.attachments : [] }}"
|
||
}
|
||
},
|
||
"id": "8c00fc59-3492-42d3-8f3f-66a86eddfc31",
|
||
"name": "Send Email (SMTP)",
|
||
"type": "n8n-nodes-base.emailSend",
|
||
"typeVersion": 2.1,
|
||
"position": [
|
||
-464,
|
||
288
|
||
],
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 5000,
|
||
"webhookId": "d03c93e2-7bf8-4006-8542-5d94a87e68d1",
|
||
"credentials": {
|
||
"smtp": {
|
||
"id": "R1iZHg4Q5J32w0tA",
|
||
"name": "QUOTES@WFSALES"
|
||
}
|
||
},
|
||
"onError": "continueErrorOutput"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.manualTrigger",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-2176,
|
||
288
|
||
],
|
||
"id": "81e80622-3c74-4aa5-b94e-638c0ba42507",
|
||
"name": "When clicking ‘Execute workflow’"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "quotes",
|
||
"parameters": {
|
||
"elementsPerPage": 1,
|
||
"filter": "status = 'sending'"
|
||
},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-1808,
|
||
288
|
||
],
|
||
"id": "9ffcb69c-e88c-477a-8b1b-d65f7b2db527",
|
||
"name": "Search quotes",
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "customers",
|
||
"operation": "view",
|
||
"elementId": "={{ $('Search quotes').item.json.customer }}",
|
||
"parameters": {},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-1584,
|
||
288
|
||
],
|
||
"id": "eba6b537-d696-4d72-a794-85149c645fbd",
|
||
"name": "View customers",
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io/api/files/{{ $json.collectionId }}/{{ $json.id }}/{{ $json.file[0] }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.5,
|
||
"position": [
|
||
-688,
|
||
288
|
||
],
|
||
"id": "d8fbb522-14b6-493a-ad78-9e0ae4b02eb2",
|
||
"name": "HTTP Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "quote_attachments",
|
||
"parameters": {
|
||
"filter": "=quote = '{{ $('Search quotes').item.json.id }}' && kind = 'pdf'"
|
||
},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-1136,
|
||
288
|
||
],
|
||
"id": "14fb9665-84b5-49f3-9f68-3d2d3e2a6167",
|
||
"name": "Search quote_attachments",
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "// Keep only the latest PDF attachment row per quote.\n// (Upstream `Search quote_attachments` already scopes to this quote's\n// id and kind = 'pdf'; no further filename filtering needed here.)\nconst items = $input.all();\n\nconst byQuote = {};\nfor (const item of items) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-912,
|
||
288
|
||
],
|
||
"id": "17c9488c-6567-45fb-af45-2ecb841542a7",
|
||
"name": "Code in JavaScript"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "quotes",
|
||
"operation": "update",
|
||
"elementId": "={{ $('Search quotes').item.json.id }}",
|
||
"parameters": {},
|
||
"fields": {
|
||
"assignments": [
|
||
{
|
||
"id": "200ad8f1-0104-439f-a8c4-56476dcf0e48",
|
||
"name": "status",
|
||
"value": "emailed",
|
||
"type": "string"
|
||
}
|
||
]
|
||
},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-240,
|
||
272
|
||
],
|
||
"id": "22f9730d-a5e2-44aa-b93f-587b8e37848c",
|
||
"name": "Update quotes",
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "users",
|
||
"operation": "view",
|
||
"elementId": "={{ $('View customers').item.json.rep }}",
|
||
"parameters": {},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
208,
|
||
272
|
||
],
|
||
"id": "1d675ee9-9027-4fc9-ba82-a9b9ff83681c",
|
||
"name": "View users",
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"deviceIdOverride": "4ce3633b-f2e1-4732-9127-7efe46711b45",
|
||
"operation": "sendMedia",
|
||
"phoneNumber": "27828524633",
|
||
"mediaType": "file",
|
||
"caption": "={{ $('HTTP Request').item.json.filename }} \n{{ $('View customers').item.json.customer }}\n{{ $('View customers').item.json.email }}\n\nCLAIMED BY - {{ $('VIEW claimed').item.json.name }}\nQUOTE PROCESS TIME - {{ $('Code in JavaScript2').item.json.work_days_duration }}"
|
||
},
|
||
"type": "@aldinokemal2104/n8n-nodes-gowa.gowa",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1552,
|
||
256
|
||
],
|
||
"id": "0b6b950d-13b0-4d53-8acb-5d427afe40c6",
|
||
"name": "Send media files",
|
||
"credentials": {
|
||
"goWhatsappApi": {
|
||
"id": "NcclW50L0w20LhlV",
|
||
"name": "GOWA account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io/api/files/{{ $json.collectionId }}/{{ $json.id }}/{{ $json.file[0] }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.5,
|
||
"position": [
|
||
1104,
|
||
272
|
||
],
|
||
"id": "230c9ea7-8fcd-4373-901b-b1def618ff17",
|
||
"name": "HTTP Request1"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "quote_attachments",
|
||
"parameters": {
|
||
"filter": "=quote = '{{ $('Search quotes').item.json.id }}' && kind = 'pdf'"
|
||
},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
656,
|
||
272
|
||
],
|
||
"id": "79596ab4-97e5-46d9-960f-96e0392321a7",
|
||
"name": "Search quote_attachments1",
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "// Keep only the latest PDF attachment row per quote.\n// (Upstream `Search quote_attachments1` already scopes to this quote's\n// id and kind = 'pdf'; no further filename filtering needed here.)\nconst items = $input.all();\n\nconst byQuote = {};\nfor (const item of items) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
880,
|
||
272
|
||
],
|
||
"id": "01009e23-7df8-4b01-a203-0148373b36d1",
|
||
"name": "Code in JavaScript1"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "users",
|
||
"operation": "view",
|
||
"elementId": "={{ $('Search quotes').item.json.claimed_by }}",
|
||
"parameters": {},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
432,
|
||
272
|
||
],
|
||
"id": "d312acc9-3b1b-4804-be96-47e386cf9feb",
|
||
"name": "VIEW claimed",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
},
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "users",
|
||
"operation": "view",
|
||
"elementId": "={{ $json.rep || 'eu0xus9z8le7n6v' }}",
|
||
"parameters": {},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-1360,
|
||
288
|
||
],
|
||
"id": "217ab67d-8bbd-4fd8-bea7-1f2e936c0913",
|
||
"name": "View users1",
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "// Compute work-day duration between created and sending_started_at\nfunction workDaysBetween(startStr, endStr) {\n const start = new Date(startStr);\n const end = new Date(endStr);\n if (isNaN(start) || isNaN(end) || end < start) return 'N/A';\n\n const cur = new Date(start);\n cur.setHours(0, 0, 0, 0);\n const endDay = new Date(end);\n endDay.setHours(0, 0, 0, 0);\n\n let count = 0;\n while (cur < endDay) {\n cur.setDate(cur.getDate() + 1);\n const day = cur.getDay();\n if (day !== 0 && day !== 6) count++; // skip Sat/Sun\n }\n return count;\n}\n\nreturn $input.all().map(item => {\n const created = $('Search quotes').item.json.created;\n const processed = $('Search quotes').item.json.sending_started_at;\n const days = workDaysBetween(created, processed);\n return {\n json: {\n ...item.json,\n work_days_duration: days === 'N/A' ? 'N/A' : `${days} work day${days === 1 ? '' : 's'}`\n }\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16,
|
||
272
|
||
],
|
||
"id": "49aa5965-281f-467c-9616-413d4de54fb7",
|
||
"name": "Code in JavaScript2"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"rule": {
|
||
"interval": [
|
||
{
|
||
"field": "minutes",
|
||
"minutesInterval": 4
|
||
}
|
||
]
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.scheduleTrigger",
|
||
"typeVersion": 1.3,
|
||
"position": [
|
||
-2176,
|
||
544
|
||
],
|
||
"id": "e33773d4-da92-49e7-8e1a-a68854cd3150",
|
||
"name": "Schedule Trigger"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "quotes",
|
||
"operation": "update",
|
||
"elementId": "={{ $('Search quotes').item.json.id }}",
|
||
"parameters": {},
|
||
"fields": {
|
||
"assignments": [
|
||
{
|
||
"id": "status-send-failed",
|
||
"name": "status",
|
||
"type": "string",
|
||
"value": "mail_failed"
|
||
}
|
||
]
|
||
},
|
||
"requestOptions": {}
|
||
},
|
||
"id": "3e2816ef-26fe-4e77-9033-725c1989c78c",
|
||
"name": "Update quote - Send Failed",
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-240,
|
||
480
|
||
],
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "quote_attachments",
|
||
"parameters": {
|
||
"filter": "=quote = '{{ $('Search quotes').item.json.id }}' && kind = 'pdf'"
|
||
},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-16,
|
||
480
|
||
],
|
||
"id": "221aefad-c648-4e1f-9511-487b4a17f468",
|
||
"name": "SEARCH FAILED QUOTES",
|
||
"credentials": {
|
||
"pocketbaseHttpApi": {
|
||
"id": "hLwB1QPIJEWsuVyW",
|
||
"name": "PocketBase HTTP account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "// Keep only the latest PDF attachment row per quote.\n// (Upstream `SEARCH FAILED QUOTES` already scopes to this quote's\n// id and kind = 'pdf'; no further filename filtering needed here.)\nconst items = $input.all();\n\nconst byQuote = {};\nfor (const item of items) {\n const quoteId = item.json.quote;\n const uploaded = new Date(item.json.uploaded_at).getTime();\n\n if (!byQuote[quoteId] || uploaded > byQuote[quoteId].uploaded) {\n byQuote[quoteId] = { item, uploaded };\n }\n}\n\nreturn Object.values(byQuote).map(entry => entry.item);"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
208,
|
||
480
|
||
],
|
||
"id": "cacf85ba-3b38-4ddd-9a6b-aa9b6e7d290d",
|
||
"name": "FILTER FAILED QUOTES"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io/api/files/{{ $json.collectionId }}/{{ $json.id }}/{{ $json.file[0] }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.5,
|
||
"position": [
|
||
432,
|
||
480
|
||
],
|
||
"id": "7243dd8c-33b7-4b44-8f39-35d3ee5617e6",
|
||
"name": "DOWNLOAD FAILED QUOTE"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"deviceIdOverride": "4ce3633b-f2e1-4732-9127-7efe46711b45",
|
||
"operation": "sendMedia",
|
||
"phoneNumber": "={{ $('View users1').item.json.tel }}",
|
||
"mediaType": "file",
|
||
"caption": "=QUOTE FAILDED TO SEND VIA EMAIL\n{{ $('HTTP Request').item.json.filename }} \n{{ $('View customers').item.json.customer }}\n{{ $('View customers').item.json.email }}\n"
|
||
},
|
||
"type": "@aldinokemal2104/n8n-nodes-gowa.gowa",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
656,
|
||
480
|
||
],
|
||
"id": "2639682b-2714-400f-8a57-fe4d7f7823be",
|
||
"name": "SEND FAILED MESSAGE",
|
||
"credentials": {
|
||
"goWhatsappApi": {
|
||
"id": "NcclW50L0w20LhlV",
|
||
"name": "GOWA account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"deviceIdOverride": "4ce3633b-f2e1-4732-9127-7efe46711b45",
|
||
"operation": "sendMedia",
|
||
"phoneNumber": "={{ $('View users1').item.json.tel }}",
|
||
"mediaType": "file",
|
||
"caption": "={{ $('HTTP Request').item.json.filename }} \n{{ $('View customers').item.json.customer }}\n{{ $('View customers').item.json.email }}\n\nCLAIMED BY - {{ $('VIEW claimed').item.json.name }}\nQUOTE PROCESS TIME - {{ $('Code in JavaScript2').item.json.work_days_duration }}"
|
||
},
|
||
"type": "@aldinokemal2104/n8n-nodes-gowa.gowa",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1552,
|
||
464
|
||
],
|
||
"id": "9d9f79b3-9598-427d-8602-958e769e8529",
|
||
"name": "Send media files1",
|
||
"credentials": {
|
||
"goWhatsappApi": {
|
||
"id": "NcclW50L0w20LhlV",
|
||
"name": "GOWA account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a2abe785-2f93-4e24-b0e2-baf9909ea940",
|
||
"leftValue": "={{ $('View users1').item.json.name }}",
|
||
"rightValue": "Gary",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
1296,
|
||
272
|
||
],
|
||
"id": "db6ed47b-8a63-4c65-9260-87ab30683752",
|
||
"name": "If"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "// Build the outbound attachments payload for the SMTP send.\n//\n// n8n's SMTP node v2.1 reads attachments from BINARY properties on\n// the input item. Each file we want attached becomes a named\n// `binary.<name>` entry on the single item we return. The SMTP node's\n// `options.attachments` expression resolves\n// `Object.keys($binary).join(',')` and Nodemailer does the rest.\n//\n// Binary objects are built by hand ({data: base64, mimeType, fileName,\n// fileExtension}) instead of via `this.helpers.prepareBinaryData`.\n// Execution 163729 showed prepareBinaryData throwing an opaque\n// error that serialised to '{}' for BOTH the PDF buffer (from\n// getBinaryDataBuffer) and the outbound-token buffer (from\n// httpRequest) -- two otherwise-unrelated code paths that only share\n// that one trailing call. Building the binary object manually from\n// the same Buffer sidesteps whatever prepareBinaryData was choking\n// on (likely something in this instance's binary-data-manager /\n// filesystem-mode bookkeeping) since a plain base64 `data` field is\n// the same wire shape and doesn't need the helper's extra bookkeeping.\n//\n// For the auto-generated PDF, the upstream `HTTP Request` node's\n// `binary.data` already holds the downloaded bytes -- we just pull\n// the Buffer via `getBinaryDataBuffer` and re-wrap it under a\n// friendly name.\n//\n// For the operator-chosen outbound attachments, we download each\n// fresh from PB via `this.helpers.httpRequest` with `encoding:\n// 'arraybuffer'` (lowercase, matching n8n's IHttpRequestOptions --\n// `fetch` is NOT available in the Code node sandbox, and `encoding:\n// null` was removed in n8n 1.0+).\n\nconst PB_BASE = 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';\n\nconst quoteRow = ($('Search quotes').first() || {}).json || {};\nconst quoteId = quoteRow.id;\nconst quoteNumber = quoteRow.quote_number || 'quote';\n\n// PDF metadata from the upstream `HTTP Request` node -- its binary.data\n// already contains the downloaded PDF bytes. We just rename it.\nconst pdfMeta = ($('HTTP Request').first() || {}).json || {};\nconst pdfFile = (pdfMeta.file && pdfMeta.file[0]) || '';\nconst pdfRawName = pdfMeta.filename || (quoteNumber + '.pdf');\nconst pdfName = String(pdfRawName)\n .replace(/[\\\\/:*?\"<>|]+/g, '_')\n .trim() || 'quote.pdf';\n\n// Outbound token intersection -- defends against drift between the\n// operator's modal selection and the row's denormalised\n// `outbound_attachments` field.\nconst allOutbound = Array.isArray(quoteRow.outbound_attachments)\n ? quoteRow.outbound_attachments\n : quoteRow.outbound_attachments\n ? [quoteRow.outbound_attachments]\n : [];\n\nlet selected = [];\ntry {\n const parsed = JSON.parse(quoteRow.email_outbound_attachment_ids || '[]');\n if (Array.isArray(parsed)) selected = parsed;\n} catch (e) {\n selected = [];\n}\n\nconst tokensToAttach = allOutbound.filter((t) => selected.includes(t));\n\nconst MIME_BY_EXT = {\n pdf: 'application/pdf',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n gif: 'image/gif',\n txt: 'text/plain',\n csv: 'text/csv',\n doc: 'application/msword',\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n xls: 'application/vnd.ms-excel',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n zip: 'application/zip',\n};\n\nconst binary = {};\nconst filenames = [];\nconst errors = [];\n\nfunction describeErr(err) {\n if (!err) return 'unknown error';\n if (err instanceof Error) {\n const own = Object.getOwnPropertyNames(err).filter((k) => k !== 'stack' && k !== 'message');\n const extra = own.length\n ? ' [' + own.map((k) => { try { return k + '=' + JSON.stringify(err[k]).slice(0, 200); } catch (e) { return k + '=<unserializable>'; } }).join(', ') + ']'\n : '';\n return (err.message || err.name || 'Error') + extra;\n }\n if (typeof err === 'string') return err;\n try {\n const own = Object.getOwnPropertyNames(err);\n if (own.length === 0) {\n return 'non-Error thrown value with no own properties: ' + Object.prototype.toString.call(err);\n }\n return JSON.stringify(err, own);\n } catch (e) {\n return String(err);\n }\n}\n\n// Build an n8n IBinaryData object directly from a Buffer/ArrayBuffer,\n// bypassing `this.helpers.prepareBinaryData` (see comment above).\nfunction bufferToBinaryData(buf, fileName, mime) {\n const ext = (fileName.split('.').pop() || '').toLowerCase();\n return {\n data: Buffer.from(buf).toString('base64'),\n mimeType: mime,\n fileName,\n fileExtension: ext,\n };\n}\n\nasync function downloadArrayBuffer(url) {\n const resp = await this.helpers.httpRequest({\n method: 'GET',\n url,\n encoding: 'arraybuffer',\n timeout: 15000,\n returnFullResponse: true,\n });\n const buf = resp && resp.body;\n const status = resp && resp.statusCode;\n if (!buf) {\n throw new Error('Empty response body (status ' + status + ')');\n }\n return buf;\n}\n\n// 1. Auto-generated PDF: rename the upstream binary.data -> binary.pdf.\nif (pdfFile && pdfMeta.id) {\n const upstream = ($('HTTP Request').first() || {}).binary || {};\n const pdfBin = upstream.data;\n if (pdfBin) {\n try {\n const upstreamItem = $('HTTP Request').first();\n const buf = await this.helpers.getBinaryDataBuffer(upstreamItem, 'data');\n binary.pdf = bufferToBinaryData(buf, pdfName, 'application/pdf');\n filenames.push(pdfName);\n } catch (err) {\n errors.push('PDF (' + pdfFile + '): ' + describeErr(err));\n }\n } else {\n errors.push('PDF (' + pdfFile + '): upstream HTTP Request produced no binary.data');\n }\n}\n\n// 2. Outbound files the operator chose.\nfor (let i = 0; i < tokensToAttach.length; i++) {\n const token = tokensToAttach[i];\n const idx = token.indexOf('_');\n const displayName = ((idx >= 0 ? token.slice(idx + 1) : token)\n .replace(/[\\\\/:*?\"<>|]+/g, '_')\n .trim()) || token;\n try {\n const url = PB_BASE + '/api/files/quotes/' + quoteId + '/' + token;\n const ext = displayName.split('.').pop().toLowerCase();\n const mime = MIME_BY_EXT[ext] || 'application/octet-stream';\n const buf = await downloadArrayBuffer.call(this, url);\n binary['outbound_' + i] = bufferToBinaryData(buf, displayName, mime);\n filenames.push(displayName);\n } catch (err) {\n errors.push(displayName + ': ' + describeErr(err));\n }\n}\n\n// Preserve the upstream item's json metadata so the SMTP node's\n// subject / html / to / cc expressions still resolve. We expose the\n// attachment metadata as JSON for logging on the dashboard.\nconst firstInput = $input.first() || {};\nconst json = Object.assign({}, firstInput.json || {}, pdfMeta, {\n attachment_count: filenames.length,\n attachment_filenames: filenames,\n attachment_errors: errors,\n});\n\nif (Object.keys(binary).length === 0) {\n return [{ json, binary: undefined }];\n}\n\nreturn [{ json, binary }];\n"
|
||
},
|
||
"id": "ab09c2d4-3f11-4d6e-a9a1-9b0d3c1f47e2",
|
||
"name": "Build attachments list",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-576,
|
||
288
|
||
]
|
||
}
|
||
],
|
||
"connections": {
|
||
"Send Email (SMTP)": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Update quotes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Update quote - Send Failed",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"When clicking ‘Execute workflow’": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Search quotes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Search quotes": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "View customers",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"View customers": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "View users1",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Search quote_attachments": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code in JavaScript",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code in JavaScript": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Update quotes": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code in JavaScript2",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"View users": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "VIEW claimed",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP Request1": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "If",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Search quote_attachments1": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code in JavaScript1",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code in JavaScript1": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP Request1",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"VIEW claimed": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Search quote_attachments1",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"View users1": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Search quote_attachments",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code in JavaScript2": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "View users",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Schedule Trigger": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Search quotes",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Update quote - Send Failed": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "SEARCH FAILED QUOTES",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"SEARCH FAILED QUOTES": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "FILTER FAILED QUOTES",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"FILTER FAILED QUOTES": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "DOWNLOAD FAILED QUOTE",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"DOWNLOAD FAILED QUOTE": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "SEND FAILED MESSAGE",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"If": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Send media files",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Send media files1",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Send media files",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Build attachments list",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Build attachments list": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Send Email (SMTP)",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"settings": {
|
||
"executionOrder": "v1",
|
||
"binaryMode": "separate",
|
||
"availableInMCP": true
|
||
},
|
||
"staticData": {
|
||
"node:Schedule Trigger": {
|
||
"recurrenceRules": [
|
||
null
|
||
],
|
||
"recurrenceRuleSignatures": [
|
||
null
|
||
]
|
||
}
|
||
}
|
||
}
|