diff --git a/packages/nodes-base/nodes/EmailReadImap/test/v2/EmailReadImapV2.node.test.ts b/packages/nodes-base/nodes/EmailReadImap/test/v2/EmailReadImapV2.node.test.ts new file mode 100644 index 0000000000..a2803f3113 --- /dev/null +++ b/packages/nodes-base/nodes/EmailReadImap/test/v2/EmailReadImapV2.node.test.ts @@ -0,0 +1,66 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { mock } from 'jest-mock-extended'; +import { type INodeTypeBaseDescription, type ITriggerFunctions } from 'n8n-workflow'; + +import { type ICredentialsDataImap } from '../../../../credentials/Imap.credentials'; +import { EmailReadImapV2 } from '../../v2/EmailReadImapV2.node'; + +jest.mock('@n8n/imap', () => { + const originalModule = jest.requireActual('@n8n/imap'); + + return { + ...originalModule, + connect: jest.fn().mockImplementation(() => ({ + then: jest.fn().mockImplementation(() => ({ + openBox: jest.fn().mockResolvedValue({}), + })), + })), + }; +}); + +describe('Test IMap V2', () => { + const triggerFunctions = mock({ + helpers: { + createDeferredPromise: jest.fn().mockImplementation(() => { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }), + }, + }); + + const credentials: ICredentialsDataImap = { + host: 'imap.gmail.com', + port: 993, + user: 'user', + password: 'password', + secure: false, + allowUnauthorizedCerts: false, + }; + + triggerFunctions.getCredentials.calledWith('imap').mockResolvedValue(credentials); + triggerFunctions.logger.debug = jest.fn(); + triggerFunctions.getNodeParameter.calledWith('options').mockReturnValue({ + name: 'Mark as Read', + value: 'read', + }); + + const baseDescription: INodeTypeBaseDescription = { + displayName: 'EmailReadImapV2', + name: 'emailReadImapV2', + icon: 'file:removeDuplicates.svg', + group: ['transform'], + description: 'Delete items with matching field values', + }; + + afterEach(() => jest.resetAllMocks()); + + it('should run return a close function on success', async () => { + const result = await new EmailReadImapV2(baseDescription).trigger.call(triggerFunctions); + + expect(result.closeFunction).toBeDefined(); + }); +}); diff --git a/packages/nodes-base/nodes/EmailReadImap/test/v2/utils.test.ts b/packages/nodes-base/nodes/EmailReadImap/test/v2/utils.test.ts new file mode 100644 index 0000000000..777f8ca8a7 --- /dev/null +++ b/packages/nodes-base/nodes/EmailReadImap/test/v2/utils.test.ts @@ -0,0 +1,91 @@ +import { type ImapSimple } from '@n8n/imap'; +import { mock } from 'jest-mock-extended'; +import { returnJsonArray } from 'n8n-core'; +import { type IDataObject, type ITriggerFunctions } from 'n8n-workflow'; + +import { getNewEmails } from '../../v2/utils'; + +describe('Test IMap V2 utils', () => { + afterEach(() => jest.resetAllMocks()); + + describe('getNewEmails', () => { + const triggerFunctions = mock({ + helpers: { returnJsonArray }, + }); + + const message = { + attributes: { + uuid: 1, + struct: {}, + }, + parts: [ + { which: '', body: 'Body content' }, + { which: 'HEADER', body: 'h' }, + { which: 'TEXT', body: 'txt' }, + ], + }; + + const staticData: IDataObject = {}; + const imapConnection = mock({ + search: jest.fn().mockReturnValue(Promise.resolve([message])), + }); + const getText = jest.fn().mockReturnValue('text'); + const getAttachment = jest.fn().mockReturnValue(['attachment']); + + it('should return new emails', async () => { + const expectedResults = [ + { + format: 'resolved', + expected: { + json: { + attachments: undefined, + headers: { '': 'Body content' }, + headerLines: undefined, + html: false, + }, + binary: undefined, + }, + }, + { + format: 'simple', + expected: { + json: { + textHtml: 'text', + textPlain: 'text', + metadata: { + '0': 'h', + }, + }, + }, + }, + { + format: 'raw', + expected: { + json: { raw: 'txt' }, + }, + }, + ]; + + expectedResults.forEach(async (expectedResult) => { + triggerFunctions.getNodeParameter + .calledWith('format') + .mockReturnValue(expectedResult.format); + triggerFunctions.getNodeParameter + .calledWith('dataPropertyAttachmentsPrefixName') + .mockReturnValue('resolved'); + + const result = getNewEmails.call( + triggerFunctions, + imapConnection, + [], + staticData, + '', + getText, + getAttachment, + ); + + await expect(result).resolves.toEqual([expectedResult.expected]); + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts index ee1ba9f3fd..d11488a062 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts @@ -1,18 +1,13 @@ import type { ImapSimple, ImapSimpleOptions, Message, MessagePart } from '@n8n/imap'; -import { connect as imapConnect, getParts } from '@n8n/imap'; -import find from 'lodash/find'; +import { connect as imapConnect } from '@n8n/imap'; import isEmpty from 'lodash/isEmpty'; -import type { Source as ParserSource } from 'mailparser'; -import { simpleParser } from 'mailparser'; import type { ITriggerFunctions, IBinaryData, - IBinaryKeyData, ICredentialsDecrypted, ICredentialTestFunctions, IDataObject, INodeCredentialTestResult, - INodeExecutionData, INodeType, INodeTypeBaseDescription, INodeTypeDescription, @@ -22,45 +17,10 @@ import type { import { NodeConnectionType, NodeOperationError, TriggerCloseError } from 'n8n-workflow'; import rfc2047 from 'rfc2047'; +import { getNewEmails } from './utils'; import type { ICredentialsDataImap } from '../../../credentials/Imap.credentials'; import { isCredentialsDataImap } from '../../../credentials/Imap.credentials'; -export async function parseRawEmail( - this: ITriggerFunctions, - messageEncoded: ParserSource, - dataPropertyNameDownload: string, -): Promise { - const responseData = await simpleParser(messageEncoded); - const headers: IDataObject = {}; - const additionalData: IDataObject = {}; - - for (const header of responseData.headerLines) { - headers[header.key] = header.line; - } - - additionalData.headers = headers; - additionalData.headerLines = undefined; - - const binaryData: IBinaryKeyData = {}; - if (responseData.attachments) { - for (let i = 0; i < responseData.attachments.length; i++) { - const attachment = responseData.attachments[i]; - binaryData[`${dataPropertyNameDownload}${i}`] = await this.helpers.prepareBinaryData( - attachment.content, - attachment.filename, - attachment.contentType, - ); - } - - additionalData.attachments = undefined; - } - - return { - json: { ...responseData, ...additionalData }, - binary: Object.keys(binaryData).length ? binaryData : undefined, - } as INodeExecutionData; -} - const versionDescription: INodeTypeDescription = { displayName: 'Email Trigger (IMAP)', name: 'emailReadImap', @@ -369,171 +329,6 @@ export class EmailReadImapV2 implements INodeType { return await Promise.all(attachmentPromises); }; - // Returns all the new unseen messages - const getNewEmails = async ( - imapConnection: ImapSimple, - searchCriteria: Array, - ): Promise => { - const format = this.getNodeParameter('format', 0) as string; - - let fetchOptions = {}; - - if (format === 'simple' || format === 'raw') { - fetchOptions = { - bodies: ['TEXT', 'HEADER'], - markSeen: false, - struct: true, - }; - } else if (format === 'resolved') { - fetchOptions = { - bodies: [''], - markSeen: false, - struct: true, - }; - } - - const results = await imapConnection.search(searchCriteria, fetchOptions); - - const newEmails: INodeExecutionData[] = []; - let newEmail: INodeExecutionData; - let attachments: IBinaryData[]; - let propertyName: string; - - // All properties get by default moved to metadata except the ones - // which are defined here which get set on the top level. - const topLevelProperties = ['cc', 'date', 'from', 'subject', 'to']; - - if (format === 'resolved') { - const dataPropertyAttachmentsPrefixName = this.getNodeParameter( - 'dataPropertyAttachmentsPrefixName', - ) as string; - - for (const message of results) { - if ( - staticData.lastMessageUid !== undefined && - message.attributes.uid <= (staticData.lastMessageUid as number) - ) { - continue; - } - if ( - staticData.lastMessageUid === undefined || - (staticData.lastMessageUid as number) < message.attributes.uid - ) { - staticData.lastMessageUid = message.attributes.uid; - } - const part = find(message.parts, { which: '' }); - - if (part === undefined) { - throw new NodeOperationError(this.getNode(), 'Email part could not be parsed.'); - } - const parsedEmail = await parseRawEmail.call( - this, - part.body as Buffer, - dataPropertyAttachmentsPrefixName, - ); - - newEmails.push(parsedEmail); - } - } else if (format === 'simple') { - const downloadAttachments = this.getNodeParameter('downloadAttachments') as boolean; - - let dataPropertyAttachmentsPrefixName = ''; - if (downloadAttachments) { - dataPropertyAttachmentsPrefixName = this.getNodeParameter( - 'dataPropertyAttachmentsPrefixName', - ) as string; - } - - for (const message of results) { - if ( - staticData.lastMessageUid !== undefined && - message.attributes.uid <= (staticData.lastMessageUid as number) - ) { - continue; - } - if ( - staticData.lastMessageUid === undefined || - (staticData.lastMessageUid as number) < message.attributes.uid - ) { - staticData.lastMessageUid = message.attributes.uid; - } - const parts = getParts(message.attributes.struct as IDataObject[]); - - newEmail = { - json: { - textHtml: await getText(parts, message, 'html'), - textPlain: await getText(parts, message, 'plain'), - metadata: {} as IDataObject, - }, - }; - - const messageHeader = message.parts.filter((part) => part.which === 'HEADER'); - - const messageBody = messageHeader[0].body as Record; - for (propertyName of Object.keys(messageBody)) { - if (messageBody[propertyName].length) { - if (topLevelProperties.includes(propertyName)) { - newEmail.json[propertyName] = messageBody[propertyName][0]; - } else { - (newEmail.json.metadata as IDataObject)[propertyName] = - messageBody[propertyName][0]; - } - } - } - - if (downloadAttachments) { - // Get attachments and add them if any get found - attachments = await getAttachment(imapConnection, parts, message); - if (attachments.length) { - newEmail.binary = {}; - for (let i = 0; i < attachments.length; i++) { - newEmail.binary[`${dataPropertyAttachmentsPrefixName}${i}`] = attachments[i]; - } - } - } - - newEmails.push(newEmail); - } - } else if (format === 'raw') { - for (const message of results) { - if ( - staticData.lastMessageUid !== undefined && - message.attributes.uid <= (staticData.lastMessageUid as number) - ) { - continue; - } - if ( - staticData.lastMessageUid === undefined || - (staticData.lastMessageUid as number) < message.attributes.uid - ) { - staticData.lastMessageUid = message.attributes.uid; - } - const part = find(message.parts, { which: 'TEXT' }); - - if (part === undefined) { - throw new NodeOperationError(this.getNode(), 'Email part could not be parsed.'); - } - // Return base64 string - newEmail = { - json: { - raw: part.body as string, - }, - }; - - newEmails.push(newEmail); - } - } - - // only mark messages as seen once processing has finished - if (postProcessAction === 'read') { - const uidList = results.map((e) => e.attributes.uid); - if (uidList.length > 0) { - await imapConnection.addFlags(uidList, '\\SEEN'); - } - } - return newEmails; - }; - const returnedPromise = this.helpers.createDeferredPromise(); const establishConnection = async (): Promise => { @@ -579,7 +374,15 @@ export class EmailReadImapV2 implements INodeType { } try { - const returnData = await getNewEmails(connection, searchCriteria); + const returnData = await getNewEmails.call( + this, + connection, + searchCriteria, + staticData, + postProcessAction, + getText, + getAttachment, + ); if (returnData.length) { this.emit([returnData]); } diff --git a/packages/nodes-base/nodes/EmailReadImap/v2/utils.ts b/packages/nodes-base/nodes/EmailReadImap/v2/utils.ts new file mode 100644 index 0000000000..7e9f35a2f0 --- /dev/null +++ b/packages/nodes-base/nodes/EmailReadImap/v2/utils.ts @@ -0,0 +1,219 @@ +import { getParts, type ImapSimple, type Message, type MessagePart } from '@n8n/imap'; +import { find } from 'lodash'; +import { simpleParser, type Source as ParserSource } from 'mailparser'; +import { + type IBinaryData, + type INodeExecutionData, + type IDataObject, + type ITriggerFunctions, + NodeOperationError, + type IBinaryKeyData, +} from 'n8n-workflow'; + +async function parseRawEmail( + this: ITriggerFunctions, + messageEncoded: ParserSource, + dataPropertyNameDownload: string, +): Promise { + const responseData = await simpleParser(messageEncoded); + const headers: IDataObject = {}; + const additionalData: IDataObject = {}; + + for (const header of responseData.headerLines) { + headers[header.key] = header.line; + } + + additionalData.headers = headers; + additionalData.headerLines = undefined; + + const binaryData: IBinaryKeyData = {}; + if (responseData.attachments) { + for (let i = 0; i < responseData.attachments.length; i++) { + const attachment = responseData.attachments[i]; + binaryData[`${dataPropertyNameDownload}${i}`] = await this.helpers.prepareBinaryData( + attachment.content, + attachment.filename, + attachment.contentType, + ); + } + + additionalData.attachments = undefined; + } + + return { + json: { ...responseData, ...additionalData }, + binary: Object.keys(binaryData).length ? binaryData : undefined, + } as INodeExecutionData; +} + +export async function getNewEmails( + this: ITriggerFunctions, + imapConnection: ImapSimple, + searchCriteria: Array, + staticData: IDataObject, + postProcessAction: string, + getText: (parts: MessagePart[], message: Message, subtype: string) => Promise, + getAttachment: ( + imapConnection: ImapSimple, + parts: MessagePart[], + message: Message, + ) => Promise, +): Promise { + const format = this.getNodeParameter('format', 0) as string; + + let fetchOptions = {}; + + if (format === 'simple' || format === 'raw') { + fetchOptions = { + bodies: ['TEXT', 'HEADER'], + markSeen: false, + struct: true, + }; + } else if (format === 'resolved') { + fetchOptions = { + bodies: [''], + markSeen: false, + struct: true, + }; + } + + const results = await imapConnection.search(searchCriteria, fetchOptions); + + const newEmails: INodeExecutionData[] = []; + let newEmail: INodeExecutionData; + let attachments: IBinaryData[]; + let propertyName: string; + + // All properties get by default moved to metadata except the ones + // which are defined here which get set on the top level. + const topLevelProperties = ['cc', 'date', 'from', 'subject', 'to']; + + if (format === 'resolved') { + const dataPropertyAttachmentsPrefixName = this.getNodeParameter( + 'dataPropertyAttachmentsPrefixName', + ) as string; + + for (const message of results) { + if ( + staticData.lastMessageUid !== undefined && + message.attributes.uid <= (staticData.lastMessageUid as number) + ) { + continue; + } + if ( + staticData.lastMessageUid === undefined || + (staticData.lastMessageUid as number) < message.attributes.uid + ) { + staticData.lastMessageUid = message.attributes.uid; + } + const part = find(message.parts, { which: '' }); + + if (part === undefined) { + throw new NodeOperationError(this.getNode(), 'Email part could not be parsed.'); + } + const parsedEmail = await parseRawEmail.call( + this, + part.body as Buffer, + dataPropertyAttachmentsPrefixName, + ); + + newEmails.push(parsedEmail); + } + } else if (format === 'simple') { + const downloadAttachments = this.getNodeParameter('downloadAttachments') as boolean; + + let dataPropertyAttachmentsPrefixName = ''; + if (downloadAttachments) { + dataPropertyAttachmentsPrefixName = this.getNodeParameter( + 'dataPropertyAttachmentsPrefixName', + ) as string; + } + + for (const message of results) { + if ( + staticData.lastMessageUid !== undefined && + message.attributes.uid <= (staticData.lastMessageUid as number) + ) { + continue; + } + if ( + staticData.lastMessageUid === undefined || + (staticData.lastMessageUid as number) < message.attributes.uid + ) { + staticData.lastMessageUid = message.attributes.uid; + } + const parts = getParts(message.attributes.struct as IDataObject[]); + + newEmail = { + json: { + textHtml: await getText(parts, message, 'html'), + textPlain: await getText(parts, message, 'plain'), + metadata: {} as IDataObject, + }, + }; + + const messageHeader = message.parts.filter((part) => part.which === 'HEADER'); + + const messageBody = messageHeader[0].body as Record; + for (propertyName of Object.keys(messageBody)) { + if (messageBody[propertyName].length) { + if (topLevelProperties.includes(propertyName)) { + newEmail.json[propertyName] = messageBody[propertyName][0]; + } else { + (newEmail.json.metadata as IDataObject)[propertyName] = messageBody[propertyName][0]; + } + } + } + + if (downloadAttachments) { + // Get attachments and add them if any get found + attachments = await getAttachment(imapConnection, parts, message); + if (attachments.length) { + newEmail.binary = {}; + for (let i = 0; i < attachments.length; i++) { + newEmail.binary[`${dataPropertyAttachmentsPrefixName}${i}`] = attachments[i]; + } + } + } + + newEmails.push(newEmail); + } + } else if (format === 'raw') { + for (const message of results) { + if ( + staticData.lastMessageUid !== undefined && + message.attributes.uid <= (staticData.lastMessageUid as number) + ) { + continue; + } + if ( + staticData.lastMessageUid === undefined || + (staticData.lastMessageUid as number) < message.attributes.uid + ) { + staticData.lastMessageUid = message.attributes.uid; + } + const part = find(message.parts, { which: 'TEXT' }); + + if (part === undefined) { + throw new NodeOperationError(this.getNode(), 'Email part could not be parsed.'); + } + // Return base64 string + newEmail = { + json: { + raw: part.body as string, + }, + }; + + newEmails.push(newEmail); + } + } + + // only mark messages as seen once processing has finished + if (postProcessAction === 'read') { + const uidList = results.map((e) => e.attributes.uid); + if (uidList.length > 0) { + await imapConnection.addFlags(uidList, '\\SEEN'); + } + } + return newEmails; +}