diff --git a/packages/nodes-base/credentials/Imap.credentials.ts b/packages/nodes-base/credentials/Imap.credentials.ts index a36d46ef93..856835dfe4 100644 --- a/packages/nodes-base/credentials/Imap.credentials.ts +++ b/packages/nodes-base/credentials/Imap.credentials.ts @@ -38,5 +38,32 @@ export class Imap implements ICredentialType { type: 'boolean', default: true, }, + { + displayName: 'Allow Self-Signed Certificates', + name: 'allowUnauthorizedCerts', + type: 'boolean', + description: 'Whether to connect even if SSL certificate validation is not possible', + default: false, + }, ]; } + +export interface ICredentialsDataImap { + host: string; + port: number; + user: string; + password: string; + secure: boolean; + allowUnauthorizedCerts: boolean; +} + +export function isCredentialsDataImap(candidate: unknown): candidate is ICredentialsDataImap { + const o = candidate as ICredentialsDataImap; + return ( + o.host !== undefined && + o.password !== undefined && + o.port !== undefined && + o.secure !== undefined && + o.user !== undefined + ); +} diff --git a/packages/nodes-base/nodes/EmailReadImap/EmailReadImap.node.ts b/packages/nodes-base/nodes/EmailReadImap/EmailReadImap.node.ts index 620783ed62..af0c7cb238 100644 --- a/packages/nodes-base/nodes/EmailReadImap/EmailReadImap.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/EmailReadImap.node.ts @@ -1,616 +1,24 @@ -import { ITriggerFunctions } from 'n8n-core'; -import { - createDeferredPromise, - IBinaryData, - IBinaryKeyData, - ICredentialDataDecryptedObject, - ICredentialsDecrypted, - ICredentialTestFunctions, - IDataObject, - IDeferredPromise, - INodeCredentialTestResult, - INodeExecutionData, - INodeType, - INodeTypeDescription, - ITriggerResponse, - LoggerProxy as Logger, - NodeOperationError, -} from 'n8n-workflow'; +import { INodeTypeBaseDescription, INodeVersionedType } from 'n8n-workflow'; +import { NodeVersionedType } from '../../src/NodeVersionedType'; +import { EmailReadImapV1 } from './v1/EmailReadImapV1.node'; +import { EmailReadImapV2 } from './v2/EmailReadImapV2.node'; -import { - connect as imapConnect, - getParts, - ImapSimple, - ImapSimpleOptions, - Message, -} from 'imap-simple'; -import { simpleParser, Source as ParserSource } from 'mailparser'; - -import _ from 'lodash'; - -export class EmailReadImap implements INodeType { - description: INodeTypeDescription = { - displayName: 'Email Trigger (IMAP)', - name: 'emailReadImap', - icon: 'fa:inbox', - group: ['trigger'], - version: 1, - description: 'Triggers the workflow when a new email is received', - eventTriggerDescription: 'Waiting for you to receive an email', - defaults: { - name: 'Email Trigger', - color: '#44AA22', - }, - inputs: [], - outputs: ['main'], - credentials: [ - { - name: 'imap', - required: true, - testedBy: 'imapConnectionTest', - }, - ], - properties: [ - { - displayName: 'Mailbox Name', - name: 'mailbox', - type: 'string', - default: 'INBOX', - }, - { - displayName: 'Action', - name: 'postProcessAction', - type: 'options', - options: [ - { - name: 'Mark as Read', - value: 'read', - }, - { - name: 'Nothing', - value: 'nothing', - }, - ], - default: 'read', - description: - 'What to do after the email has been received. If "nothing" gets selected it will be processed multiple times.', - }, - { - displayName: 'Download Attachments', - name: 'downloadAttachments', - type: 'boolean', - default: false, - displayOptions: { - show: { - format: ['simple'], - }, - }, - description: - 'Whether attachments of emails should be downloaded. Only set if needed as it increases processing.', - }, - { - displayName: 'Format', - name: 'format', - type: 'options', - options: [ - { - name: 'RAW', - value: 'raw', - description: - 'Returns the full email message data with body content in the raw field as a base64url encoded string; the payload field is not used', - }, - { - name: 'Resolved', - value: 'resolved', - description: - 'Returns the full email with all data resolved and attachments saved as binary data', - }, - { - name: 'Simple', - value: 'simple', - description: - 'Returns the full email; do not use if you wish to gather inline attachments', - }, - ], - default: 'simple', - description: 'The format to return the message in', - }, - { - displayName: 'Property Prefix Name', - name: 'dataPropertyAttachmentsPrefixName', - type: 'string', - default: 'attachment_', - displayOptions: { - show: { - format: ['resolved'], - }, - }, - description: - 'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"', - }, - { - displayName: 'Property Prefix Name', - name: 'dataPropertyAttachmentsPrefixName', - type: 'string', - default: 'attachment_', - displayOptions: { - show: { - format: ['simple'], - downloadAttachments: [true], - }, - }, - description: - 'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - options: [ - { - displayName: 'Custom Email Rules', - name: 'customEmailConfig', - type: 'string', - default: '["UNSEEN"]', - description: - 'Custom email fetching rules. See node-imap\'s search function for more details.', - }, - { - displayName: 'Ignore SSL Issues', - name: 'allowUnauthorizedCerts', - type: 'boolean', - default: false, - description: 'Whether to connect even if SSL certificate validation is not possible', - }, - { - displayName: 'Force Reconnect', - name: 'forceReconnect', - type: 'number', - default: 60, - description: 'Sets an interval (in minutes) to force a reconnection', - }, - ], - }, - ], - }; - - methods = { - credentialTest: { - async imapConnectionTest( - this: ICredentialTestFunctions, - credential: ICredentialsDecrypted, - ): Promise { - const credentials = credential.data as ICredentialDataDecryptedObject; - try { - const config: ImapSimpleOptions = { - imap: { - user: credentials.user as string, - password: credentials.password as string, - host: credentials.host as string, - port: credentials.port as number, - tls: credentials.secure as boolean, - authTimeout: 20000, - }, - }; - const tlsOptions: IDataObject = {}; - - if (credentials.secure) { - tlsOptions.servername = credentials.host as string; - } - if (!_.isEmpty(tlsOptions)) { - config.imap.tlsOptions = tlsOptions; - } - const conn = imapConnect(config).then(async (conn) => { - return conn; - }); - (await conn).getBoxes((err, boxes) => {}); - } catch (error) { - console.log(error); - return { - status: 'Error', - message: error.message, - }; - } - return { - status: 'OK', - message: 'Connection successful!', - }; - }, - }, - }; - - async trigger(this: ITriggerFunctions): Promise { - const credentials = await this.getCredentials('imap'); - - const mailbox = this.getNodeParameter('mailbox') as string; - const postProcessAction = this.getNodeParameter('postProcessAction') as string; - const options = this.getNodeParameter('options', {}) as IDataObject; - - const staticData = this.getWorkflowStaticData('node'); - Logger.debug('Loaded static data for node "EmailReadImap"', { staticData }); - - let connection: ImapSimple; - - // Returns the email text - // tslint:disable-next-line:no-any - const getText = async (parts: any[], message: Message, subtype: string) => { - if (!message.attributes.struct) { - return ''; - } - - const textParts = parts.filter((part) => { - return ( - part.type.toUpperCase() === 'TEXT' && part.subtype.toUpperCase() === subtype.toUpperCase() - ); - }); - - if (textParts.length === 0) { - return ''; - } - - try { - return await connection.getPartData(message, textParts[0]); - } catch { - return ''; - } +export class EmailReadImap extends NodeVersionedType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Email Trigger (IMAP)', + name: 'emailReadImap', + icon: 'fa:inbox', + group: ['trigger'], + description: 'Triggers the workflow when a new email is received', + defaultVersion: 2, }; - // Returns the email attachments - const getAttachment = async ( - connection: ImapSimple, - // tslint:disable-next-line:no-any - parts: any[], - message: Message, - ): Promise => { - if (!message.attributes.struct) { - return []; - } - - // Check if the message has attachments and if so get them - const attachmentParts = parts.filter((part) => { - return part.disposition && part.disposition.type.toUpperCase() === 'ATTACHMENT'; - }); - - const attachmentPromises = []; - let attachmentPromise; - for (const attachmentPart of attachmentParts) { - attachmentPromise = connection.getPartData(message, attachmentPart).then((partData) => { - // Return it in the format n8n expects - return this.helpers.prepareBinaryData( - partData, - attachmentPart.disposition.params.filename, - ); - }); - - attachmentPromises.push(attachmentPromise); - } - - return Promise.all(attachmentPromises); + const nodeVersions: INodeVersionedType['nodeVersions'] = { + 1: new EmailReadImapV1(baseDescription), + 2: new EmailReadImapV2(baseDescription), }; - // Returns all the new unseen messages - const getNewEmails = async ( - connection: ImapSimple, - searchCriteria: Array, - ): Promise => { - const format = this.getNodeParameter('format', 0) as string; - - let fetchOptions = {}; - - if (format === 'simple' || format === 'raw') { - fetchOptions = { - bodies: ['TEXT', 'HEADER'], - markSeen: postProcessAction === 'read', - struct: true, - }; - } else if (format === 'resolved') { - fetchOptions = { - bodies: [''], - markSeen: postProcessAction === 'read', - struct: true, - }; - } - - const results = await connection.search(searchCriteria, fetchOptions); - - const newEmails: INodeExecutionData[] = []; - let newEmail: INodeExecutionData, messageHeader, messageBody; - 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, - dataPropertyAttachmentsPrefixName, - ); - - newEmails.push(parsedEmail); - } - } else if (format === 'simple') { - const downloadAttachments = this.getNodeParameter('downloadAttachments') as boolean; - - let dataPropertyAttachmentsPrefixName = ''; - if (downloadAttachments === true) { - 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!); - - newEmail = { - json: { - textHtml: await getText(parts, message, 'html'), - textPlain: await getText(parts, message, 'plain'), - metadata: {} as IDataObject, - }, - }; - - messageHeader = message.parts.filter((part) => { - return part.which === 'HEADER'; - }); - - messageBody = messageHeader[0].body; - 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 === true) { - // Get attachments and add them if any get found - attachments = await getAttachment(connection, 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, - }, - }; - - newEmails.push(newEmail); - } - } - - return newEmails; - }; - - const returnedPromise: IDeferredPromise | undefined = await createDeferredPromise(); - - const establishConnection = (): Promise => { - let searchCriteria = ['UNSEEN'] as Array; - if (options.customEmailConfig !== undefined) { - try { - searchCriteria = JSON.parse(options.customEmailConfig as string); - } catch (error) { - throw new NodeOperationError(this.getNode(), `Custom email config is not valid JSON.`); - } - } - - const config: ImapSimpleOptions = { - imap: { - user: credentials.user as string, - password: credentials.password as string, - host: credentials.host as string, - port: credentials.port as number, - tls: credentials.secure as boolean, - authTimeout: 20000, - }, - onmail: async () => { - if (connection) { - if (staticData.lastMessageUid !== undefined) { - searchCriteria.push(['UID', `${staticData.lastMessageUid as number}:*`]); - /** - * A short explanation about UIDs and how they work - * can be found here: https://dev.to/kehers/imap-new-messages-since-last-check-44gm - * TL;DR: - * - You cannot filter using ['UID', 'CURRENT ID + 1:*'] because IMAP - * won't return correct results if current id + 1 does not yet exist. - * - UIDs can change but this is not being treated here. - * If the mailbox is recreated (lets say you remove all emails, remove - * the mail box and create another with same name, UIDs will change) - * - You can check if UIDs changed in the above example - * by checking UIDValidity. - */ - Logger.debug('Querying for new messages on node "EmailReadImap"', { searchCriteria }); - } - - try { - const returnData = await getNewEmails(connection, searchCriteria); - if (returnData.length) { - this.emit([returnData]); - } - } catch (error) { - Logger.error('Email Read Imap node encountered an error fetching new emails', { - error, - }); - // Wait with resolving till the returnedPromise got resolved, else n8n will be unhappy - // if it receives an error before the workflow got activated - returnedPromise.promise().then(() => { - this.emitError(error as Error); - }); - } - } - }, - }; - - const tlsOptions: IDataObject = {}; - - if (options.allowUnauthorizedCerts === true) { - tlsOptions.rejectUnauthorized = false; - } - - if (credentials.secure) { - tlsOptions.servername = credentials.host as string; - } - - if (!_.isEmpty(tlsOptions)) { - config.imap.tlsOptions = tlsOptions; - } - - // Connect to the IMAP server and open the mailbox - // that we get informed whenever a new email arrives - return imapConnect(config).then(async (conn) => { - conn.on('error', async (error) => { - const errorCode = error.code.toUpperCase(); - if (['ECONNRESET', 'EPIPE'].includes(errorCode)) { - Logger.verbose(`IMAP connection was reset (${errorCode}) - reconnecting.`, { error }); - try { - connection = await establishConnection(); - await connection.openBox(mailbox); - return; - } catch (e) { - Logger.error('IMAP reconnect did fail', { error: e }); - // If something goes wrong we want to run emitError - } - } else { - Logger.error('Email Read Imap node encountered a connection error', { error }); - this.emitError(error); - } - }); - return conn; - }); - }; - - connection = await establishConnection(); - - await connection.openBox(mailbox); - - let reconnectionInterval: NodeJS.Timeout | undefined; - - if (options.forceReconnect !== undefined) { - reconnectionInterval = setInterval(async () => { - Logger.verbose('Forcing reconnection of IMAP node.'); - await connection.end(); - connection = await establishConnection(); - await connection.openBox(mailbox); - }, (options.forceReconnect as number) * 1000 * 60); - } - - // When workflow and so node gets set to inactive close the connectoin - async function closeFunction() { - if (reconnectionInterval) { - clearInterval(reconnectionInterval); - } - await connection.end(); - } - - // Resolve returned-promise so that waiting errors can be emitted - returnedPromise.resolve(); - - return { - closeFunction, - }; + super(nodeVersions, baseDescription); } } - -export async function parseRawEmail( - this: ITriggerFunctions, - messageEncoded: ParserSource, - dataPropertyNameDownload: string, -): Promise { - const responseData = await simpleParser(messageEncoded); - const headers: IDataObject = {}; - for (const header of responseData.headerLines) { - headers[header.key] = header.line; - } - - // @ts-ignore - responseData.headers = headers; - // @ts-ignore - responseData.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, - ); - } - // @ts-ignore - responseData.attachments = undefined; - } - - return { - json: responseData as unknown as IDataObject, - binary: Object.keys(binaryData).length ? binaryData : undefined, - } as INodeExecutionData; -} diff --git a/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts b/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts new file mode 100644 index 0000000000..bee4de0099 --- /dev/null +++ b/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts @@ -0,0 +1,628 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { ITriggerFunctions } from 'n8n-core'; +import { + createDeferredPromise, + IBinaryData, + IBinaryKeyData, + ICredentialDataDecryptedObject, + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + IDeferredPromise, + INodeCredentialTestResult, + INodeExecutionData, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + ITriggerResponse, + LoggerProxy as Logger, + NodeOperationError, +} from 'n8n-workflow'; + +import { + connect as imapConnect, + getParts, + ImapSimple, + ImapSimpleOptions, + Message, +} from 'imap-simple'; +import { simpleParser, Source as ParserSource } from 'mailparser'; + +import _ from 'lodash'; + +const versionDescription: INodeTypeDescription = { + displayName: 'Email Trigger (IMAP)', + name: 'emailReadImap', + icon: 'fa:inbox', + group: ['trigger'], + version: 1, + description: 'Triggers the workflow when a new email is received', + eventTriggerDescription: 'Waiting for you to receive an email', + defaults: { + name: 'IMAP Email', + color: '#44AA22', + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'imap', + required: true, + testedBy: 'imapConnectionTest', + }, + ], + properties: [ + { + displayName: 'Mailbox Name', + name: 'mailbox', + type: 'string', + default: 'INBOX', + }, + { + displayName: 'Action', + name: 'postProcessAction', + type: 'options', + options: [ + { + name: 'Mark as Read', + value: 'read', + }, + { + name: 'Nothing', + value: 'nothing', + }, + ], + default: 'read', + description: + 'What to do after the email has been received. If "nothing" gets selected it will be processed multiple times.', + }, + { + displayName: 'Download Attachments', + name: 'downloadAttachments', + type: 'boolean', + default: false, + displayOptions: { + show: { + format: ['simple'], + }, + }, + description: + 'Whether attachments of emails should be downloaded. Only set if needed as it increases processing.', + }, + { + displayName: 'Format', + name: 'format', + type: 'options', + options: [ + { + name: 'RAW', + value: 'raw', + description: + 'Returns the full email message data with body content in the raw field as a base64url encoded string; the payload field is not used', + }, + { + name: 'Resolved', + value: 'resolved', + description: + 'Returns the full email with all data resolved and attachments saved as binary data', + }, + { + name: 'Simple', + value: 'simple', + description: + 'Returns the full email; do not use if you wish to gather inline attachments', + }, + ], + default: 'simple', + description: 'The format to return the message in', + }, + { + displayName: 'Property Prefix Name', + name: 'dataPropertyAttachmentsPrefixName', + type: 'string', + default: 'attachment_', + displayOptions: { + show: { + format: ['resolved'], + }, + }, + description: + 'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"', + }, + { + displayName: 'Property Prefix Name', + name: 'dataPropertyAttachmentsPrefixName', + type: 'string', + default: 'attachment_', + displayOptions: { + show: { + format: ['simple'], + downloadAttachments: [true], + }, + }, + description: + 'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Custom Email Rules', + name: 'customEmailConfig', + type: 'string', + default: '["UNSEEN"]', + description: + 'Custom email fetching rules. See node-imap\'s search function for more details.', + }, + { + displayName: 'Ignore SSL Issues', + name: 'allowUnauthorizedCerts', + type: 'boolean', + default: false, + description: 'Whether to connect even if SSL certificate validation is not possible', + }, + { + displayName: 'Force Reconnect', + name: 'forceReconnect', + type: 'number', + default: 60, + description: 'Sets an interval (in minutes) to force a reconnection', + }, + ], + }, + ], +}; + +export class EmailReadImapV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + credentialTest: { + async imapConnectionTest( + this: ICredentialTestFunctions, + credential: ICredentialsDecrypted, + ): Promise { + const credentials = credential.data as ICredentialDataDecryptedObject; + try { + const config: ImapSimpleOptions = { + imap: { + user: credentials.user as string, + password: credentials.password as string, + host: credentials.host as string, + port: credentials.port as number, + tls: credentials.secure as boolean, + authTimeout: 20000, + }, + }; + const tlsOptions: IDataObject = {}; + + if (credentials.secure) { + tlsOptions.servername = credentials.host as string; + } + if (!_.isEmpty(tlsOptions)) { + config.imap.tlsOptions = tlsOptions; + } + const conn = imapConnect(config).then(async (conn) => { + return conn; + }); + (await conn).getBoxes((err, boxes) => {}); + } catch (error) { + console.log(error); + return { + status: 'Error', + message: error.message, + }; + } + return { + status: 'OK', + message: 'Connection successful!', + }; + }, + }, + }; + + async trigger(this: ITriggerFunctions): Promise { + const credentials = await this.getCredentials('imap'); + + const mailbox = this.getNodeParameter('mailbox') as string; + const postProcessAction = this.getNodeParameter('postProcessAction') as string; + const options = this.getNodeParameter('options', {}) as IDataObject; + + const staticData = this.getWorkflowStaticData('node'); + Logger.debug('Loaded static data for node "EmailReadImap"', { staticData }); + + let connection: ImapSimple; + + // Returns the email text + // tslint:disable-next-line:no-any + const getText = async (parts: any[], message: Message, subtype: string) => { + if (!message.attributes.struct) { + return ''; + } + + const textParts = parts.filter((part) => { + return ( + part.type.toUpperCase() === 'TEXT' && part.subtype.toUpperCase() === subtype.toUpperCase() + ); + }); + + if (textParts.length === 0) { + return ''; + } + + try { + return await connection.getPartData(message, textParts[0]); + } catch { + return ''; + } + }; + + // Returns the email attachments + const getAttachment = async ( + connection: ImapSimple, + // tslint:disable-next-line:no-any + parts: any[], + message: Message, + ): Promise => { + if (!message.attributes.struct) { + return []; + } + + // Check if the message has attachments and if so get them + const attachmentParts = parts.filter((part) => { + return part.disposition && part.disposition.type.toUpperCase() === 'ATTACHMENT'; + }); + + const attachmentPromises = []; + let attachmentPromise; + for (const attachmentPart of attachmentParts) { + attachmentPromise = connection.getPartData(message, attachmentPart).then((partData) => { + // Return it in the format n8n expects + return this.helpers.prepareBinaryData( + partData, + attachmentPart.disposition.params.filename, + ); + }); + + attachmentPromises.push(attachmentPromise); + } + + return Promise.all(attachmentPromises); + }; + + // Returns all the new unseen messages + const getNewEmails = async ( + connection: ImapSimple, + searchCriteria: Array, + ): Promise => { + const format = this.getNodeParameter('format', 0) as string; + + let fetchOptions = {}; + + if (format === 'simple' || format === 'raw') { + fetchOptions = { + bodies: ['TEXT', 'HEADER'], + markSeen: postProcessAction === 'read', + struct: true, + }; + } else if (format === 'resolved') { + fetchOptions = { + bodies: [''], + markSeen: postProcessAction === 'read', + struct: true, + }; + } + + const results = await connection.search(searchCriteria, fetchOptions); + + const newEmails: INodeExecutionData[] = []; + let newEmail: INodeExecutionData, messageHeader, messageBody; + 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, + dataPropertyAttachmentsPrefixName, + ); + + newEmails.push(parsedEmail); + } + } else if (format === 'simple') { + const downloadAttachments = this.getNodeParameter('downloadAttachments') as boolean; + + let dataPropertyAttachmentsPrefixName = ''; + if (downloadAttachments === true) { + 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!); + + newEmail = { + json: { + textHtml: await getText(parts, message, 'html'), + textPlain: await getText(parts, message, 'plain'), + metadata: {} as IDataObject, + }, + }; + + messageHeader = message.parts.filter((part) => { + return part.which === 'HEADER'; + }); + + messageBody = messageHeader[0].body; + 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 === true) { + // Get attachments and add them if any get found + attachments = await getAttachment(connection, 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, + }, + }; + + newEmails.push(newEmail); + } + } + + return newEmails; + }; + + const returnedPromise: IDeferredPromise | undefined = await createDeferredPromise(); + + const establishConnection = (): Promise => { + let searchCriteria = ['UNSEEN'] as Array; + if (options.customEmailConfig !== undefined) { + try { + searchCriteria = JSON.parse(options.customEmailConfig as string); + } catch (error) { + throw new NodeOperationError(this.getNode(), `Custom email config is not valid JSON.`); + } + } + + const config: ImapSimpleOptions = { + imap: { + user: credentials.user as string, + password: credentials.password as string, + host: credentials.host as string, + port: credentials.port as number, + tls: credentials.secure as boolean, + authTimeout: 20000, + }, + onmail: async () => { + if (connection) { + if (staticData.lastMessageUid !== undefined) { + searchCriteria.push(['UID', `${staticData.lastMessageUid as number}:*`]); + /** + * A short explanation about UIDs and how they work + * can be found here: https://dev.to/kehers/imap-new-messages-since-last-check-44gm + * TL;DR: + * - You cannot filter using ['UID', 'CURRENT ID + 1:*'] because IMAP + * won't return correct results if current id + 1 does not yet exist. + * - UIDs can change but this is not being treated here. + * If the mailbox is recreated (lets say you remove all emails, remove + * the mail box and create another with same name, UIDs will change) + * - You can check if UIDs changed in the above example + * by checking UIDValidity. + */ + Logger.debug('Querying for new messages on node "EmailReadImap"', { searchCriteria }); + } + + try { + const returnData = await getNewEmails(connection, searchCriteria); + if (returnData.length) { + this.emit([returnData]); + } + } catch (error) { + Logger.error('Email Read Imap node encountered an error fetching new emails', { + error, + }); + // Wait with resolving till the returnedPromise got resolved, else n8n will be unhappy + // if it receives an error before the workflow got activated + returnedPromise.promise().then(() => { + this.emitError(error as Error); + }); + } + } + }, + }; + + const tlsOptions: IDataObject = {}; + + if (options.allowUnauthorizedCerts === true) { + tlsOptions.rejectUnauthorized = false; + } + + if (credentials.secure) { + tlsOptions.servername = credentials.host as string; + } + + if (!_.isEmpty(tlsOptions)) { + config.imap.tlsOptions = tlsOptions; + } + + // Connect to the IMAP server and open the mailbox + // that we get informed whenever a new email arrives + return imapConnect(config).then(async (conn) => { + conn.on('error', async (error) => { + const errorCode = error.code.toUpperCase(); + if (['ECONNRESET', 'EPIPE'].includes(errorCode)) { + Logger.verbose(`IMAP connection was reset (${errorCode}) - reconnecting.`, { error }); + try { + connection = await establishConnection(); + await connection.openBox(mailbox); + return; + } catch (e) { + Logger.error('IMAP reconnect did fail', { error: e }); + // If something goes wrong we want to run emitError + } + } else { + Logger.error('Email Read Imap node encountered a connection error', { error }); + this.emitError(error); + } + }); + return conn; + }); + }; + + connection = await establishConnection(); + + await connection.openBox(mailbox); + + let reconnectionInterval: NodeJS.Timeout | undefined; + + if (options.forceReconnect !== undefined) { + reconnectionInterval = setInterval(async () => { + Logger.verbose('Forcing reconnection of IMAP node.'); + await connection.end(); + connection = await establishConnection(); + await connection.openBox(mailbox); + }, (options.forceReconnect as number) * 1000 * 60); + } + + // When workflow and so node gets set to inactive close the connectoin + async function closeFunction() { + if (reconnectionInterval) { + clearInterval(reconnectionInterval); + } + await connection.end(); + } + + // Resolve returned-promise so that waiting errors can be emitted + returnedPromise.resolve(); + + return { + closeFunction, + }; + } +} + +export async function parseRawEmail( + this: ITriggerFunctions, + messageEncoded: ParserSource, + dataPropertyNameDownload: string, +): Promise { + const responseData = await simpleParser(messageEncoded); + const headers: IDataObject = {}; + for (const header of responseData.headerLines) { + headers[header.key] = header.line; + } + + // @ts-ignore + responseData.headers = headers; + // @ts-ignore + responseData.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, + ); + } + // @ts-ignore + responseData.attachments = undefined; + } + + return { + json: responseData as unknown as IDataObject, + binary: Object.keys(binaryData).length ? binaryData : undefined, + } as INodeExecutionData; +} diff --git a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts new file mode 100644 index 0000000000..e8ec50d66a --- /dev/null +++ b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts @@ -0,0 +1,654 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { ITriggerFunctions } from 'n8n-core'; +import { + createDeferredPromise, + IBinaryData, + IBinaryKeyData, + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + IDeferredPromise, + INodeCredentialTestResult, + INodeExecutionData, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + ITriggerResponse, + LoggerProxy as Logger, + NodeOperationError, +} from 'n8n-workflow'; + +import { + connect as imapConnect, + getParts, + ImapSimple, + ImapSimpleOptions, + Message, +} from 'imap-simple'; +import { simpleParser, Source as ParserSource } from 'mailparser'; + +import _ from 'lodash'; +import { ICredentialsDataImap, isCredentialsDataImap } from '../../../credentials/Imap.credentials'; + +const versionDescription: INodeTypeDescription = { + displayName: 'Email Trigger (IMAP)', + name: 'emailReadImap', + icon: 'fa:inbox', + group: ['trigger'], + version: 2, + description: 'Triggers the workflow when a new email is received', + eventTriggerDescription: 'Waiting for you to receive an email', + defaults: { + name: 'IMAP Email', + color: '#44AA22', + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'imap', + required: true, + testedBy: 'imapConnectionTest', + }, + ], + properties: [ + { + displayName: 'Mailbox Name', + name: 'mailbox', + type: 'string', + default: 'INBOX', + }, + { + displayName: 'Action', + name: 'postProcessAction', + type: 'options', + options: [ + { + name: 'Mark as Read', + value: 'read', + }, + { + name: 'Nothing', + value: 'nothing', + }, + ], + default: 'read', + description: + 'What to do after the email has been received. If "nothing" gets selected it will be processed multiple times.', + }, + { + displayName: 'Download Attachments', + name: 'downloadAttachments', + type: 'boolean', + default: false, + displayOptions: { + show: { + format: ['simple'], + }, + }, + description: + 'Whether attachments of emails should be downloaded. Only set if needed as it increases processing.', + }, + { + displayName: 'Format', + name: 'format', + type: 'options', + options: [ + { + name: 'RAW', + value: 'raw', + description: + 'Returns the full email message data with body content in the raw field as a base64url encoded string; the payload field is not used', + }, + { + name: 'Resolved', + value: 'resolved', + description: + 'Returns the full email with all data resolved and attachments saved as binary data', + }, + { + name: 'Simple', + value: 'simple', + description: + 'Returns the full email; do not use if you wish to gather inline attachments', + }, + ], + default: 'simple', + description: 'The format to return the message in', + }, + { + displayName: 'Property Prefix Name', + name: 'dataPropertyAttachmentsPrefixName', + type: 'string', + default: 'attachment_', + displayOptions: { + show: { + format: ['resolved'], + }, + }, + description: + 'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"', + }, + { + displayName: 'Property Prefix Name', + name: 'dataPropertyAttachmentsPrefixName', + type: 'string', + default: 'attachment_', + displayOptions: { + show: { + format: ['simple'], + downloadAttachments: [true], + }, + }, + description: + 'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Custom Email Rules', + name: 'customEmailConfig', + type: 'string', + default: '["UNSEEN"]', + description: + 'Custom email fetching rules. See node-imap\'s search function for more details.', + }, + { + displayName: 'Force Reconnect Every Minutes', + name: 'forceReconnect', + type: 'number', + default: 60, + description: 'Sets an interval (in minutes) to force a reconnection', + }, + ], + }, + ], +}; + +export class EmailReadImapV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + credentialTest: { + async imapConnectionTest( + this: ICredentialTestFunctions, + credential: ICredentialsDecrypted, + ): Promise { + if (isCredentialsDataImap(credential.data)) { + const credentials = credential.data as ICredentialsDataImap; + try { + const config: ImapSimpleOptions = { + imap: { + user: credentials.user, + password: credentials.password, + host: credentials.host, + port: credentials.port, + tls: credentials.secure, + authTimeout: 20000, + }, + }; + const tlsOptions: IDataObject = {}; + + if (credentials.allowUnauthorizedCerts === true) { + tlsOptions.rejectUnauthorized = false; + } + + if (credentials.secure) { + tlsOptions.servername = credentials.host; + } + if (!_.isEmpty(tlsOptions)) { + config.imap.tlsOptions = tlsOptions; + } + const connection = await imapConnect(config); + await connection.getBoxes(); + connection.end(); + } catch (error) { + return { + status: 'Error', + message: error.message, + }; + } + return { + status: 'OK', + message: 'Connection successful!', + }; + } else { + return { + status: 'Error', + message: 'Credentials are no IMAP credentials.', + }; + } + }, + }, + }; + + async trigger(this: ITriggerFunctions): Promise { + const credentialsObject = await this.getCredentials('imap'); + const credentials = isCredentialsDataImap(credentialsObject) ? credentialsObject : undefined; + if (!credentials) { + throw new NodeOperationError(this.getNode(), `Credentials are not valid for imap node.`); + } + const mailbox = this.getNodeParameter('mailbox') as string; + const postProcessAction = this.getNodeParameter('postProcessAction') as string; + const options = this.getNodeParameter('options', {}) as IDataObject; + + const staticData = this.getWorkflowStaticData('node'); + Logger.debug('Loaded static data for node "EmailReadImap"', { staticData }); + + let connection: ImapSimple; + let closeFunctionWasCalled = false; + let isCurrentlyReconnecting = false; + + // Returns the email text + // tslint:disable-next-line:no-any + const getText = async (parts: any[], message: Message, subtype: string) => { + if (!message.attributes.struct) { + return ''; + } + + const textParts = parts.filter((part) => { + return ( + part.type.toUpperCase() === 'TEXT' && part.subtype.toUpperCase() === subtype.toUpperCase() + ); + }); + + if (textParts.length === 0) { + return ''; + } + + try { + return await connection.getPartData(message, textParts[0]); + } catch { + return ''; + } + }; + + // Returns the email attachments + const getAttachment = async ( + connection: ImapSimple, + // tslint:disable-next-line:no-any + parts: any[], + message: Message, + ): Promise => { + if (!message.attributes.struct) { + return []; + } + + // Check if the message has attachments and if so get them + const attachmentParts = parts.filter((part) => { + return part.disposition && part.disposition.type.toUpperCase() === 'ATTACHMENT'; + }); + + const attachmentPromises = []; + let attachmentPromise; + for (const attachmentPart of attachmentParts) { + attachmentPromise = connection.getPartData(message, attachmentPart).then((partData) => { + // Return it in the format n8n expects + return this.helpers.prepareBinaryData( + partData, + attachmentPart.disposition.params.filename, + ); + }); + + attachmentPromises.push(attachmentPromise); + } + + return Promise.all(attachmentPromises); + }; + + // Returns all the new unseen messages + const getNewEmails = async ( + connection: 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 connection.search(searchCriteria, fetchOptions); + + const newEmails: INodeExecutionData[] = []; + let newEmail: INodeExecutionData, messageHeader, messageBody; + 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, + dataPropertyAttachmentsPrefixName, + ); + + newEmails.push(parsedEmail); + } + } else if (format === 'simple') { + const downloadAttachments = this.getNodeParameter('downloadAttachments') as boolean; + + let dataPropertyAttachmentsPrefixName = ''; + if (downloadAttachments === true) { + 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!); + + newEmail = { + json: { + textHtml: await getText(parts, message, 'html'), + textPlain: await getText(parts, message, 'plain'), + metadata: {} as IDataObject, + }, + }; + + messageHeader = message.parts.filter((part) => { + return part.which === 'HEADER'; + }); + + messageBody = messageHeader[0].body; + 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 === true) { + // Get attachments and add them if any get found + attachments = await getAttachment(connection, 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, + }, + }; + + 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) { + connection.addFlags(uidList, '\\SEEN'); + } + } + return newEmails; + }; + + const returnedPromise: IDeferredPromise | undefined = await createDeferredPromise(); + + const establishConnection = (): Promise => { + let searchCriteria = ['UNSEEN'] as Array; + if (options.customEmailConfig !== undefined) { + try { + searchCriteria = JSON.parse(options.customEmailConfig as string); + } catch (error) { + throw new NodeOperationError(this.getNode(), `Custom email config is not valid JSON.`); + } + } + + const config: ImapSimpleOptions = { + imap: { + user: credentials.user, + password: credentials.password, + host: credentials.host, + port: credentials.port, + tls: credentials.secure, + authTimeout: 20000, + }, + onmail: async () => { + if (connection) { + if (staticData.lastMessageUid !== undefined) { + searchCriteria.push(['UID', `${staticData.lastMessageUid as number}:*`]); + /** + * A short explanation about UIDs and how they work + * can be found here: https://dev.to/kehers/imap-new-messages-since-last-check-44gm + * TL;DR: + * - You cannot filter using ['UID', 'CURRENT ID + 1:*'] because IMAP + * won't return correct results if current id + 1 does not yet exist. + * - UIDs can change but this is not being treated here. + * If the mailbox is recreated (lets say you remove all emails, remove + * the mail box and create another with same name, UIDs will change) + * - You can check if UIDs changed in the above example + * by checking UIDValidity. + */ + Logger.debug('Querying for new messages on node "EmailReadImap"', { searchCriteria }); + } + + try { + const returnData = await getNewEmails(connection, searchCriteria); + if (returnData.length) { + this.emit([returnData]); + } + } catch (error) { + Logger.error('Email Read Imap node encountered an error fetching new emails', { + error, + }); + // Wait with resolving till the returnedPromise got resolved, else n8n will be unhappy + // if it receives an error before the workflow got activated + returnedPromise.promise().then(() => { + this.emitError(error as Error); + }); + } + } + }, + onupdate: async (seqno: number, info) => { + Logger.verbose(`Email Read Imap:update ${seqno}`, info); + }, + }; + + const tlsOptions: IDataObject = {}; + + if (credentials.allowUnauthorizedCerts === true) { + tlsOptions.rejectUnauthorized = false; + } + + if (credentials.secure) { + tlsOptions.servername = credentials.host as string; + } + + if (!_.isEmpty(tlsOptions)) { + config.imap.tlsOptions = tlsOptions; + } + + // Connect to the IMAP server and open the mailbox + // that we get informed whenever a new email arrives + return imapConnect(config).then(async (conn) => { + conn.on('close', async (hadError: boolean) => { + if (isCurrentlyReconnecting === true) { + Logger.debug(`Email Read Imap: Connected closed for forced reconnecting`); + } else if (closeFunctionWasCalled === true) { + Logger.debug(`Email Read Imap: Shutting down workflow - connected closed`); + } else { + Logger.error(`Email Read Imap: Connected closed unexpectedly`); + this.emitError(new Error('Imap connection closed unexpectedly')); + } + }); + conn.on('error', async (error) => { + const errorCode = error.code.toUpperCase(); + Logger.verbose(`IMAP connection experienced an error: (${errorCode})`, { error }); + await closeFunction(); + this.emitError(error); + }); + return conn; + }); + }; + + connection = await establishConnection(); + + await connection.openBox(mailbox); + + let reconnectionInterval: NodeJS.Timeout | undefined; + + if (options.forceReconnect !== undefined) { + reconnectionInterval = setInterval(async () => { + Logger.verbose(`Forcing reconnect to IMAP server`); + try { + isCurrentlyReconnecting = true; + if (connection.closeBox) connection.closeBox(false); + connection.end(); + connection = await establishConnection(); + await connection.openBox(mailbox); + } catch (error) { + Logger.error(error); + } finally { + isCurrentlyReconnecting = false; + } + }, (options.forceReconnect as number) * 1000 * 60); + } + + // When workflow and so node gets set to inactive close the connectoin + async function closeFunction() { + closeFunctionWasCalled = true; + if (reconnectionInterval) { + clearInterval(reconnectionInterval); + } + if (connection.closeBox) connection.closeBox(false); + connection.end(); + } + + // Resolve returned-promise so that waiting errors can be emitted + returnedPromise.resolve(); + + return { + closeFunction, + }; + } +} + +export async function parseRawEmail( + this: ITriggerFunctions, + messageEncoded: ParserSource, + dataPropertyNameDownload: string, +): Promise { + const responseData = await simpleParser(messageEncoded); + const headers: IDataObject = {}; + for (const header of responseData.headerLines) { + headers[header.key] = header.line; + } + + // @ts-ignore + responseData.headers = headers; + // @ts-ignore + responseData.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, + ); + } + // @ts-ignore + responseData.attachments = undefined; + } + + return { + json: responseData as unknown as IDataObject, + binary: Object.keys(binaryData).length ? binaryData : undefined, + } as INodeExecutionData; +}