The first letterhead-persistence fix (1e3121d) gated the WHOLE PB
write for generateQuotePdf's operator-choice persistence behind
"quote.status is not terminal" -- because terminal statuses
(approved/emailed/complete/cancelled/sending) deliberately skip the
pdf_generated_at re-stamp.
But regenerating a PDF on an already-terminal quote (e.g. clicking
"ISherwood" on an already-`emailed` quote from the process page) is
a normal, supported operator action -- confirmed live: it renders
the right letterhead and toasts "quote kept at emailed". That regen
hit the terminal branch and skipped the PB write entirely, so
company_letterhead never updated. A later auto-regen (sendQuoteEmail's
staleness check, e.g. triggered by a markup edit) then fell back to
the stale stored value and shipped the wrong letterhead again --
reproducing the original bug through a path the first fix didn't
cover.
Verified against the live quote via a direct PocketBase read
(bypassing app/MCP caching): company_letterhead was unchanged by the
first fix's terminal-status regen, and is now correctly persisted
while pdf_generated_at/status stay untouched as intended.
Split the letterhead write out from the timestamp/status write so it
always persists on an explicit operator choice regardless of quote
status. Added a regression test for the terminal-status + explicit
letterhead case.
Also syncs the n8n "Quote Outbound Send" workflow snapshot with two
more live fixes made during testing:
- Build attachments list: this.helpers.getBinaryDataBuffer() takes
an item INDEX (number), not an item object -- per n8n's own docs.
Was passing the item object, which threw an opaque non-Error value
on every real send.
- Send Email (SMTP): the html field injected the plain-text
email_message (with literal \n breaks) straight into HTML, so
clients collapsed every paragraph into one block. Now converts \n
to <br> first.
Also captures that the four WhatsApp-notify nodes and the workflow
itself are currently disabled/inactive in the live instance (manual
n8n edits during testing, not part of this fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
732 lines
27 KiB
JSON
732 lines
27 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 || '').replace(/\\n/g, '<br>') }}",
|
||
"options": {
|
||
"appendAttribution": false,
|
||
"attachments": "={{ Object.keys($binary || {}).join(',') }}",
|
||
"ccEmail": "={{ ($('Search quotes').item.json.email_cc || '').trim() }}",
|
||
"bccEmail": "=",
|
||
"replyTo": "quotes@wfsales.co.za"
|
||
}
|
||
},
|
||
"id": "8c00fc59-3492-42d3-8f3f-66a86eddfc31",
|
||
"name": "Send Email (SMTP)",
|
||
"type": "n8n-nodes-base.emailSend",
|
||
"typeVersion": 2.1,
|
||
"position": [
|
||
-384,
|
||
448
|
||
],
|
||
"retryOnFail": true,
|
||
"maxTries": 3,
|
||
"waitBetweenTries": 5000,
|
||
"webhookId": "d03c93e2-7bf8-4006-8542-5d94a87e68d1",
|
||
"onError": "continueErrorOutput"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.manualTrigger",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-2176,
|
||
352
|
||
],
|
||
"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": [
|
||
-1952,
|
||
448
|
||
],
|
||
"id": "9ffcb69c-e88c-477a-8b1b-d65f7b2db527",
|
||
"name": "Search quotes"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "customers",
|
||
"operation": "view",
|
||
"elementId": "={{ $('Search quotes').item.json.customer }}",
|
||
"parameters": {},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-1728,
|
||
448
|
||
],
|
||
"id": "eba6b537-d696-4d72-a794-85149c645fbd",
|
||
"name": "View customers"
|
||
},
|
||
{
|
||
"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": [
|
||
-832,
|
||
448
|
||
],
|
||
"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": [
|
||
-1280,
|
||
448
|
||
],
|
||
"id": "14fb9665-84b5-49f3-9f68-3d2d3e2a6167",
|
||
"name": "Search quote_attachments"
|
||
},
|
||
{
|
||
"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": [
|
||
-1056,
|
||
448
|
||
],
|
||
"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": [
|
||
-160,
|
||
352
|
||
],
|
||
"id": "22f9730d-a5e2-44aa-b93f-587b8e37848c",
|
||
"name": "Update quotes"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "users",
|
||
"operation": "view",
|
||
"elementId": "={{ $('View customers').item.json.rep }}",
|
||
"parameters": {},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
288,
|
||
352
|
||
],
|
||
"id": "1d675ee9-9027-4fc9-ba82-a9b9ff83681c",
|
||
"name": "View users"
|
||
},
|
||
{
|
||
"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": [
|
||
1632,
|
||
256
|
||
],
|
||
"id": "0b6b950d-13b0-4d53-8acb-5d427afe40c6",
|
||
"name": "Send media files",
|
||
"disabled": true
|
||
},
|
||
{
|
||
"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": [
|
||
1184,
|
||
352
|
||
],
|
||
"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": [
|
||
736,
|
||
352
|
||
],
|
||
"id": "79596ab4-97e5-46d9-960f-96e0392321a7",
|
||
"name": "Search quote_attachments1"
|
||
},
|
||
{
|
||
"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": [
|
||
960,
|
||
352
|
||
],
|
||
"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": [
|
||
512,
|
||
352
|
||
],
|
||
"id": "d312acc9-3b1b-4804-be96-47e386cf9feb",
|
||
"name": "VIEW claimed",
|
||
"alwaysOutputData": true,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "users",
|
||
"operation": "view",
|
||
"elementId": "={{ $json.rep || 'eu0xus9z8le7n6v' }}",
|
||
"parameters": {},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-1504,
|
||
448
|
||
],
|
||
"id": "217ab67d-8bbd-4fd8-bea7-1f2e936c0913",
|
||
"name": "View users1"
|
||
},
|
||
{
|
||
"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": [
|
||
64,
|
||
352
|
||
],
|
||
"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": [
|
||
-160,
|
||
544
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "quote_attachments",
|
||
"parameters": {
|
||
"filter": "=quote = '{{ $('Search quotes').item.json.id }}' && kind = 'pdf'"
|
||
},
|
||
"requestOptions": {}
|
||
},
|
||
"type": "n8n-nodes-pocketbase.pocketbaseHttp",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
64,
|
||
544
|
||
],
|
||
"id": "221aefad-c648-4e1f-9511-487b4a17f468",
|
||
"name": "SEARCH FAILED QUOTES"
|
||
},
|
||
{
|
||
"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": [
|
||
288,
|
||
544
|
||
],
|
||
"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": [
|
||
512,
|
||
544
|
||
],
|
||
"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": [
|
||
736,
|
||
544
|
||
],
|
||
"id": "2639682b-2714-400f-8a57-fe4d7f7823be",
|
||
"name": "SEND FAILED MESSAGE",
|
||
"disabled": true,
|
||
"onError": "continueErrorOutput"
|
||
},
|
||
{
|
||
"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": [
|
||
1632,
|
||
448
|
||
],
|
||
"id": "9d9f79b3-9598-427d-8602-958e769e8529",
|
||
"name": "Send media files1",
|
||
"disabled": true
|
||
},
|
||
{
|
||
"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": [
|
||
1408,
|
||
352
|
||
],
|
||
"id": "db6ed47b-8a63-4c65-9260-87ab30683752",
|
||
"name": "If",
|
||
"disabled": true
|
||
},
|
||
{
|
||
"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// that helper threw an opaque error on this instance (execution\n// 163729). A plain base64 `data` field is the same wire shape n8n\n// expects and doesn't need prepareBinaryData's extra bookkeeping.\n//\n// For the auto-generated PDF: `this.helpers.getBinaryDataBuffer()`\n// takes an ITEM INDEX (number), not an item object -- per n8n's docs,\n// `getBinaryDataBuffer(itemIndex, binaryPropertyName)`. Execution\n// 163852 was passing the upstream item object instead of an index,\n// which threw a non-Error opaque value. This node's only upstream\n// node is `HTTP Request` (single item in, single item out), so index\n// 0 is always the right item.\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 buf = await this.helpers.getBinaryDataBuffer(0, '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": [
|
||
-608,
|
||
448
|
||
]
|
||
}
|
||
],
|
||
"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
|
||
]
|
||
}
|
||
}
|
||
}
|