From 72814d1f0fb5dfce19a89397a6a1cd5829563222 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Mon, 7 Aug 2023 13:33:06 +0300 Subject: [PATCH] fix(Email Trigger (IMAP) Node): UTF-8 attachments are not correctly named (#6856) --- .../EmailReadImap/v2/EmailReadImapV2.node.ts | 111 +++++++++++------- packages/nodes-base/package.json | 2 + pnpm-lock.yaml | 52 +++++--- 3 files changed, 107 insertions(+), 58 deletions(-) diff --git a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts index fc0bed1701..4ba43f92a7 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts @@ -12,6 +12,7 @@ import type { INodeTypeBaseDescription, INodeTypeDescription, ITriggerResponse, + JsonObject, } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; @@ -19,9 +20,10 @@ import type { ImapSimple, ImapSimpleOptions, Message } from 'imap-simple'; import { connect as imapConnect, getParts } from 'imap-simple'; import type { Source as ParserSource } from 'mailparser'; import { simpleParser } from 'mailparser'; - +import rfc2047 from 'rfc2047'; import isEmpty from 'lodash/isEmpty'; import find from 'lodash/find'; + import type { ICredentialsDataImap } from '../../../credentials/Imap.credentials'; import { isCredentialsDataImap } from '../../../credentials/Imap.credentials'; @@ -32,14 +34,14 @@ export async function parseRawEmail( ): Promise { const responseData = await simpleParser(messageEncoded); const headers: IDataObject = {}; + const addidtionalData: IDataObject = {}; + for (const header of responseData.headerLines) { headers[header.key] = header.line; } - // @ts-ignore - responseData.headers = headers; - // @ts-ignore - responseData.headerLines = undefined; + addidtionalData.headers = headers; + addidtionalData.headerLines = undefined; const binaryData: IBinaryKeyData = {}; if (responseData.attachments) { @@ -51,12 +53,12 @@ export async function parseRawEmail( attachment.contentType, ); } - // @ts-ignore - responseData.attachments = undefined; + + addidtionalData.attachments = undefined; } return { - json: responseData as unknown as IDataObject, + json: { ...responseData, ...addidtionalData }, binary: Object.keys(binaryData).length ? binaryData : undefined, } as INodeExecutionData; } @@ -260,7 +262,7 @@ export class EmailReadImapV2 implements INodeType { } catch (error) { return { status: 'Error', - message: error.message, + message: (error as Error).message, }; } return { @@ -296,14 +298,15 @@ export class EmailReadImapV2 implements INodeType { // Returns the email text - const getText = async (parts: any[], message: Message, subtype: string) => { + const getText = async (parts: IDataObject[], message: Message, subtype: string) => { if (!message.attributes.struct) { return ''; } const textParts = parts.filter((part) => { return ( - part.type.toUpperCase() === 'TEXT' && part.subtype.toUpperCase() === subtype.toUpperCase() + (part.type as string).toUpperCase() === 'TEXT' && + (part.subtype as string).toUpperCase() === subtype.toUpperCase() ); }); @@ -312,7 +315,7 @@ export class EmailReadImapV2 implements INodeType { } try { - return await connection.getPartData(message, textParts[0]); + return (await connection.getPartData(message, textParts[0])) as string; } catch { return ''; } @@ -321,7 +324,7 @@ export class EmailReadImapV2 implements INodeType { // Returns the email attachments const getAttachment = async ( imapConnection: ImapSimple, - parts: any[], + parts: IDataObject[], message: Message, ): Promise => { if (!message.attributes.struct) { @@ -330,20 +333,33 @@ export class EmailReadImapV2 implements INodeType { // Check if the message has attachments and if so get them const attachmentParts = parts.filter((part) => { - return part.disposition && part.disposition.type.toUpperCase() === 'ATTACHMENT'; + return ( + part.disposition && + ((part.disposition as IDataObject)?.type as string).toUpperCase() === 'ATTACHMENT' + ); }); + const decodeFilename = (filename: string) => { + const regex = /=\?([\w-]+)\?Q\?.*\?=/i; + if (regex.test(filename)) { + return rfc2047.decode(filename); + } + return filename; + }; + const attachmentPromises = []; let attachmentPromise; for (const attachmentPart of attachmentParts) { attachmentPromise = imapConnection .getPartData(message, attachmentPart) .then(async (partData) => { - // Return it in the format n8n expects - return this.helpers.prepareBinaryData( - partData as Buffer, - attachmentPart.disposition.params.filename as string, + // if filename contains utf-8 encoded characters, decode it + const fileName = decodeFilename( + ((attachmentPart.disposition as IDataObject)?.params as IDataObject) + ?.filename as string, ); + // Return it in the format n8n expects + return this.helpers.prepareBinaryData(partData as Buffer, fileName); }); attachmentPromises.push(attachmentPromise); @@ -440,7 +456,7 @@ export class EmailReadImapV2 implements INodeType { ) { staticData.lastMessageUid = message.attributes.uid; } - const parts = getParts(message.attributes.struct!); + const parts = getParts(message.attributes.struct as IDataObject[]) as IDataObject[]; newEmail = { json: { @@ -454,14 +470,15 @@ export class EmailReadImapV2 implements INodeType { return part.which === 'HEADER'; }); - messageBody = messageHeader[0].body; - for (propertyName of Object.keys(messageBody as IDataObject)) { - if (messageBody[propertyName].length) { + messageBody = messageHeader[0].body as IDataObject; + for (propertyName of Object.keys(messageBody)) { + if ((messageBody[propertyName] as IDataObject[]).length) { if (topLevelProperties.includes(propertyName)) { - newEmail.json[propertyName] = messageBody[propertyName][0]; + newEmail.json[propertyName] = (messageBody[propertyName] as string[])[0]; } else { - (newEmail.json.metadata as IDataObject)[propertyName] = - messageBody[propertyName][0]; + (newEmail.json.metadata as IDataObject)[propertyName] = ( + messageBody[propertyName] as string[] + )[0]; } } } @@ -501,7 +518,7 @@ export class EmailReadImapV2 implements INodeType { // Return base64 string newEmail = { json: { - raw: part.body, + raw: part.body as string, }, }; @@ -525,7 +542,9 @@ export class EmailReadImapV2 implements INodeType { let searchCriteria = ['UNSEEN'] as Array; if (options.customEmailConfig !== undefined) { try { - searchCriteria = JSON.parse(options.customEmailConfig as string); + searchCriteria = JSON.parse(options.customEmailConfig as string) as Array< + string | string[] + >; } catch (error) { throw new NodeOperationError(this.getNode(), 'Custom email config is not valid JSON.'); } @@ -568,7 +587,7 @@ export class EmailReadImapV2 implements INodeType { } } catch (error) { this.logger.error('Email Read Imap node encountered an error fetching new emails', { - error, + error: error as Error, }); // Wait with resolving till the returnedPromise got resolved, else n8n will be unhappy // if it receives an error before the workflow got activated @@ -611,8 +630,10 @@ export class EmailReadImapV2 implements INodeType { } }); conn.on('error', async (error) => { - const errorCode = error.code.toUpperCase(); - this.logger.verbose(`IMAP connection experienced an error: (${errorCode})`, { error }); + const errorCode = ((error as JsonObject).code as string).toUpperCase(); + this.logger.verbose(`IMAP connection experienced an error: (${errorCode})`, { + error: error as Error, + }); // eslint-disable-next-line @typescript-eslint/no-use-before-define await closeFunction(); this.emitError(error as Error); @@ -627,22 +648,24 @@ export class EmailReadImapV2 implements INodeType { let reconnectionInterval: NodeJS.Timeout | undefined; + const handleReconect = async () => { + this.logger.verbose('Forcing reconnect to IMAP server'); + try { + isCurrentlyReconnecting = true; + if (connection.closeBox) await connection.closeBox(false); + connection.end(); + connection = await establishConnection(); + await connection.openBox(mailbox); + } catch (error) { + this.logger.error(error as string); + } finally { + isCurrentlyReconnecting = false; + } + }; + if (options.forceReconnect !== undefined) { reconnectionInterval = setInterval( - async () => { - this.logger.verbose('Forcing reconnect to IMAP server'); - try { - isCurrentlyReconnecting = true; - if (connection.closeBox) await connection.closeBox(false); - connection.end(); - connection = await establishConnection(); - await connection.openBox(mailbox); - } catch (error) { - this.logger.error(error as string); - } finally { - isCurrentlyReconnecting = false; - } - }, + handleReconect, (options.forceReconnect as number) * 1000 * 60, ); } diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 698519fc95..7442d5cc63 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -796,6 +796,7 @@ "@types/promise-ftp": "^1.3.4", "@types/redis": "^2.8.11", "@types/request-promise-native": "~1.0.15", + "@types/rfc2047": "^2.0.1", "@types/showdown": "^1.9.4", "@types/snowflake-sdk": "^1.6.12", "@types/ssh2-sftp-client": "^5.1.0", @@ -854,6 +855,7 @@ "promise-ftp": "^1.3.5", "pyodide": "^0.23.4", "redis": "^3.1.1", + "rfc2047": "^4.0.1", "rhea": "^1.0.11", "rss-parser": "^3.7.0", "semver": "^7.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1e4db2fa4..991d693457 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,7 +135,7 @@ importers: dependencies: axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) packages/@n8n_io/eslint-config: devDependencies: @@ -216,7 +216,7 @@ importers: version: 7.28.1 axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) basic-auth: specifier: ^2.0.1 version: 2.0.1 @@ -574,7 +574,7 @@ importers: version: link:../@n8n/client-oauth2 axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) concat-stream: specifier: ^2.0.0 version: 2.0.0 @@ -834,7 +834,7 @@ importers: version: 10.2.0(vue@3.3.4) axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) codemirror-lang-html-n8n: specifier: ^1.0.0 version: 1.0.0 @@ -1134,6 +1134,9 @@ importers: redis: specifier: ^3.1.1 version: 3.1.2 + rfc2047: + specifier: ^4.0.1 + version: 4.0.1 rhea: specifier: ^1.0.11 version: 1.0.24 @@ -1234,6 +1237,9 @@ importers: '@types/request-promise-native': specifier: ~1.0.15 version: 1.0.18 + '@types/rfc2047': + specifier: ^2.0.1 + version: 2.0.1 '@types/showdown': specifier: ^1.9.4 version: 1.9.4 @@ -4448,7 +4454,7 @@ packages: dependencies: '@segment/loosely-validate-event': 2.0.0 auto-changelog: 1.16.4 - axios: 0.21.4 + axios: 0.21.4(debug@4.3.2) axios-retry: 3.3.1 bull: 3.29.3 lodash.clonedeep: 4.5.0 @@ -6484,6 +6490,10 @@ packages: form-data: 2.5.1 dev: true + /@types/rfc2047@2.0.1: + resolution: {integrity: sha512-slgtykv+XXME7EperkdqfdBBUGcs28ru+a21BK0zOQY4IoxE7tEqvIcvAFAz5DJVxyOmoAUXo30Oxpm3KS+TBQ==} + dev: true + /@types/sanitize-html@2.9.0: resolution: {integrity: sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==} dependencies: @@ -8074,14 +8084,6 @@ packages: is-retry-allowed: 2.2.0 dev: false - /axios@0.21.4: - resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} - dependencies: - follow-redirects: 1.15.2(debug@4.3.4) - transitivePeerDependencies: - - debug - dev: false - /axios@0.21.4(debug@4.3.2): resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: @@ -8090,6 +8092,15 @@ packages: - debug dev: false + /axios@0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + dependencies: + follow-redirects: 1.15.2(debug@4.3.2) + form-data: 4.0.0 + transitivePeerDependencies: + - debug + dev: false + /axios@0.27.2(debug@3.2.7): resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} dependencies: @@ -8106,6 +8117,7 @@ packages: form-data: 4.0.0 transitivePeerDependencies: - debug + dev: true /babel-core@7.0.0-bridge.0(@babel/core@7.22.9): resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==} @@ -11686,6 +11698,7 @@ packages: optional: true dependencies: debug: 4.3.4(supports-color@8.1.1) + dev: true /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -12683,6 +12696,11 @@ packages: dependencies: safer-buffer: 2.1.2 + /iconv-lite@0.4.5: + resolution: {integrity: sha512-LQ4GtDkFagYaac8u4rE73zWu7h0OUUmR0qVBOgzLyFSoJhoDG2xV9PZJWWyVVcYha/9/RZzQHUinFMbNKiOoAA==} + engines: {node: '>=0.8.0'} + dev: false + /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -17040,7 +17058,7 @@ packages: resolution: {integrity: sha512-aXYe/D+28kF63W8Cz53t09ypEORz+ULeDCahdAqhVrRm2scbOXFbtnn0GGhvMpYe45grepLKuwui9KxrZ2ZuMw==} engines: {node: '>=14.17.0'} dependencies: - axios: 0.27.2(debug@4.3.4) + axios: 0.27.2 transitivePeerDependencies: - debug dev: false @@ -18075,6 +18093,12 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + /rfc2047@4.0.1: + resolution: {integrity: sha512-x5zHBAZtSSZDuBNAqGEAVpsQFV+YUluIkMWVaYRMEeGoLPxNVMmg67TxRnXwmRmCB7QaneyrkWXeKqbjfcK6RA==} + dependencies: + iconv-lite: 0.4.5 + dev: false + /rfdc@1.3.0: resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==}