Skip to content

Commit 9f87cc2

Browse files
netroyJoffcom
andauthored
feat(Email Trigger (IMAP) Node): Migrate from imap-simple to @n8n/imap (#8899)
Co-authored-by: Jonathan Bennetts <[email protected]>
1 parent 2826104 commit 9f87cc2

16 files changed

+616
-89
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
"start:tunnel": "./packages/cli/bin/n8n start --tunnel",
2727
"start:windows": "cd packages/cli/bin && n8n",
2828
"test": "turbo run test",
29-
"test:backend": "pnpm --filter=!@n8n/chat --filter=!n8n-design-system --filter=!n8n-editor-ui --filter=!n8n-nodes-base test",
30-
"test:nodes": "pnpm --filter=n8n-nodes-base test",
29+
"test:backend": "pnpm --filter=!@n8n/chat --filter=!n8n-design-system --filter=!n8n-editor-ui --filter=!n8n-nodes-base --filter=!@n8n/n8n-nodes-langchain test",
30+
"test:nodes": "pnpm --filter=n8n-nodes-base --filter=@n8n/n8n-nodes-langchain test",
3131
"test:frontend": "pnpm --filter=@n8n/chat --filter=n8n-design-system --filter=n8n-editor-ui test",
3232
"watch": "turbo run watch --parallel",
3333
"webhook": "./packages/cli/bin/n8n webhook",

packages/@n8n/imap/.eslintrc.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const sharedOptions = require('@n8n_io/eslint-config/shared');
2+
3+
/**
4+
* @type {import('@types/eslint').ESLint.ConfigData}
5+
*/
6+
module.exports = {
7+
extends: ['@n8n_io/eslint-config/base'],
8+
9+
...sharedOptions(__dirname),
10+
11+
rules: {
12+
'@typescript-eslint/consistent-type-imports': 'error',
13+
'n8n-local-rules/no-plain-errors': 'off',
14+
},
15+
};

packages/@n8n/imap/jest.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {import('jest').Config} */
2+
module.exports = require('../../../jest.config');

packages/@n8n/imap/package.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "@n8n/imap",
3+
"version": "0.1.0",
4+
"scripts": {
5+
"clean": "rimraf dist .turbo",
6+
"dev": "pnpm watch",
7+
"typecheck": "tsc",
8+
"build": "tsc -p tsconfig.build.json",
9+
"format": "prettier --write . --ignore-path ../../../.prettierignore",
10+
"lint": "eslint . --quiet",
11+
"lintfix": "eslint . --fix",
12+
"watch": "tsc -p tsconfig.build.json --watch",
13+
"test": "echo \"Error: no test created yet\""
14+
},
15+
"main": "dist/index.js",
16+
"module": "src/index.ts",
17+
"types": "dist/index.d.ts",
18+
"files": [
19+
"dist/**/*"
20+
],
21+
"dependencies": {
22+
"iconv-lite": "0.6.3",
23+
"imap": "0.8.19",
24+
"quoted-printable": "1.0.1",
25+
"utf8": "3.0.0",
26+
"uuencode": "0.0.4"
27+
},
28+
"devDependencies": {
29+
"@types/imap": "^0.8.40",
30+
"@types/quoted-printable": "^1.0.2",
31+
"@types/utf8": "^3.0.3",
32+
"@types/uuencode": "^0.0.3"
33+
}
34+
}

packages/@n8n/imap/src/ImapSimple.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/* eslint-disable @typescript-eslint/no-use-before-define */
2+
import { EventEmitter } from 'events';
3+
import type Imap from 'imap';
4+
import { type ImapMessage } from 'imap';
5+
import * as qp from 'quoted-printable';
6+
import * as iconvlite from 'iconv-lite';
7+
import * as utf8 from 'utf8';
8+
import * as uuencode from 'uuencode';
9+
10+
import { getMessage } from './helpers/getMessage';
11+
import type { Message, MessagePart } from './types';
12+
13+
const IMAP_EVENTS = ['alert', 'mail', 'expunge', 'uidvalidity', 'update', 'close', 'end'] as const;
14+
15+
export class ImapSimple extends EventEmitter {
16+
/** flag to determine whether we should suppress ECONNRESET from bubbling up to listener */
17+
private ending = false;
18+
19+
constructor(private readonly imap: Imap) {
20+
super();
21+
22+
// pass most node-imap `Connection` events through 1:1
23+
IMAP_EVENTS.forEach((event) => {
24+
this.imap.on(event, this.emit.bind(this, event));
25+
});
26+
27+
// special handling for `error` event
28+
this.imap.on('error', (e: Error & { code?: string }) => {
29+
// if .end() has been called and an 'ECONNRESET' error is received, don't bubble
30+
if (e && this.ending && e.code?.toUpperCase() === 'ECONNRESET') {
31+
return;
32+
}
33+
this.emit('error', e);
34+
});
35+
}
36+
37+
/** disconnect from the imap server */
38+
end(): void {
39+
// set state flag to suppress 'ECONNRESET' errors that are triggered when .end() is called.
40+
// it is a known issue that has no known fix. This just temporarily ignores that error.
41+
// https://github.com/mscdex/node-imap/issues/391
42+
// https://github.com/mscdex/node-imap/issues/395
43+
this.ending = true;
44+
45+
// using 'close' event to unbind ECONNRESET error handler, because the node-imap
46+
// maintainer claims it is the more reliable event between 'end' and 'close'.
47+
// https://github.com/mscdex/node-imap/issues/394
48+
this.imap.once('close', () => {
49+
this.ending = false;
50+
});
51+
52+
this.imap.end();
53+
}
54+
55+
/**
56+
* Search the currently open mailbox, and retrieve the results
57+
*
58+
* Results are in the form:
59+
*
60+
* [{
61+
* attributes: object,
62+
* parts: [ { which: string, size: number, body: string }, ... ]
63+
* }, ...]
64+
*
65+
* See node-imap's ImapMessage signature for information about `attributes`, `which`, `size`, and `body`.
66+
* For any message part that is a `HEADER`, the body is automatically parsed into an object.
67+
*/
68+
async search(
69+
/** Criteria to use to search. Passed to node-imap's .search() 1:1 */
70+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71+
searchCriteria: any[],
72+
/** Criteria to use to fetch the search results. Passed to node-imap's .fetch() 1:1 */
73+
fetchOptions: Imap.FetchOptions,
74+
) {
75+
return await new Promise<Message[]>((resolve, reject) => {
76+
this.imap.search(searchCriteria, (e, uids) => {
77+
if (e) {
78+
reject(e);
79+
return;
80+
}
81+
82+
if (uids.length === 0) {
83+
resolve([]);
84+
return;
85+
}
86+
87+
const fetch = this.imap.fetch(uids, fetchOptions);
88+
let messagesRetrieved = 0;
89+
const messages: Message[] = [];
90+
91+
const fetchOnMessage = async (message: Imap.ImapMessage, seqNo: number) => {
92+
const msg: Message = await getMessage(message);
93+
msg.seqNo = seqNo;
94+
messages[seqNo] = msg;
95+
96+
messagesRetrieved++;
97+
if (messagesRetrieved === uids.length) {
98+
resolve(messages.filter((m) => !!m));
99+
}
100+
};
101+
102+
const fetchOnError = (error: Error) => {
103+
fetch.removeListener('message', fetchOnMessage);
104+
fetch.removeListener('end', fetchOnEnd);
105+
reject(error);
106+
};
107+
108+
const fetchOnEnd = () => {
109+
fetch.removeListener('message', fetchOnMessage);
110+
fetch.removeListener('error', fetchOnError);
111+
};
112+
113+
fetch.on('message', fetchOnMessage);
114+
fetch.once('error', fetchOnError);
115+
fetch.once('end', fetchOnEnd);
116+
});
117+
});
118+
}
119+
120+
/** Download a "part" (either a portion of the message body, or an attachment) */
121+
async getPartData(
122+
/** The message returned from `search()` */
123+
message: Message,
124+
/** The message part to be downloaded, from the `message.attributes.struct` Array */
125+
part: MessagePart,
126+
) {
127+
return await new Promise<string>((resolve, reject) => {
128+
const fetch = this.imap.fetch(message.attributes.uid, {
129+
bodies: [part.partID],
130+
struct: true,
131+
});
132+
133+
const fetchOnMessage = async (msg: ImapMessage) => {
134+
const result = await getMessage(msg);
135+
if (result.parts.length !== 1) {
136+
reject(new Error('Got ' + result.parts.length + ' parts, should get 1'));
137+
return;
138+
}
139+
140+
const data = result.parts[0].body as string;
141+
142+
const encoding = part.encoding.toUpperCase();
143+
144+
if (encoding === 'BASE64') {
145+
resolve(Buffer.from(data, 'base64').toString());
146+
return;
147+
}
148+
149+
if (encoding === 'QUOTED-PRINTABLE') {
150+
if (part.params?.charset?.toUpperCase() === 'UTF-8') {
151+
resolve(Buffer.from(utf8.decode(qp.decode(data))).toString());
152+
} else {
153+
resolve(Buffer.from(qp.decode(data)).toString());
154+
}
155+
return;
156+
}
157+
158+
if (encoding === '7BIT') {
159+
resolve(Buffer.from(data).toString('ascii'));
160+
return;
161+
}
162+
163+
if (encoding === '8BIT' || encoding === 'BINARY') {
164+
const charset = part.params?.charset ?? 'utf-8';
165+
resolve(iconvlite.decode(Buffer.from(data), charset));
166+
return;
167+
}
168+
169+
if (encoding === 'UUENCODE') {
170+
const parts = data.toString().split('\n'); // remove newline characters
171+
const merged = parts.splice(1, parts.length - 4).join(''); // remove excess lines and join lines with empty string
172+
resolve(uuencode.decode(merged));
173+
return;
174+
}
175+
176+
// if it gets here, the encoding is not currently supported
177+
reject(new Error('Unknown encoding ' + part.encoding));
178+
};
179+
180+
const fetchOnError = (error: Error) => {
181+
fetch.removeListener('message', fetchOnMessage);
182+
fetch.removeListener('end', fetchOnEnd);
183+
reject(error);
184+
};
185+
186+
const fetchOnEnd = () => {
187+
fetch.removeListener('message', fetchOnMessage);
188+
fetch.removeListener('error', fetchOnError);
189+
};
190+
191+
fetch.once('message', fetchOnMessage);
192+
fetch.once('error', fetchOnError);
193+
fetch.once('end', fetchOnEnd);
194+
});
195+
}
196+
197+
/** Adds the provided flag(s) to the specified message(s). */
198+
async addFlags(
199+
/** The messages uid */
200+
uid: number[],
201+
/** The flags to add to the message(s). */
202+
flags: string | string[],
203+
) {
204+
return await new Promise<void>((resolve, reject) => {
205+
this.imap.addFlags(uid, flags, (e) => (e ? reject(e) : resolve()));
206+
});
207+
}
208+
209+
/** Returns a list of mailboxes (folders). */
210+
async getBoxes() {
211+
return await new Promise<Imap.MailBoxes>((resolve, reject) => {
212+
this.imap.getBoxes((e, boxes) => (e ? reject(e) : resolve(boxes)));
213+
});
214+
}
215+
216+
/** Open a mailbox */
217+
async openBox(
218+
/** The name of the box to open */
219+
boxName: string,
220+
): Promise<Imap.Box> {
221+
return await new Promise((resolve, reject) => {
222+
this.imap.openBox(boxName, (e, result) => (e ? reject(e) : resolve(result)));
223+
});
224+
}
225+
226+
/** Close a mailbox */
227+
async closeBox(
228+
/** If autoExpunge is true, any messages marked as Deleted in the currently open mailbox will be removed @default true */
229+
autoExpunge = true,
230+
) {
231+
return await new Promise<void>((resolve, reject) => {
232+
this.imap.closeBox(autoExpunge, (e) => (e ? reject(e) : resolve()));
233+
});
234+
}
235+
}

packages/@n8n/imap/src/errors.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export abstract class ImapError extends Error {}
2+
3+
/** Error thrown when a connection attempt has timed out */
4+
export class ConnectionTimeoutError extends ImapError {
5+
constructor(
6+
/** timeout in milliseconds that the connection waited before timing out */
7+
readonly timeout?: number,
8+
) {
9+
let message = 'connection timed out';
10+
if (timeout) {
11+
message += `. timeout = ${timeout} ms`;
12+
}
13+
super(message);
14+
}
15+
}
16+
17+
export class ConnectionClosedError extends ImapError {
18+
constructor() {
19+
super('Connection closed unexpectedly');
20+
}
21+
}
22+
23+
export class ConnectionEndedError extends ImapError {
24+
constructor() {
25+
super('Connection ended unexpectedly');
26+
}
27+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
parseHeader,
3+
type ImapMessage,
4+
type ImapMessageBodyInfo,
5+
type ImapMessageAttributes,
6+
} from 'imap';
7+
import type { Message, MessageBodyPart } from '../types';
8+
9+
/**
10+
* Given an 'ImapMessage' from the node-imap library, retrieves the `Message`
11+
*/
12+
export async function getMessage(
13+
/** an ImapMessage from the node-imap library */
14+
message: ImapMessage,
15+
): Promise<Message> {
16+
return await new Promise((resolve) => {
17+
let attributes: ImapMessageAttributes;
18+
const parts: MessageBodyPart[] = [];
19+
20+
const messageOnBody = (stream: NodeJS.ReadableStream, info: ImapMessageBodyInfo) => {
21+
let body: string = '';
22+
23+
const streamOnData = (chunk: Buffer) => {
24+
body += chunk.toString('utf8');
25+
};
26+
27+
stream.on('data', streamOnData);
28+
stream.once('end', () => {
29+
stream.removeListener('data', streamOnData);
30+
31+
parts.push({
32+
which: info.which,
33+
size: info.size,
34+
body: /^HEADER/g.test(info.which) ? parseHeader(body) : body,
35+
});
36+
});
37+
};
38+
39+
const messageOnAttributes = (attrs: ImapMessageAttributes) => {
40+
attributes = attrs;
41+
};
42+
43+
const messageOnEnd = () => {
44+
message.removeListener('body', messageOnBody);
45+
message.removeListener('attributes', messageOnAttributes);
46+
resolve({ attributes, parts });
47+
};
48+
49+
message.on('body', messageOnBody);
50+
message.once('attributes', messageOnAttributes);
51+
message.once('end', messageOnEnd);
52+
});
53+
}

0 commit comments

Comments
 (0)