From 68a6c8172973091e8474a9f173fa4a5e97284f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 15 May 2024 15:50:53 +0200 Subject: [PATCH] fix(Email Trigger (IMAP) Node): Handle attachments correctly (#9410) --- package.json | 3 +- packages/@n8n/imap/package.json | 2 +- packages/@n8n/imap/src/ImapSimple.ts | 44 +--------- packages/@n8n/imap/src/PartData.ts | 84 ++++++++++++++++++ packages/@n8n/imap/test/PartData.test.ts | 88 +++++++++++++++++++ .../EmailReadImap/v1/EmailReadImapV1.node.ts | 10 ++- .../EmailReadImap/v2/EmailReadImapV2.node.ts | 14 ++- patches/@types__uuencode@0.0.3.patch | 10 +++ pnpm-lock.yaml | 14 +-- 9 files changed, 213 insertions(+), 56 deletions(-) create mode 100644 packages/@n8n/imap/src/PartData.ts create mode 100644 packages/@n8n/imap/test/PartData.test.ts create mode 100644 patches/@types__uuencode@0.0.3.patch diff --git a/package.json b/package.json index 7e5d9381cd..db1f0d70a9 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,8 @@ "pyodide@0.23.4": "patches/pyodide@0.23.4.patch", "@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch", "@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch", - "vite-plugin-checker@0.6.4": "patches/vite-plugin-checker@0.6.4.patch" + "vite-plugin-checker@0.6.4": "patches/vite-plugin-checker@0.6.4.patch", + "@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch" } } } diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index 3773c5f3a1..66e1fcd491 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -10,7 +10,7 @@ "lint": "eslint . --quiet", "lintfix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", - "test": "echo \"Error: no test created yet\"" + "test": "jest" }, "main": "dist/index.js", "module": "src/index.ts", diff --git a/packages/@n8n/imap/src/ImapSimple.ts b/packages/@n8n/imap/src/ImapSimple.ts index c495e8d1f4..eb6dc07722 100644 --- a/packages/@n8n/imap/src/ImapSimple.ts +++ b/packages/@n8n/imap/src/ImapSimple.ts @@ -2,13 +2,10 @@ import { EventEmitter } from 'events'; import type Imap from 'imap'; import { type ImapMessage } from 'imap'; -import * as qp from 'quoted-printable'; -import * as iconvlite from 'iconv-lite'; -import * as utf8 from 'utf8'; -import * as uuencode from 'uuencode'; import { getMessage } from './helpers/getMessage'; import type { Message, MessagePart } from './types'; +import { PartData } from './PartData'; const IMAP_EVENTS = ['alert', 'mail', 'expunge', 'uidvalidity', 'update', 'close', 'end'] as const; @@ -124,7 +121,7 @@ export class ImapSimple extends EventEmitter { /** The message part to be downloaded, from the `message.attributes.struct` Array */ part: MessagePart, ) { - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const fetch = this.imap.fetch(message.attributes.uid, { bodies: [part.partID], struct: true, @@ -138,43 +135,8 @@ export class ImapSimple extends EventEmitter { } const data = result.parts[0].body as string; - const encoding = part.encoding.toUpperCase(); - - if (encoding === 'BASE64') { - resolve(Buffer.from(data, 'base64').toString()); - return; - } - - if (encoding === 'QUOTED-PRINTABLE') { - if (part.params?.charset?.toUpperCase() === 'UTF-8') { - resolve(Buffer.from(utf8.decode(qp.decode(data))).toString()); - } else { - resolve(Buffer.from(qp.decode(data)).toString()); - } - return; - } - - if (encoding === '7BIT') { - resolve(Buffer.from(data).toString('ascii')); - return; - } - - if (encoding === '8BIT' || encoding === 'BINARY') { - const charset = part.params?.charset ?? 'utf-8'; - resolve(iconvlite.decode(Buffer.from(data), charset)); - return; - } - - if (encoding === 'UUENCODE') { - const parts = data.toString().split('\n'); // remove newline characters - const merged = parts.splice(1, parts.length - 4).join(''); // remove excess lines and join lines with empty string - resolve(uuencode.decode(merged)); - return; - } - - // if it gets here, the encoding is not currently supported - reject(new Error('Unknown encoding ' + part.encoding)); + resolve(PartData.fromData(data, encoding)); }; const fetchOnError = (error: Error) => { diff --git a/packages/@n8n/imap/src/PartData.ts b/packages/@n8n/imap/src/PartData.ts new file mode 100644 index 0000000000..d4ad353a97 --- /dev/null +++ b/packages/@n8n/imap/src/PartData.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import * as qp from 'quoted-printable'; +import * as iconvlite from 'iconv-lite'; +import * as utf8 from 'utf8'; +import * as uuencode from 'uuencode'; + +export abstract class PartData { + constructor(readonly buffer: Buffer) {} + + toString() { + return this.buffer.toString(); + } + + static fromData(data: string, encoding: string, charset?: string): PartData { + if (encoding === 'BASE64') { + return new Base64PartData(data); + } + + if (encoding === 'QUOTED-PRINTABLE') { + return new QuotedPrintablePartData(data, charset); + } + + if (encoding === '7BIT') { + return new SevenBitPartData(data); + } + + if (encoding === '8BIT' || encoding === 'BINARY') { + return new BinaryPartData(data, charset); + } + + if (encoding === 'UUENCODE') { + return new UuencodedPartData(data); + } + + // if it gets here, the encoding is not currently supported + throw new Error('Unknown encoding ' + encoding); + } +} + +export class Base64PartData extends PartData { + constructor(data: string) { + super(Buffer.from(data, 'base64')); + } +} + +export class QuotedPrintablePartData extends PartData { + constructor(data: string, charset?: string) { + const decoded = + charset?.toUpperCase() === 'UTF-8' ? utf8.decode(qp.decode(data)) : qp.decode(data); + super(Buffer.from(decoded)); + } +} + +export class SevenBitPartData extends PartData { + constructor(data: string) { + super(Buffer.from(data)); + } + + toString() { + return this.buffer.toString('ascii'); + } +} + +export class BinaryPartData extends PartData { + constructor( + data: string, + readonly charset: string = 'utf-8', + ) { + super(Buffer.from(data)); + } + + toString() { + return iconvlite.decode(this.buffer, this.charset); + } +} + +export class UuencodedPartData extends PartData { + constructor(data: string) { + const parts = data.split('\n'); // remove newline characters + const merged = parts.splice(1, parts.length - 4).join(''); // remove excess lines and join lines with empty string + const decoded = uuencode.decode(merged); + super(decoded); + } +} diff --git a/packages/@n8n/imap/test/PartData.test.ts b/packages/@n8n/imap/test/PartData.test.ts new file mode 100644 index 0000000000..67d81718f3 --- /dev/null +++ b/packages/@n8n/imap/test/PartData.test.ts @@ -0,0 +1,88 @@ +import { + PartData, + Base64PartData, + QuotedPrintablePartData, + SevenBitPartData, + BinaryPartData, + UuencodedPartData, +} from '../src/PartData'; + +describe('PartData', () => { + describe('fromData', () => { + it('should return an instance of Base64PartData when encoding is BASE64', () => { + const result = PartData.fromData('data', 'BASE64'); + expect(result).toBeInstanceOf(Base64PartData); + }); + + it('should return an instance of QuotedPrintablePartData when encoding is QUOTED-PRINTABLE', () => { + const result = PartData.fromData('data', 'QUOTED-PRINTABLE'); + expect(result).toBeInstanceOf(QuotedPrintablePartData); + }); + + it('should return an instance of SevenBitPartData when encoding is 7BIT', () => { + const result = PartData.fromData('data', '7BIT'); + expect(result).toBeInstanceOf(SevenBitPartData); + }); + + it('should return an instance of BinaryPartData when encoding is 8BIT or BINARY', () => { + let result = PartData.fromData('data', '8BIT'); + expect(result).toBeInstanceOf(BinaryPartData); + result = PartData.fromData('data', 'BINARY'); + expect(result).toBeInstanceOf(BinaryPartData); + }); + + it('should return an instance of UuencodedPartData when encoding is UUENCODE', () => { + const result = PartData.fromData('data', 'UUENCODE'); + expect(result).toBeInstanceOf(UuencodedPartData); + }); + + it('should throw an error when encoding is not supported', () => { + expect(() => PartData.fromData('data', 'UNSUPPORTED')).toThrow( + 'Unknown encoding UNSUPPORTED', + ); + }); + }); +}); + +describe('Base64PartData', () => { + it('should correctly decode base64 data', () => { + const data = Buffer.from('Hello, world!', 'utf-8').toString('base64'); + const partData = new Base64PartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); + +describe('QuotedPrintablePartData', () => { + it('should correctly decode quoted-printable data', () => { + const data = '=48=65=6C=6C=6F=2C=20=77=6F=72=6C=64=21'; // 'Hello, world!' in quoted-printable + const partData = new QuotedPrintablePartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); + +describe('SevenBitPartData', () => { + it('should correctly decode 7bit data', () => { + const data = 'Hello, world!'; + const partData = new SevenBitPartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); + +describe('BinaryPartData', () => { + it('should correctly decode binary data', () => { + const data = Buffer.from('Hello, world!', 'utf-8').toString(); + const partData = new BinaryPartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); + +describe('UuencodedPartData', () => { + it('should correctly decode uuencoded data', () => { + const data = Buffer.from( + 'YmVnaW4gNjQ0IGRhdGEKLTImNUw7JlxMKCc9TzxGUUQoMGBgCmAKZW5kCg==', + 'base64', + ).toString('binary'); + const partData = new UuencodedPartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); diff --git a/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts b/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts index 8df78cce8c..a7e321d1fd 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts @@ -285,7 +285,7 @@ export class EmailReadImapV1 implements INodeType { // Returns the email text - const getText = async (parts: any[], message: Message, subtype: string) => { + const getText = async (parts: any[], message: Message, subtype: string): Promise => { if (!message.attributes.struct) { return ''; } @@ -296,12 +296,14 @@ export class EmailReadImapV1 implements INodeType { ); }); - if (textParts.length === 0) { + const part = textParts[0]; + if (!part) { return ''; } try { - return await connection.getPartData(message, textParts[0]); + const partData = await connection.getPartData(message, part); + return partData.toString(); } catch { return ''; } @@ -330,7 +332,7 @@ export class EmailReadImapV1 implements INodeType { .then(async (partData) => { // Return it in the format n8n expects return await this.helpers.prepareBinaryData( - Buffer.from(partData), + partData.buffer, attachmentPart.disposition.params.filename as string, ); }); diff --git a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts index 250615fb11..2d0cee5f5f 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts @@ -298,7 +298,11 @@ export class EmailReadImapV2 implements INodeType { // Returns the email text - const getText = async (parts: MessagePart[], message: Message, subtype: string) => { + const getText = async ( + parts: MessagePart[], + message: Message, + subtype: string, + ): Promise => { if (!message.attributes.struct) { return ''; } @@ -309,12 +313,14 @@ export class EmailReadImapV2 implements INodeType { ); }); - if (textParts.length === 0) { + const part = textParts[0]; + if (!part) { return ''; } try { - return await connection.getPartData(message, textParts[0]); + const partData = await connection.getPartData(message, part); + return partData.toString(); } catch { return ''; } @@ -355,7 +361,7 @@ export class EmailReadImapV2 implements INodeType { ?.filename as string, ); // Return it in the format n8n expects - return await this.helpers.prepareBinaryData(Buffer.from(partData), fileName); + return await this.helpers.prepareBinaryData(partData.buffer, fileName); }); attachmentPromises.push(attachmentPromise); diff --git a/patches/@types__uuencode@0.0.3.patch b/patches/@types__uuencode@0.0.3.patch new file mode 100644 index 0000000000..fbb1abfe43 --- /dev/null +++ b/patches/@types__uuencode@0.0.3.patch @@ -0,0 +1,10 @@ +diff --git a/index.d.ts b/index.d.ts +index f8f89c567f394a538018bfdf11c28dc15e9c9fdc..f3d1cd426711f1f714744474604bd7e321073983 100644 +--- a/index.d.ts ++++ b/index.d.ts +@@ -1,4 +1,4 @@ + /// + +-export function decode(str: string | Buffer): string; ++export function decode(str: string | Buffer): Buffer; + export function encode(str: string | Buffer): string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 644ea2c175..6b3427c96b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ patchedDependencies: '@types/express-serve-static-core@4.17.43': hash: 5orrj4qleu2iko5t27vl44u4we path: patches/@types__express-serve-static-core@4.17.43.patch + '@types/uuencode@0.0.3': + hash: 3i7wecddkama6vhpu5o37g24u4 + path: patches/@types__uuencode@0.0.3.patch '@types/ws@8.5.4': hash: nbzuqaoyqbrfwipijj5qriqqju path: patches/@types__ws@8.5.4.patch @@ -227,7 +230,7 @@ importers: version: 3.0.3 '@types/uuencode': specifier: ^0.0.3 - version: 0.0.3 + version: 0.0.3(patch_hash=3i7wecddkama6vhpu5o37g24u4) packages/@n8n/nodes-langchain: dependencies: @@ -9258,7 +9261,7 @@ packages: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.4.21(typescript@5.4.2) - vue-component-type-helpers: 2.0.17 + vue-component-type-helpers: 2.0.18 transitivePeerDependencies: - encoding - supports-color @@ -10161,11 +10164,12 @@ packages: resolution: {integrity: sha512-+lqLGxWZsEe4Z6OrzBI7Ym4SMUTaMS5yOrHZ0/IL0bpIye1Qbs4PpobJL2mLDbftUXlPFZR7fu6d1yM+bHLX1w==} dev: true - /@types/uuencode@0.0.3: + /@types/uuencode@0.0.3(patch_hash=3i7wecddkama6vhpu5o37g24u4): resolution: {integrity: sha512-NaBWHPPQvcXqiSaMAGa2Ea/XaFcK/nHwGe2akwJBXRLkCNa2+izx/F1aKJrzFH+L68D88VLYIATTYP7B2k4zVA==} dependencies: '@types/node': 18.16.16 dev: true + patched: true /@types/uuid@8.3.4: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} @@ -24340,8 +24344,8 @@ packages: resolution: {integrity: sha512-0vOfAtI67UjeO1G6UiX5Kd76CqaQ67wrRZiOe7UAb9Jm6GzlUr/fC7CV90XfwapJRjpCMaZFhv1V0ajWRmE9Dg==} dev: true - /vue-component-type-helpers@2.0.17: - resolution: {integrity: sha512-2car49m8ciqg/JjgMBkx7o/Fd2A7fHESxNqL/2vJYFLXm4VwYO4yH0rexOi4a35vwNgDyvt17B07Vj126l9rAQ==} + /vue-component-type-helpers@2.0.18: + resolution: {integrity: sha512-zi1QaDBhSb3oeHJh55aTCrosFNKEQsOL9j3XCAjpF9dwxDUUtd85RkJVzO+YpJqy1LNoCWLU8gwuZ7HW2iDN/A==} dev: true /vue-demi@0.14.5(vue@3.4.21):