{ "name": "Archive IMAP email attachments in Folderit with verified files and saved receipts", "nodes": [ { "id": "254477ba-a1b0-547b-9b5e-8d5939f83fe7", "name": "1. Set up receipt table", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ 0, 1300 ], "parameters": {} }, { "id": "123a2caf-22b0-5d2d-aa4e-875c7aa2928c", "name": "Ledger setup settings", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 240, 1300 ], "parameters": { "mode": "raw", "jsonOutput": "{\n \"tableName\": \"Folderit IMAP archive receipts\"\n}", "options": {} } }, { "id": "2809966f-565a-56b1-93fa-dccd3f40bee8", "name": "Setup: Create or reuse ledger", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 480, 1300 ], "parameters": { "resource": "table", "operation": "create", "tableName": "={{ $('Ledger setup settings').first().json.tableName }}", "columns": { "column": [ { "name": "key", "type": "string" }, { "name": "state", "type": "string" }, { "name": "owner", "type": "string" }, { "name": "payloadHash", "type": "string" }, { "name": "targetUid", "type": "string" }, { "name": "sourceRef", "type": "string" } ] }, "options": { "createIfNotExists": true } } }, { "id": "d1fd0b0f-63b4-5da1-b315-04f0ccc23788", "name": "Setup: Validate table schema", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 720, 1300 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const table=$input.first().json; const expected=[\"key\", \"state\", \"owner\", \"payloadHash\", \"targetUid\", \"sourceRef\"]; if(!table.id || !Array.isArray(table.columns) || expected.some(k=>!table.columns.some(c=>c.name===k && c.type==='string'))) throw new Error('Existing table has an incompatible schema. Choose a new table name; do not replace customer data.'); return [{json:table}];" } }, { "id": "bc4977ee-6c8a-57a3-afce-a3b351491356", "name": "Setup: Find existing lock", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 960, 1300 ], "parameters": { "resource": "row", "operation": "get", "dataTableId": { "__rl": true, "mode": "id", "value": "={{ $('Setup: Create or reuse ledger').first().json.id }}" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "key", "condition": "eq", "keyValue": "__mutex__" } ] }, "returnAll": false, "limit": 2 }, "alwaysOutputData": true }, { "id": "13509ec8-6504-55e9-92a2-b99e67f06812", "name": "Setup: Check setup state", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1200, 1300 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const rows=$input.all().filter(x=>x.json.id!==undefined); if(rows.length>1) throw new Error('Duplicate mutex rows need manual reconciliation.'); if(rows.length===1 && rows[0].json.sourceRef!=='folderit-ledger/v1') throw new Error('Unexpected lock schema.'); return [{json:{exists:rows.length?'yes':'no',tableId:$('Setup: Create or reuse ledger').first().json.id}}];" } }, { "id": "da249309-0413-5501-b738-52e55a044e12", "name": "Setup: Lock already exists?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1440, 1300 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "83b8d873-b1db-51ff-b8a6-bec0577b9df8", "leftValue": "={{ $json.exists }}", "rightValue": "yes", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} } }, { "id": "f84e891f-bf37-56ae-ae0f-7e8752cf61a3", "name": "Setup: Create initial lock", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 1680, 1450 ], "parameters": { "resource": "row", "operation": "insert", "dataTableId": { "__rl": true, "mode": "id", "value": "={{ $('Setup: Create or reuse ledger').first().json.id }}" }, "matchType": "allConditions", "filters": { "conditions": [] }, "columns": { "mappingMode": "defineBelow", "value": { "key": "__mutex__", "state": "free", "owner": "", "payloadHash": "", "targetUid": "", "sourceRef": "folderit-ledger/v1" }, "matchingColumns": [], "schema": [ { "id": "key", "displayName": "key", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "state", "displayName": "state", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "owner", "displayName": "owner", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "payloadHash", "displayName": "payloadHash", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "targetUid", "displayName": "targetUid", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "sourceRef", "displayName": "sourceRef", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false } ] }, "options": {} }, "alwaysOutputData": true }, { "id": "9481b87b-c7a4-55fe-99ca-992daac6a75d", "name": "Copy ledgerTableId into Settings", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1920, 1300 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "return [{json:{ledgerTableId:$('Setup: Create or reuse ledger').first().json.id, note:'Copy this ID into Settings in the customer workflows. Setup never resets an existing lock. Run setup once, not concurrently.'}}];" } }, { "id": "80496824-1baa-5330-84ab-6c03aea58e6e", "name": "New email with attachments", "type": "n8n-nodes-base.emailReadImap", "typeVersion": 2.2, "position": [ 0, 400 ], "parameters": { "mailbox": "INBOX", "postProcessAction": "nothing", "downloadAttachments": true, "format": "simple", "dataPropertyAttachmentsPrefixName": "attachment_", "options": { "trackLastMessageId": true } } }, { "id": "aaff4754-021a-5747-b23b-725e44c7e6e7", "name": "Settings", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 200, 400 ], "parameters": { "mode": "raw", "jsonOutput": "{\n \"accountUid\": \"YOUR_ACCOUNT_UID\",\n \"folderUid\": \"YOUR_DESTINATION_FOLDER_UID\",\n \"ledgerTableId\": \"YOUR_LEDGER_TABLE_ID\",\n \"mailboxScope\": \"YOUR_STABLE_MAILBOX_LABEL\",\n \"allowedExtensions\": \"pdf,docx,txt\"\n}", "options": {} } }, { "id": "524f913d-0ae4-57db-a5b1-fbd9ca0008b6", "name": "Split and validate attachments", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 400, 400 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const cfg=$('Settings').first().json;\nfor(const key of ['accountUid','folderUid','ledgerTableId','mailboxScope']) if(typeof cfg[key]!=='string'||!cfg[key].trim()||cfg[key].startsWith('YOUR_')) throw new Error(`Replace ${key} in Settings.`);\nconst allowed=String(cfg.allowedExtensions||'').split(',').map(x=>x.trim().toLowerCase().replace(/^\\./,'')).filter(Boolean);\nif(!allowed.length)throw new Error('Set allowedExtensions, for example pdf,docx,txt.');\nconst out=[]; const mail=$('New email with attachments').all();\nfor(let i=0;itypeof value==='string'&&value.trim())?.trim()||'';\n const parts=Object.entries(mail[i].binary||{}).filter(([k])=>k.startsWith('attachment_'));\n if(parts.length&&!messageId)throw new Error('Email has no stable Message-ID/provider ID. Inspect it before archiving.');\n for(const [field,b] of parts){\n const fileName=String(b.fileName||'').split(/[\\\\/]/).pop().replace(/[\\x00-\\x1f\\x7f]/g,'').trim();\n const ext=fileName.includes('.')?fileName.split('.').pop().toLowerCase():'';\n if(!fileName||!allowed.includes(ext))continue;\n out.push({json:{accountUid:cfg.accountUid,folderUid:cfg.folderUid,mailboxScope:cfg.mailboxScope,messageId,sourceAttachment:field,fileName},binary:{data:{...b,fileName}},pairedItem:{item:i}});\n }\n}\nreturn out;" } }, { "id": "b613c6dc-9441-52d8-9e35-f563dd9b3ff5", "name": "Hash attachment bytes", "type": "n8n-nodes-base.crypto", "typeVersion": 2, "position": [ 600, 400 ], "parameters": { "action": "hash", "binaryData": true, "binaryPropertyName": "data", "type": "SHA256", "dataPropertyName": "payloadHash", "encoding": "hex" } }, { "id": "54ec7af5-1323-5b28-b107-b34023730e8a", "name": "Build attachment receipt keys", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 800, 400 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const attachments=$('Split and validate attachments').all();\nreturn $input.all().map((x,i)=>{\n if(!/^[a-f0-9]{64}$/.test(x.json.payloadHash||''))throw new Error('Missing attachment SHA-256.');\n if(x.json.payloadHash==='e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')throw new Error('Empty attachments cannot be archived.');\n const binary=attachments[i]?.binary;\n if(!binary?.data)throw new Error('The source attachment binary is unavailable; no upload was attempted.');\n const j=x.json;\n return {json:{...j,receiptKey:JSON.stringify(['archive-v1',j.accountUid,j.folderUid,j.mailboxScope,j.messageId,j.sourceAttachment,j.fileName,j.payloadHash]),sourceRef:JSON.stringify([j.mailboxScope,j.messageId,j.sourceAttachment])},binary,pairedItem:{item:i}};\n});" } }, { "id": "f31381ef-b3bc-56d9-b93a-0bb7ad339c63", "name": "Process one item", "type": "n8n-nodes-base.splitInBatches", "typeVersion": 3, "position": [ 1100, 760 ], "parameters": { "batchSize": 1, "options": {} } }, { "id": "b375a68c-57ab-5a3e-be81-5b0a0b061b38", "name": "Read processing lock", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 1100, 400 ], "parameters": { "resource": "row", "operation": "get", "dataTableId": { "__rl": true, "mode": "id", "value": "={{ $('Settings').first().json.ledgerTableId }}" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "key", "condition": "eq", "keyValue": "__mutex__" } ] }, "returnAll": false, "limit": 2 }, "alwaysOutputData": true }, { "id": "5dfa307e-0280-5ea8-91d6-a600aa9db636", "name": "Check lock record", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1300, 400 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "if (!$execution.id) throw new Error('No execution identity is available; processing was stopped before locking.');\nconst rows = $input.all().filter(x => x.json.id !== undefined);\nif (rows.length !== 1 || rows[0].json.sourceRef !== 'folderit-ledger/v1') throw new Error('Ledger lock is missing, duplicated or has the wrong schema. Run the setup workflow once and inspect the table.');\nreturn rows;" } }, { "id": "a61c54c5-6ae3-51ca-ae52-9a58de473439", "name": "Acquire processing lock", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 1500, 400 ], "parameters": { "resource": "row", "operation": "update", "dataTableId": { "__rl": true, "mode": "id", "value": "={{ $('Settings').first().json.ledgerTableId }}" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "id", "condition": "eq", "keyValue": "={{ $json.id }}" }, { "keyName": "key", "condition": "eq", "keyValue": "__mutex__" }, { "keyName": "state", "condition": "eq", "keyValue": "free" }, { "keyName": "owner", "condition": "eq", "keyValue": "" } ] }, "columns": { "mappingMode": "defineBelow", "value": { "state": "held", "owner": "={{ String($execution.id) }}" }, "matchingColumns": [], "schema": [ { "id": "key", "displayName": "key", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "state", "displayName": "state", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "owner", "displayName": "owner", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "payloadHash", "displayName": "payloadHash", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "targetUid", "displayName": "targetUid", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "sourceRef", "displayName": "sourceRef", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false } ] }, "options": {} }, "alwaysOutputData": true }, { "id": "88a5f52b-088b-5380-9b1c-6c183450a123", "name": "Confirm lock ownership", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1700, 400 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const rows = $input.all().filter(x => x.json.id !== undefined);\nif (rows.length !== 1 || rows[0].json.owner !== String($execution.id) || rows[0].json.state !== 'held') throw new Error('Another run owns the processing lock. No destination write was attempted by this run. Retain this failed input and retry after the owner completes.');\nreturn rows;" } }, { "id": "8ff0cf1d-a760-526e-b3b9-c70e4fe2419c", "name": "Find processing receipt", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 2400, 400 ], "parameters": { "resource": "row", "operation": "get", "dataTableId": { "__rl": true, "mode": "id", "value": "={{ $('Settings').first().json.ledgerTableId }}" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "key", "condition": "eq", "keyValue": "={{ $('Process one item').item.json.receiptKey }}" } ] }, "returnAll": false, "limit": 2 }, "alwaysOutputData": true }, { "id": "4d153505-7c73-5c52-80a3-4f6894d745e4", "name": "Decide whether to process", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2600, 400 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const item = $('Process one item').item;\nconst rows = $input.all().filter(x => x.json.id !== undefined);\nif (rows.length > 1) throw new Error('Duplicate ledger keys need reconciliation. No destination write was attempted.');\nif (rows.length === 1) {\n const receipt = rows[0].json;\n if (receipt.key !== item.json.receiptKey || (item.json.payloadHash && receipt.payloadHash !== item.json.payloadHash)) throw new Error('The receipt does not match this work item. Inspect before continuing.');\n if (receipt.state !== 'done' || !receipt.targetUid) throw new Error('A prior attempt is unresolved. Inspect the receipt, its owner execution and the destination before clearing the processing lock. Do not repeat the write.');\n return [{json: {...item.json, route: 'done', targetUid: receipt.targetUid, receiptRowId: receipt.id}, binary: item.binary, pairedItem: {item: 0}}];\n}\nreturn [{json: {...item.json, route: 'new'}, binary: item.binary, pairedItem: {item: 0}}];" } }, { "id": "c7bf7b9f-f7d5-536c-9abe-70016fdcbced", "name": "Already completed?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 2800, 400 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "bd514677-bf15-5a33-9398-5564d7f24e27", "leftValue": "={{ $json.route }}", "rightValue": "done", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} } }, { "id": "eab8f60f-4e1a-5a44-a40f-0dcaa429a572", "name": "Record pending attempt", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 3300, 540 ], "parameters": { "resource": "row", "operation": "insert", "dataTableId": { "__rl": true, "mode": "id", "value": "={{ $('Settings').first().json.ledgerTableId }}" }, "matchType": "allConditions", "filters": { "conditions": [] }, "columns": { "mappingMode": "defineBelow", "value": { "key": "={{ $json.receiptKey }}", "state": "pending", "owner": "={{ String($execution.id) }}", "payloadHash": "={{ $json.payloadHash || \"\" }}", "targetUid": "", "sourceRef": "={{ $json.sourceRef }}" }, "matchingColumns": [], "schema": [ { "id": "key", "displayName": "key", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "state", "displayName": "state", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "owner", "displayName": "owner", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "payloadHash", "displayName": "payloadHash", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "targetUid", "displayName": "targetUid", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "sourceRef", "displayName": "sourceRef", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false } ] }, "options": {} }, "alwaysOutputData": true }, { "id": "8d1259b9-2d00-56a4-a9af-f172f5be327d", "name": "Restore work item", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3500, 540 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const rows = $input.all().filter(x => x.json.id !== undefined);\nconst source = $('Process one item').item;\nif (rows.length !== 1 || rows[0].json.state !== 'pending' || rows[0].json.owner !== String($execution.id) || rows[0].json.key !== source.json.receiptKey) throw new Error('Pending receipt was not saved as expected. No destination write was attempted.');\nreturn [{json: {...source.json, receiptRowId: rows[0].json.id}, binary: source.binary, pairedItem: {item: 0}}];" } }, { "id": "c08a4d56-4194-5c0e-b94c-d80c3d079878", "name": "Upload attachment", "type": "n8n-nodes-folderit.folderit", "typeVersion": 1, "position": [ 3700, 540 ], "parameters": { "resource": "file", "operation": "upload", "accountUid": "={{ $json.accountUid }}", "folderUid": "={{ $json.folderUid }}", "binaryPropertyName": "data" } }, { "id": "0c3bdfe5-e87b-59cb-85f2-6a91cc468fb9", "name": "Read uploaded document", "type": "n8n-nodes-folderit.folderit", "typeVersion": 1, "position": [ 3900, 540 ], "parameters": { "resource": "file", "operation": "get", "accountUid": "={{ $('Settings').first().json.accountUid }}", "fileUid": "={{ $json.uid }}" } }, { "id": "d3dc1007-63b6-5f1d-ad49-e410f7980bd1", "name": "Download stored attachment", "type": "n8n-nodes-folderit.folderit", "typeVersion": 1, "position": [ 4200, 760 ], "parameters": { "resource": "file", "operation": "download", "accountUid": "={{ $('Settings').first().json.accountUid }}", "fileUid": "={{ $json.uid }}", "outputBinaryField": "verifiedFile" } }, { "id": "defac51e-6625-5964-927e-24268fbedb7d", "name": "Hash stored attachment bytes", "type": "n8n-nodes-base.crypto", "typeVersion": 2, "position": [ 4400, 760 ], "parameters": { "action": "hash", "binaryData": true, "binaryPropertyName": "verifiedFile", "type": "SHA256", "dataPropertyName": "verifiedHash", "encoding": "hex" } }, { "id": "1ae43e33-0566-5a2e-a172-55eb82b3cb38", "name": "Verify destination result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4600, 540 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const expected=$('Restore work item').item.json; const actual=$('Read uploaded document').item.json; const verifiedHash=$input.first().json.verifiedHash;\nif(!actual.uid || actual.name!==expected.fileName || actual.folderUid!==expected.folderUid || verifiedHash!==expected.payloadHash) throw new Error('Uploaded file readback/hash did not match. Keep the pending receipt and inspect the destination before retrying.');\nreturn [{json:{receiptRowId:expected.receiptRowId,targetUid:actual.uid},pairedItem:{item:0}}];" } }, { "id": "39c85e7c-5795-5f60-aa92-8081d18e6c2b", "name": "Save completed receipt", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 4900, 540 ], "parameters": { "resource": "row", "operation": "update", "dataTableId": { "__rl": true, "mode": "id", "value": "={{ $('Settings').first().json.ledgerTableId }}" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "id", "condition": "eq", "keyValue": "={{ $json.receiptRowId }}" }, { "keyName": "state", "condition": "eq", "keyValue": "pending" }, { "keyName": "owner", "condition": "eq", "keyValue": "={{ String($execution.id) }}" } ] }, "columns": { "mappingMode": "defineBelow", "value": { "state": "done", "targetUid": "={{ $json.targetUid }}" }, "matchingColumns": [], "schema": [ { "id": "key", "displayName": "key", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "state", "displayName": "state", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "owner", "displayName": "owner", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "payloadHash", "displayName": "payloadHash", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "targetUid", "displayName": "targetUid", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "sourceRef", "displayName": "sourceRef", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false } ] }, "options": {} }, "alwaysOutputData": true }, { "id": "5ebbf1ca-e785-5f12-8ec1-33b9eb4b92b9", "name": "Confirm completed receipt", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5100, 540 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const rows = $input.all().filter(x => x.json.id !== undefined);\nconst expected = $('Verify destination result').item.json;\nif (rows.length !== 1 || rows[0].json.state !== 'done' || rows[0].json.targetUid !== expected.targetUid || rows[0].json.owner !== String($execution.id)) throw new Error('The destination action succeeded but its receipt could not be confirmed. Inspect before retrying.');\nreturn [{json: {status: 'completed', targetUid: rows[0].json.targetUid, receiptKey: rows[0].json.key}, pairedItem: {item: 0}}];" } }, { "id": "a8034dd6-f586-5f6a-9841-432c8a8d3faf", "name": "Report previous completion", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3000, 160 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "return [{json: {status: 'previously_completed', targetUid: $json.targetUid, receiptKey: $json.receiptKey}}];" } }, { "id": "4977e50e-4982-5b33-9ce4-8aedb7b0463d", "name": "Release processing lock", "type": "n8n-nodes-base.dataTable", "typeVersion": 1.1, "position": [ 5300, 400 ], "parameters": { "resource": "row", "operation": "update", "dataTableId": { "__rl": true, "mode": "id", "value": "={{ $('Settings').first().json.ledgerTableId }}" }, "matchType": "allConditions", "filters": { "conditions": [ { "keyName": "key", "condition": "eq", "keyValue": "__mutex__" }, { "keyName": "state", "condition": "eq", "keyValue": "held" }, { "keyName": "owner", "condition": "eq", "keyValue": "={{ String($execution.id) }}" } ] }, "columns": { "mappingMode": "defineBelow", "value": { "state": "free", "owner": "" }, "matchingColumns": [], "schema": [ { "id": "key", "displayName": "key", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "state", "displayName": "state", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "owner", "displayName": "owner", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "payloadHash", "displayName": "payloadHash", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "targetUid", "displayName": "targetUid", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false }, { "id": "sourceRef", "displayName": "sourceRef", "required": false, "defaultMatch": false, "display": true, "type": "string", "readOnly": false, "removed": false } ] }, "options": {} }, "alwaysOutputData": true }, { "id": "1c93b2a8-cefc-5f26-8be4-e99073263582", "name": "Confirm lock released", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5500, 400 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const rows = $input.all().filter(x => x.json.id !== undefined);\nif (rows.length !== 1 || rows[0].json.state !== 'free' || rows[0].json.owner !== '' || rows[0].json.key !== '__mutex__') throw new Error('Could not confirm processing lock release. Inspect the table and owner execution.');\nreturn [{json: {lockReleased: true}}];" } }, { "id": "96a8c7f0-81fd-5a9c-86b3-049e276393b1", "name": "Lock acquired?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1700, 120 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "25b1f340-09d9-5114-817b-5d4c95b55a4b", "leftValue": "={{ ($json.owner === String($execution.id) && $json.state === 'held') ? 'yes' : 'no' }}", "rightValue": "yes", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} } }, { "id": "342de397-e972-5ee2-a7ef-49926118fa59", "name": "Limit lock waiting", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1900, 120 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "if ($runIndex >= 11) throw new Error('Processing lock is still busy after bounded waiting. No destination action was repeated. Retain this failed input and inspect the owner execution.'); return [{json:{waitingForLock:true},pairedItem:{item:0}}];" } }, { "id": "8c8c8891-d811-5b5b-b4d0-278be5270b17", "name": "Wait before checking lock", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 2100, 120 ], "parameters": { "amount": 5, "unit": "seconds" } }, { "id": "57d6ce60-bd85-5330-8596-361faee19443", "name": "Overview and requirements", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -1000, -100 ], "parameters": { "content": "### How it works\nOperations teams that need a searchable document archive for an intake mailbox. Save permitted attachments, independently download and hash every new file, and remember confirmed work across runs. Completed receipts suppress repeats. An uncertain destination result stops processing and holds the lock for manual recovery.\n\n### Setup\nRequires Folderit **Tailor with API access**, a dedicated test destination and provider credentials. Install Folderit using the connection guide below. Tested on n8n **2.46.0** with **n8n-nodes-folderit 0.4.0**; Data Table v1.1 is required.\n\n1. Keep inactive. Choose a stable table name in **Ledger setup settings** and run only **1. Set up receipt table**, once at a time in the same n8n project.\n2. Connect an IMAP credential in **New email with attachments** and the SAME **Folderit OAuth2 API** credential in all three Folderit actions. Set accountUid, folderUid, mailboxScope and allowedExtensions in **Settings**. mailboxScope is a permanent label for this mailbox, not its password. After the setup branch succeeds, paste its ledgerTableId into Settings. Never put secrets in Settings. Copies processing the same input must share the table.\n3. Start with an empty test inbox: existing unseen mail can be fetched. Activate before sending two allowed test attachments and one excluded file. Check downloaded bytes and replay suppression.\n\n### Customization\nChange allowedExtensions for your intake. A stable Message-ID is required; empty files and files over 100 MiB fail. Extension filtering is not malware scanning.\n\n[Connection and first-run guide](https://www.folderit.com/knowledge-base/connect-folderit-to-n8n/) · [Recovery instructions](https://github.com/folderit-dms/n8n-nodes-folderit/blob/main/CUSTOMER-WORKFLOWS.md#recovery-inspect-before-retrying)\n\n[Workflow image](https://raw.githubusercontent.com/folderit-dms/n8n-nodes-folderit/main/templates/assets/archive-imap-attachments.png)", "width": 860, "height": 900, "color": 1 } }, { "id": "85c3e427-14c6-5686-a598-6d76a80b324a", "name": "Intake and selection", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -80, 200 ], "parameters": { "content": "## 2 · Select safe attachments\nConfigure the IMAP and Settings nodes first. Split permitted attachments and hash their bytes; read/unread status is unchanged. Message-ID and attachment identity distinguish repeats from a different email with identical content.", "width": 1080, "height": 800, "color": 7 } }, { "id": "ed1ec736-4c14-5f00-8cb6-1540ac232ad0", "name": "Serialize processing", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 1000, -80 ], "parameters": { "content": "## 3 · Process one item at a time\nA shared lock serializes overlapping runs. Confirm ownership before continuing; competing runs wait for about one minute, then stop. There is no automatic lock expiry: a failed run needs inspection.", "width": 1280, "height": 1080, "color": 7 } }, { "id": "55d579e2-3e8b-59b2-9b0e-c5e2cb2ff8d0", "name": "Check saved receipts", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 2300, -80 ], "parameters": { "content": "## 4 · Skip confirmed work\nLook up the item’s durable receipt. Completed work follows the upper bypass; a new item continues to the destination action. Pending or inconsistent receipts stop for recovery instead of repeating an uncertain action.", "width": 880, "height": 1080, "color": 7 } }, { "id": "75eb8b08-a723-5089-a17b-f3e6b59030b2", "name": "Record before writing", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 3200, 200 ], "parameters": { "content": "## 5 · Record before uploading\nSave a pending receipt before creating the Folderit file. If the request fails or times out, inspect the actual destination before retrying; the file may already exist.", "width": 900, "height": 800, "color": 7 } }, { "id": "145b25ac-114a-54fe-adfb-41c1a1581fcc", "name": "Verify the destination", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 4100, 200 ], "parameters": { "content": "## 6 · Verify the stored bytes\nRead the new document, download its stored binary and compare SHA-256 with the original attachment. Only an exact match may become a completed receipt. A successful upload status alone is insufficient.", "width": 700, "height": 800, "color": 7 } }, { "id": "25040094-632d-5ba8-8a0e-d1c6ae91ce89", "name": "Complete and release", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 4800, 200 ], "parameters": { "content": "## 7 · Save the result, then unlock\nConfirm the completed receipt before freeing the lock and taking the next item. Retain completed receipts. After a failure, confirm the old owner has stopped and reconcile every pending result before releasing its lock.", "width": 900, "height": 800, "color": 7 } }, { "id": "a6d2fdd9-e341-5f44-ac9a-e14f6b074a14", "name": "Manual receipt-table setup", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -80, 1080 ], "parameters": { "content": "## 1 · Run setup before the service trigger\nChoose a stable table name, then run this manual branch once. It validates or creates local receipts and the lock without contacting providers. Copy the final ledgerTableId into the main Settings node; preserve this table on upgrades.", "width": 2160, "height": 650, "color": 7 } } ], "connections": { "New email with attachments": { "main": [ [ { "node": "Settings", "type": "main", "index": 0 } ] ] }, "Settings": { "main": [ [ { "node": "Split and validate attachments", "type": "main", "index": 0 } ] ] }, "Split and validate attachments": { "main": [ [ { "node": "Hash attachment bytes", "type": "main", "index": 0 } ] ] }, "Hash attachment bytes": { "main": [ [ { "node": "Build attachment receipt keys", "type": "main", "index": 0 } ] ] }, "Build attachment receipt keys": { "main": [ [ { "node": "Process one item", "type": "main", "index": 0 } ] ] }, "Process one item": { "main": [ [], [ { "node": "Read processing lock", "type": "main", "index": 0 } ] ] }, "Read processing lock": { "main": [ [ { "node": "Check lock record", "type": "main", "index": 0 } ] ] }, "Check lock record": { "main": [ [ { "node": "Acquire processing lock", "type": "main", "index": 0 } ] ] }, "Acquire processing lock": { "main": [ [ { "node": "Lock acquired?", "type": "main", "index": 0 } ] ] }, "Confirm lock ownership": { "main": [ [ { "node": "Find processing receipt", "type": "main", "index": 0 } ] ] }, "Find processing receipt": { "main": [ [ { "node": "Decide whether to process", "type": "main", "index": 0 } ] ] }, "Decide whether to process": { "main": [ [ { "node": "Already completed?", "type": "main", "index": 0 } ] ] }, "Lock acquired?": { "main": [ [ { "node": "Confirm lock ownership", "type": "main", "index": 0 } ], [ { "node": "Limit lock waiting", "type": "main", "index": 0 } ] ] }, "Limit lock waiting": { "main": [ [ { "node": "Wait before checking lock", "type": "main", "index": 0 } ] ] }, "Wait before checking lock": { "main": [ [ { "node": "Read processing lock", "type": "main", "index": 0 } ] ] }, "Already completed?": { "main": [ [ { "node": "Report previous completion", "type": "main", "index": 0 } ], [ { "node": "Record pending attempt", "type": "main", "index": 0 } ] ] }, "Record pending attempt": { "main": [ [ { "node": "Restore work item", "type": "main", "index": 0 } ] ] }, "Restore work item": { "main": [ [ { "node": "Upload attachment", "type": "main", "index": 0 } ] ] }, "Upload attachment": { "main": [ [ { "node": "Read uploaded document", "type": "main", "index": 0 } ] ] }, "Read uploaded document": { "main": [ [ { "node": "Download stored attachment", "type": "main", "index": 0 } ] ] }, "Download stored attachment": { "main": [ [ { "node": "Hash stored attachment bytes", "type": "main", "index": 0 } ] ] }, "Hash stored attachment bytes": { "main": [ [ { "node": "Verify destination result", "type": "main", "index": 0 } ] ] }, "Verify destination result": { "main": [ [ { "node": "Save completed receipt", "type": "main", "index": 0 } ] ] }, "Save completed receipt": { "main": [ [ { "node": "Confirm completed receipt", "type": "main", "index": 0 } ] ] }, "Confirm completed receipt": { "main": [ [ { "node": "Release processing lock", "type": "main", "index": 0 } ] ] }, "Release processing lock": { "main": [ [ { "node": "Confirm lock released", "type": "main", "index": 0 } ] ] }, "Report previous completion": { "main": [ [ { "node": "Release processing lock", "type": "main", "index": 0 } ] ] }, "Confirm lock released": { "main": [ [ { "node": "Process one item", "type": "main", "index": 0 } ] ] }, "1. Set up receipt table": { "main": [ [ { "node": "Ledger setup settings", "type": "main", "index": 0 } ] ] }, "Ledger setup settings": { "main": [ [ { "node": "Setup: Create or reuse ledger", "type": "main", "index": 0 } ] ] }, "Setup: Create or reuse ledger": { "main": [ [ { "node": "Setup: Validate table schema", "type": "main", "index": 0 } ] ] }, "Setup: Validate table schema": { "main": [ [ { "node": "Setup: Find existing lock", "type": "main", "index": 0 } ] ] }, "Setup: Find existing lock": { "main": [ [ { "node": "Setup: Check setup state", "type": "main", "index": 0 } ] ] }, "Setup: Check setup state": { "main": [ [ { "node": "Setup: Lock already exists?", "type": "main", "index": 0 } ] ] }, "Setup: Lock already exists?": { "main": [ [ { "node": "Copy ledgerTableId into Settings", "type": "main", "index": 0 } ], [ { "node": "Setup: Create initial lock", "type": "main", "index": 0 } ] ] }, "Setup: Create initial lock": { "main": [ [ { "node": "Copy ledgerTableId into Settings", "type": "main", "index": 0 } ] ] } }, "active": false, "settings": { "executionOrder": "v1", "saveDataErrorExecution": "all", "saveDataSuccessExecution": "all", "saveExecutionProgress": true }, "pinData": {} }