Skip to content

Commit

Permalink
fix(Email Trigger (IMAP) Node): Handle attachments correctly (#9410)
Browse files Browse the repository at this point in the history
  • Loading branch information
netroy committed May 16, 2024
1 parent c19d4aa commit 5633eec
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 56 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,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"
}
}
}
2 changes: 1 addition & 1 deletion packages/@n8n/imap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 3 additions & 41 deletions packages/@n8n/imap/src/ImapSimple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string>((resolve, reject) => {
return await new Promise<PartData>((resolve, reject) => {
const fetch = this.imap.fetch(message.attributes.uid, {
bodies: [part.partID],
struct: true,
Expand All @@ -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) => {
Expand Down
84 changes: 84 additions & 0 deletions packages/@n8n/imap/src/PartData.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
88 changes: 88 additions & 0 deletions packages/@n8n/imap/test/PartData.test.ts
Original file line number Diff line number Diff line change
@@ -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!');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
if (!message.attributes.struct) {
return '';
}
Expand All @@ -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 '';
}
Expand Down Expand Up @@ -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,
);
});
Expand Down
14 changes: 10 additions & 4 deletions packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
if (!message.attributes.struct) {
return '';
}
Expand All @@ -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 '';
}
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions patches/@types__uuencode@0.0.3.patch
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 9 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 5633eec

Please sign in to comment.