imap-extract-attachments.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. /**
  2. * @node imap-extract-attachments
  3. * @name IMAP Extract Attachments
  4. * @category email
  5. * @version 1.4.0
  6. * @description Extract attachments from email as binary files. Compatible with OCR Upload and other binary-accepting nodes.
  7. * @icon paperclip
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. emailSource: {
  13. type: 'string',
  14. title: 'Email Source',
  15. description: 'Path to email data containing the raw body (e.g., {{item.body}} for loop iterations, or {{data.body}} for direct input)'
  16. },
  17. deduplicateByHash: {
  18. type: 'boolean',
  19. title: 'Deduplicate by Content',
  20. description: 'Skip storing attachments that already exist (based on content hash). Prevents duplicates when processing the same email multiple times.',
  21. default: true
  22. },
  23. filterMimeTypes: {
  24. type: 'string',
  25. title: 'Filter by MIME Types',
  26. description: 'Comma-separated list of MIME types to include (e.g. application/pdf, image/*). Leave empty for all attachments.'
  27. },
  28. filterExtensions: {
  29. type: 'string',
  30. title: 'Filter by Extensions',
  31. description: 'Comma-separated list of file extensions to include (e.g. pdf, jpg, png). Leave empty for all.'
  32. },
  33. maxSize: {
  34. type: 'integer',
  35. title: 'Max Size (bytes)',
  36. description: 'Maximum attachment size in bytes (0 = no limit)',
  37. default: 0
  38. },
  39. storeInDatabase: {
  40. type: 'boolean',
  41. title: 'Store in Database',
  42. description: 'Store extracted attachments in database for later retrieval',
  43. default: false
  44. },
  45. storageCollection: {
  46. type: 'string',
  47. title: 'Storage Collection',
  48. description: 'Select collection to store attachments',
  49. dynamicOptions: {
  50. source: 'storage.collections',
  51. labelField: 'name',
  52. valueField: 'name',
  53. filter: { access: 'read-write' }
  54. }
  55. },
  56. storageTtlHours: {
  57. type: 'number',
  58. title: 'TTL (hours)',
  59. default: 24,
  60. description: 'Auto-delete stored files after hours (0 = never)'
  61. },
  62. binaryStoragePath: {
  63. type: 'string',
  64. title: 'Binary Storage Path',
  65. default: './data/attachments',
  66. description: 'Base directory for storing binary files on disk'
  67. }
  68. }
  69. };
  70. const inputSchema = {
  71. type: 'object',
  72. properties: {
  73. raw: {
  74. type: 'string',
  75. description: 'Raw email content (from IMAP Fetch or IMAP Trigger with fetchContent enabled)'
  76. },
  77. email: {
  78. type: 'object',
  79. description: 'Email object containing raw property'
  80. }
  81. }
  82. };
  83. const outputSchema = {
  84. type: 'object',
  85. properties: {
  86. attachments: {
  87. type: 'array',
  88. description: 'Array of extracted attachments as binary file objects',
  89. items: {
  90. type: 'object',
  91. properties: {
  92. type: { type: 'string', const: 'binary' },
  93. data: { type: 'string', description: 'Base64-encoded content' },
  94. mimeType: { type: 'string' },
  95. filename: { type: 'string' },
  96. size: { type: 'number' },
  97. contentId: { type: 'string', description: 'Content-ID for inline attachments' },
  98. storage: {
  99. type: 'object',
  100. description: 'Storage reference (if storeInDatabase enabled)',
  101. properties: {
  102. collection: { type: 'string' },
  103. id: { type: 'string' }
  104. }
  105. }
  106. }
  107. }
  108. },
  109. count: { type: 'integer', description: 'Number of attachments extracted' },
  110. totalSize: { type: 'integer', description: 'Total size of all attachments in bytes' }
  111. }
  112. };
  113. const outputs = [
  114. { name: 'main', displayName: 'Attachments', type: 'object', color: '#f59e0b' }
  115. ];
  116. /**
  117. * Parse MIME multipart content and extract attachments
  118. */
  119. function parseMimeContent(rawEmail) {
  120. const attachments = [];
  121. // Find boundary from Content-Type header
  122. const boundaryMatch = rawEmail.match(/boundary=["']?([^"'\s;]+)["']?/i);
  123. if (!boundaryMatch) {
  124. // Not a multipart email, no attachments
  125. return attachments;
  126. }
  127. const boundary = boundaryMatch[1];
  128. const parts = rawEmail.split('--' + boundary);
  129. for (let i = 1; i < parts.length; i++) {
  130. const part = parts[i];
  131. // Skip the closing boundary marker
  132. if (part.trim() === '--' || part.trim().startsWith('--')) {
  133. continue;
  134. }
  135. // Parse headers and body from this part
  136. const headerEndIndex = part.indexOf('\r\n\r\n');
  137. const headerEndIndex2 = part.indexOf('\n\n');
  138. const actualHeaderEnd = headerEndIndex !== -1 ? headerEndIndex : headerEndIndex2;
  139. if (actualHeaderEnd === -1) continue;
  140. const headers = part.substring(0, actualHeaderEnd);
  141. const body = part.substring(actualHeaderEnd + (headerEndIndex !== -1 ? 4 : 2));
  142. // Check if this is an attachment
  143. const contentDisposition = headers.match(/Content-Disposition:\s*([^\r\n]+)/i);
  144. const contentType = headers.match(/Content-Type:\s*([^\r\n;]+)/i);
  145. const contentTransferEncoding = headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i);
  146. const contentId = headers.match(/Content-ID:\s*<?([^>\r\n]+)>?/i);
  147. // Extract filename from Content-Disposition or Content-Type
  148. let filename = null;
  149. const filenameMatch = headers.match(/filename=["']?([^"'\r\n;]+)["']?/i);
  150. const filenameStarMatch = headers.match(/filename\*=(?:UTF-8''|utf-8'')([^\r\n;]+)/i);
  151. const nameMatch = headers.match(/name=["']?([^"'\r\n;]+)["']?/i);
  152. if (filenameStarMatch) {
  153. try {
  154. filename = decodeURIComponent(filenameStarMatch[1]);
  155. } catch (e) {
  156. filename = filenameStarMatch[1];
  157. }
  158. } else if (filenameMatch) {
  159. filename = filenameMatch[1];
  160. } else if (nameMatch) {
  161. filename = nameMatch[1];
  162. }
  163. // Determine if this is an attachment (has filename or Content-Disposition: attachment)
  164. const isAttachment = filename ||
  165. (contentDisposition && contentDisposition[1].toLowerCase().includes('attachment'));
  166. // Skip if not an attachment and no filename
  167. if (!isAttachment) continue;
  168. // Get MIME type
  169. const mimeType = contentType ? contentType[1].trim() : 'application/octet-stream';
  170. // Skip text/plain and text/html parts (these are usually the email body)
  171. if (mimeType.startsWith('text/plain') || mimeType.startsWith('text/html')) {
  172. if (!filename) continue; // Only skip if no filename (body part)
  173. }
  174. // Decode content based on transfer encoding
  175. let data = body.trim();
  176. const encoding = contentTransferEncoding ? contentTransferEncoding[1].trim().toLowerCase() : '';
  177. if (encoding === 'base64') {
  178. // Already base64 encoded, just clean it up
  179. data = data.replace(/[\r\n\s]/g, '');
  180. } else if (encoding === 'quoted-printable') {
  181. // Decode quoted-printable and then base64 encode
  182. const decoded = decodeQuotedPrintable(data);
  183. data = smartbotic.utils.base64Encode(decoded);
  184. } else {
  185. // Raw or 7bit/8bit - base64 encode it
  186. data = smartbotic.utils.base64Encode(data);
  187. }
  188. // Generate filename if not present
  189. if (!filename) {
  190. const ext = getExtensionForMimeType(mimeType);
  191. filename = 'attachment_' + (attachments.length + 1) + ext;
  192. }
  193. // Calculate size (approximate from base64)
  194. const size = Math.floor(data.length * 3 / 4);
  195. attachments.push({
  196. type: 'binary',
  197. data: data,
  198. mimeType: mimeType,
  199. filename: filename,
  200. size: size,
  201. contentId: contentId ? contentId[1] : null
  202. });
  203. }
  204. return attachments;
  205. }
  206. /**
  207. * Decode quoted-printable encoding
  208. */
  209. function decodeQuotedPrintable(str) {
  210. return str
  211. .replace(/=\r?\n/g, '') // Remove soft line breaks
  212. .replace(/=([0-9A-Fa-f]{2})/g, (match, hex) => {
  213. return String.fromCharCode(parseInt(hex, 16));
  214. });
  215. }
  216. /**
  217. * Get file extension for common MIME types
  218. */
  219. function getExtensionForMimeType(mimeType) {
  220. const mimeMap = {
  221. 'application/pdf': '.pdf',
  222. 'application/msword': '.doc',
  223. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
  224. 'application/vnd.ms-excel': '.xls',
  225. 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
  226. 'application/vnd.ms-powerpoint': '.ppt',
  227. 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
  228. 'application/zip': '.zip',
  229. 'application/x-rar-compressed': '.rar',
  230. 'application/x-7z-compressed': '.7z',
  231. 'application/gzip': '.gz',
  232. 'image/jpeg': '.jpg',
  233. 'image/png': '.png',
  234. 'image/gif': '.gif',
  235. 'image/bmp': '.bmp',
  236. 'image/webp': '.webp',
  237. 'image/tiff': '.tiff',
  238. 'text/plain': '.txt',
  239. 'text/csv': '.csv',
  240. 'text/html': '.html',
  241. 'application/json': '.json',
  242. 'application/xml': '.xml'
  243. };
  244. return mimeMap[mimeType.toLowerCase()] || '.bin';
  245. }
  246. /**
  247. * Check if MIME type matches filter pattern (supports wildcards like "image/*")
  248. */
  249. function matchesMimeType(mimeType, patterns) {
  250. if (!patterns || patterns.length === 0) return true;
  251. const lowerMime = mimeType.toLowerCase();
  252. for (const pattern of patterns) {
  253. const lowerPattern = pattern.trim().toLowerCase();
  254. if (lowerPattern.endsWith('/*')) {
  255. const prefix = lowerPattern.slice(0, -1);
  256. if (lowerMime.startsWith(prefix)) return true;
  257. } else if (lowerMime === lowerPattern) {
  258. return true;
  259. }
  260. }
  261. return false;
  262. }
  263. /**
  264. * Check if filename matches extension filter
  265. */
  266. function matchesExtension(filename, extensions) {
  267. if (!extensions || extensions.length === 0) return true;
  268. const lowerFilename = filename.toLowerCase();
  269. for (const ext of extensions) {
  270. const lowerExt = ext.trim().toLowerCase();
  271. const dotExt = lowerExt.startsWith('.') ? lowerExt : '.' + lowerExt;
  272. if (lowerFilename.endsWith(dotExt)) return true;
  273. }
  274. return false;
  275. }
  276. module.exports = {
  277. configSchema,
  278. inputSchema,
  279. outputSchema,
  280. outputs,
  281. async execute(config, input, context) {
  282. // Get raw email content from input
  283. let rawEmail = null;
  284. // First priority: use configured emailSource if provided
  285. // The expression (e.g., {{item.body}}) is evaluated by the workflow engine,
  286. // so config.emailSource contains the actual email body string
  287. if (config.emailSource && typeof config.emailSource === 'string' && config.emailSource.length > 0) {
  288. rawEmail = config.emailSource;
  289. smartbotic.log.info('IMAP Extract Attachments: Using configured email source');
  290. }
  291. // Fallback: try to auto-detect from various input structures
  292. if (!rawEmail) {
  293. rawEmail = input.raw || input.body;
  294. if (!rawEmail && input.email) {
  295. rawEmail = input.email.raw || input.email.body;
  296. }
  297. if (!rawEmail && input.data) {
  298. rawEmail = input.data.raw || input.data.body;
  299. }
  300. // Support loop iteration input (item/currentItem from Loop node)
  301. if (!rawEmail && input.item) {
  302. rawEmail = input.item.raw || input.item.body;
  303. }
  304. if (!rawEmail && input.currentItem) {
  305. rawEmail = input.currentItem.raw || input.currentItem.body;
  306. }
  307. }
  308. if (!rawEmail) {
  309. // Log available keys for debugging
  310. const availableKeys = Object.keys(input || {}).join(', ');
  311. smartbotic.log.warn('IMAP Extract Attachments: No raw email found. Input keys: ' + availableKeys);
  312. throw new Error('No raw email content provided. Configure the "Email Source" field with an expression like {{item.body}} for loop iterations, or ensure the email data is properly connected.');
  313. }
  314. // Parse MIME content
  315. let attachments = parseMimeContent(rawEmail);
  316. // Apply filters
  317. const mimeFilters = config.filterMimeTypes
  318. ? config.filterMimeTypes.split(',').map(s => s.trim()).filter(s => s)
  319. : [];
  320. const extFilters = config.filterExtensions
  321. ? config.filterExtensions.split(',').map(s => s.trim()).filter(s => s)
  322. : [];
  323. const maxSize = config.maxSize || 0;
  324. attachments = attachments.filter(att => {
  325. // Filter by MIME type
  326. if (!matchesMimeType(att.mimeType, mimeFilters)) return false;
  327. // Filter by extension
  328. if (!matchesExtension(att.filename, extFilters)) return false;
  329. // Filter by size
  330. if (maxSize > 0 && att.size > maxSize) return false;
  331. return true;
  332. });
  333. // Store attachments - always write to disk, optionally store metadata in database
  334. const storagePath = config.binaryStoragePath || './data/attachments';
  335. const timestamp = Date.now();
  336. const deduplicateByHash = config.deduplicateByHash !== false; // Default to true
  337. for (const att of attachments) {
  338. // Generate file path - use content hash for deduplication if enabled
  339. const date = new Date(timestamp);
  340. const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
  341. const safeFilename = att.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
  342. let filePath;
  343. let skipWrite = false;
  344. if (deduplicateByHash) {
  345. // Calculate content hash for deduplication
  346. const contentHash = smartbotic.utils.hash(att.data);
  347. att.contentHash = contentHash;
  348. // Use hash-based path: basePath/YYYY-MM/hash_filename
  349. // Using first 16 chars of SHA-256 hash (64 bits) for reasonable uniqueness
  350. const shortHash = contentHash.substring(0, 16);
  351. filePath = `${storagePath}/${yearMonth}/${shortHash}_${safeFilename}`;
  352. // Check if file already exists
  353. if (smartbotic.fs.exists(filePath)) {
  354. smartbotic.log.info(`Attachment ${att.filename} already exists (hash: ${shortHash}), reusing: ${filePath}`);
  355. att.filePath = filePath;
  356. att.deduplicated = true;
  357. delete att.data; // Remove base64 data from memory
  358. skipWrite = true;
  359. }
  360. } else {
  361. // Use UUID-based path for unique storage
  362. const uuid = smartbotic.utils.uuid();
  363. filePath = `${storagePath}/${yearMonth}/${uuid}_${safeFilename}`;
  364. }
  365. if (!skipWrite) {
  366. // Write binary data to disk
  367. const writeResult = smartbotic.fs.writeFile(filePath, att.data, 'base64');
  368. if (writeResult.success) {
  369. // Replace base64 data with file path
  370. att.filePath = filePath;
  371. att.deduplicated = false;
  372. delete att.data; // Remove base64 data from memory
  373. smartbotic.log.info(`Stored attachment ${att.filename} to disk: ${filePath}`);
  374. } else {
  375. smartbotic.log.warn(`Failed to write attachment to disk: ${writeResult.error}`);
  376. // Keep base64 data as fallback
  377. }
  378. }
  379. }
  380. // Store metadata in database if configured (without binary data)
  381. if (config.storeInDatabase && attachments.length > 0) {
  382. const collection = config.storageCollection || 'email_attachments';
  383. const ttlHours = config.storageTtlHours || 0;
  384. const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
  385. for (const att of attachments) {
  386. // Store only metadata, not the binary data
  387. const storageDoc = {
  388. filename: att.filename,
  389. mimeType: att.mimeType,
  390. size: att.size,
  391. contentId: att.contentId,
  392. contentHash: att.contentHash, // For deduplication lookup
  393. deduplicated: att.deduplicated,
  394. extractedAt: timestamp,
  395. filePath: att.filePath // Reference to disk location
  396. };
  397. const insertResult = smartbotic.storage.insert(collection, storageDoc, null, ttlMs);
  398. if (insertResult.success) {
  399. att.storage = {
  400. collection: collection,
  401. id: insertResult.id
  402. };
  403. smartbotic.log.info(`Stored attachment metadata ${att.filename} in ${collection}/${insertResult.id}`);
  404. }
  405. }
  406. }
  407. // Calculate total size
  408. const totalSize = attachments.reduce((sum, att) => sum + att.size, 0);
  409. smartbotic.log.info(`Extracted ${attachments.length} attachment(s), total size: ${totalSize} bytes`);
  410. return {
  411. attachments,
  412. count: attachments.length,
  413. totalSize
  414. };
  415. }
  416. };