fix(Email Trigger (IMAP) Node): Handle attachments correctly (#9410)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-05-15 15:50:53 +02:00 committed by GitHub
parent bf549301df
commit 68a6c81729
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 213 additions and 56 deletions

View file

@ -92,7 +92,8 @@
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch", "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/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", "@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"
} }
} }
} }

View file

@ -10,7 +10,7 @@
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lintfix": "eslint . --fix", "lintfix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch", "watch": "tsc -p tsconfig.build.json --watch",
"test": "echo \"Error: no test created yet\"" "test": "jest"
}, },
"main": "dist/index.js", "main": "dist/index.js",
"module": "src/index.ts", "module": "src/index.ts",

View file

@ -2,13 +2,10 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import type Imap from 'imap'; import type Imap from 'imap';
import { type ImapMessage } 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 { getMessage } from './helpers/getMessage';
import type { Message, MessagePart } from './types'; import type { Message, MessagePart } from './types';
import { PartData } from './PartData';
const IMAP_EVENTS = ['alert', 'mail', 'expunge', 'uidvalidity', 'update', 'close', 'end'] as const; 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 */ /** The message part to be downloaded, from the `message.attributes.struct` Array */
part: MessagePart, part: MessagePart,
) { ) {
return await new Promise<string>((resolve, reject) => { return await new Promise<PartData>((resolve, reject) => {
const fetch = this.imap.fetch(message.attributes.uid, { const fetch = this.imap.fetch(message.attributes.uid, {
bodies: [part.partID], bodies: [part.partID],
struct: true, struct: true,
@ -138,43 +135,8 @@ export class ImapSimple extends EventEmitter {
} }
const data = result.parts[0].body as string; const data = result.parts[0].body as string;
const encoding = part.encoding.toUpperCase(); const encoding = part.encoding.toUpperCase();
resolve(PartData.fromData(data, encoding));
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));
}; };
const fetchOnError = (error: Error) => { const fetchOnError = (error: Error) => {

View file

@ -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);
}
}

View file

@ -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!');
});
});

View file

@ -285,7 +285,7 @@ export class EmailReadImapV1 implements INodeType {
// Returns the email text // Returns the email text
const getText = async (parts: any[], message: Message, subtype: string) => { const getText = async (parts: any[], message: Message, subtype: string): Promise<string> => {
if (!message.attributes.struct) { if (!message.attributes.struct) {
return ''; return '';
} }
@ -296,12 +296,14 @@ export class EmailReadImapV1 implements INodeType {
); );
}); });
if (textParts.length === 0) { const part = textParts[0];
if (!part) {
return ''; return '';
} }
try { try {
return await connection.getPartData(message, textParts[0]); const partData = await connection.getPartData(message, part);
return partData.toString();
} catch { } catch {
return ''; return '';
} }
@ -330,7 +332,7 @@ export class EmailReadImapV1 implements INodeType {
.then(async (partData) => { .then(async (partData) => {
// Return it in the format n8n expects // Return it in the format n8n expects
return await this.helpers.prepareBinaryData( return await this.helpers.prepareBinaryData(
Buffer.from(partData), partData.buffer,
attachmentPart.disposition.params.filename as string, attachmentPart.disposition.params.filename as string,
); );
}); });

View file

@ -298,7 +298,11 @@ export class EmailReadImapV2 implements INodeType {
// Returns the email text // Returns the email text
const getText = async (parts: MessagePart[], message: Message, subtype: string) => { const getText = async (
parts: MessagePart[],
message: Message,
subtype: string,
): Promise<string> => {
if (!message.attributes.struct) { if (!message.attributes.struct) {
return ''; return '';
} }
@ -309,12 +313,14 @@ export class EmailReadImapV2 implements INodeType {
); );
}); });
if (textParts.length === 0) { const part = textParts[0];
if (!part) {
return ''; return '';
} }
try { try {
return await connection.getPartData(message, textParts[0]); const partData = await connection.getPartData(message, part);
return partData.toString();
} catch { } catch {
return ''; return '';
} }
@ -355,7 +361,7 @@ export class EmailReadImapV2 implements INodeType {
?.filename as string, ?.filename as string,
); );
// Return it in the format n8n expects // 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); attachmentPromises.push(attachmentPromise);

View file

@ -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 @@
/// <reference types="node"/>
-export function decode(str: string | Buffer): string;
+export function decode(str: string | Buffer): Buffer;
export function encode(str: string | Buffer): string;

View file

@ -25,6 +25,9 @@ patchedDependencies:
'@types/express-serve-static-core@4.17.43': '@types/express-serve-static-core@4.17.43':
hash: 5orrj4qleu2iko5t27vl44u4we hash: 5orrj4qleu2iko5t27vl44u4we
path: patches/@types__express-serve-static-core@4.17.43.patch 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': '@types/ws@8.5.4':
hash: nbzuqaoyqbrfwipijj5qriqqju hash: nbzuqaoyqbrfwipijj5qriqqju
path: patches/@types__ws@8.5.4.patch path: patches/@types__ws@8.5.4.patch
@ -227,7 +230,7 @@ importers:
version: 3.0.3 version: 3.0.3
'@types/uuencode': '@types/uuencode':
specifier: ^0.0.3 specifier: ^0.0.3
version: 0.0.3 version: 0.0.3(patch_hash=3i7wecddkama6vhpu5o37g24u4)
packages/@n8n/nodes-langchain: packages/@n8n/nodes-langchain:
dependencies: dependencies:
@ -9258,7 +9261,7 @@ packages:
ts-dedent: 2.2.0 ts-dedent: 2.2.0
type-fest: 2.19.0 type-fest: 2.19.0
vue: 3.4.21(typescript@5.4.2) vue: 3.4.21(typescript@5.4.2)
vue-component-type-helpers: 2.0.17 vue-component-type-helpers: 2.0.18
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
- supports-color - supports-color
@ -10161,11 +10164,12 @@ packages:
resolution: {integrity: sha512-+lqLGxWZsEe4Z6OrzBI7Ym4SMUTaMS5yOrHZ0/IL0bpIye1Qbs4PpobJL2mLDbftUXlPFZR7fu6d1yM+bHLX1w==} resolution: {integrity: sha512-+lqLGxWZsEe4Z6OrzBI7Ym4SMUTaMS5yOrHZ0/IL0bpIye1Qbs4PpobJL2mLDbftUXlPFZR7fu6d1yM+bHLX1w==}
dev: true dev: true
/@types/uuencode@0.0.3: /@types/uuencode@0.0.3(patch_hash=3i7wecddkama6vhpu5o37g24u4):
resolution: {integrity: sha512-NaBWHPPQvcXqiSaMAGa2Ea/XaFcK/nHwGe2akwJBXRLkCNa2+izx/F1aKJrzFH+L68D88VLYIATTYP7B2k4zVA==} resolution: {integrity: sha512-NaBWHPPQvcXqiSaMAGa2Ea/XaFcK/nHwGe2akwJBXRLkCNa2+izx/F1aKJrzFH+L68D88VLYIATTYP7B2k4zVA==}
dependencies: dependencies:
'@types/node': 18.16.16 '@types/node': 18.16.16
dev: true dev: true
patched: true
/@types/uuid@8.3.4: /@types/uuid@8.3.4:
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
@ -24340,8 +24344,8 @@ packages:
resolution: {integrity: sha512-0vOfAtI67UjeO1G6UiX5Kd76CqaQ67wrRZiOe7UAb9Jm6GzlUr/fC7CV90XfwapJRjpCMaZFhv1V0ajWRmE9Dg==} resolution: {integrity: sha512-0vOfAtI67UjeO1G6UiX5Kd76CqaQ67wrRZiOe7UAb9Jm6GzlUr/fC7CV90XfwapJRjpCMaZFhv1V0ajWRmE9Dg==}
dev: true dev: true
/vue-component-type-helpers@2.0.17: /vue-component-type-helpers@2.0.18:
resolution: {integrity: sha512-2car49m8ciqg/JjgMBkx7o/Fd2A7fHESxNqL/2vJYFLXm4VwYO4yH0rexOi4a35vwNgDyvt17B07Vj126l9rAQ==} resolution: {integrity: sha512-zi1QaDBhSb3oeHJh55aTCrosFNKEQsOL9j3XCAjpF9dwxDUUtd85RkJVzO+YpJqy1LNoCWLU8gwuZ7HW2iDN/A==}
dev: true dev: true
/vue-demi@0.14.5(vue@3.4.21): /vue-demi@0.14.5(vue@3.4.21):