mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
7ce7285f7a
* Changes to types so that credentials can be always loaded from DB This first commit changes all return types from the execute functions and calls to get credentials to be async so we can use await. This is a first step as previously credentials were loaded in memory and always available. We will now be loading them from the DB which requires turning the whole call chain async. * Fix updated files * Removed unnecessary credential loading to improve performance * Fix typo * ⚡ Fix issue * Updated new nodes to load credentials async * ⚡ Remove not needed comment Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
505 lines
15 KiB
TypeScript
505 lines
15 KiB
TypeScript
import { ITriggerFunctions } from 'n8n-core';
|
|
import {
|
|
IBinaryData,
|
|
IBinaryKeyData,
|
|
IDataObject,
|
|
INodeExecutionData,
|
|
INodeType,
|
|
INodeTypeDescription,
|
|
ITriggerResponse,
|
|
NodeOperationError,
|
|
} from 'n8n-workflow';
|
|
|
|
import {
|
|
connect as imapConnect,
|
|
getParts,
|
|
ImapSimple,
|
|
ImapSimpleOptions,
|
|
Message,
|
|
} from 'imap-simple';
|
|
import {
|
|
simpleParser,
|
|
Source as ParserSource,
|
|
} from 'mailparser';
|
|
|
|
import * as lodash from 'lodash';
|
|
|
|
import {
|
|
LoggerProxy as Logger
|
|
} from 'n8n-workflow';
|
|
|
|
export class EmailReadImap implements INodeType {
|
|
description: INodeTypeDescription = {
|
|
displayName: 'EmailReadImap',
|
|
name: 'emailReadImap',
|
|
icon: 'fa:inbox',
|
|
group: ['trigger'],
|
|
version: 1,
|
|
description: 'Triggers the workflow when a new email is received',
|
|
defaults: {
|
|
name: 'IMAP Email',
|
|
color: '#44AA22',
|
|
},
|
|
inputs: [],
|
|
outputs: ['main'],
|
|
credentials: [
|
|
{
|
|
name: 'imap',
|
|
required: true,
|
|
},
|
|
],
|
|
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<br />selected it will be processed multiple times.',
|
|
},
|
|
{
|
|
displayName: 'Download Attachments',
|
|
name: 'downloadAttachments',
|
|
type: 'boolean',
|
|
default: false,
|
|
displayOptions: {
|
|
show: {
|
|
format: [
|
|
'simple',
|
|
],
|
|
},
|
|
},
|
|
description: 'If attachments of emails should be downloaded.<br />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<br />write the attachments. An index starting with 0 will be added.<br />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<br />write the attachments. An index starting with 0 will be added.<br />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 <a href="https://github.com/mscdex/node-imap">node-imap</a>\'s search function for more details',
|
|
},
|
|
{
|
|
displayName: 'Ignore SSL Issues',
|
|
name: 'allowUnauthorizedCerts',
|
|
type: 'boolean',
|
|
default: false,
|
|
description: 'Do 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.',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
|
|
|
|
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
|
|
const credentials = await this.getCredentials('imap');
|
|
|
|
if (credentials === undefined) {
|
|
throw new NodeOperationError(this.getNode(), 'No credentials got returned!');
|
|
}
|
|
|
|
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});
|
|
|
|
// Returns the email text
|
|
const getText = async (parts: any[], message: Message, subtype: string) => { // tslint:disable-line:no-any
|
|
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, parts: any[], message: Message): Promise<IBinaryData[]> => { // tslint:disable-line:no-any
|
|
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<string | string[]>): Promise<INodeExecutionData[]> => {
|
|
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 = lodash.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 = lodash.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 establishConnection = (): Promise<ImapSimple> => {
|
|
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) {
|
|
let searchCriteria = [
|
|
'UNSEEN',
|
|
] as Array<string | string[]>;
|
|
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.`);
|
|
}
|
|
}
|
|
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});
|
|
}
|
|
|
|
const returnData = await getNewEmails(connection, searchCriteria);
|
|
|
|
if (returnData.length) {
|
|
this.emit([returnData]);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
if (options.allowUnauthorizedCerts === true) {
|
|
config.imap.tlsOptions = {
|
|
rejectUnauthorized: false,
|
|
};
|
|
}
|
|
|
|
// 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 err => {
|
|
if (err.code.toUpperCase() === 'ECONNRESET') {
|
|
Logger.verbose('IMAP connection was reset - reconnecting.');
|
|
connection = await establishConnection();
|
|
await connection.openBox(mailbox);
|
|
}
|
|
throw err;
|
|
});
|
|
return conn;
|
|
});
|
|
};
|
|
|
|
let connection: ImapSimple = 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();
|
|
}
|
|
|
|
return {
|
|
closeFunction,
|
|
};
|
|
|
|
}
|
|
}
|
|
|
|
export async function parseRawEmail(this: ITriggerFunctions, messageEncoded: ParserSource, dataPropertyNameDownload: string): Promise<INodeExecutionData> {
|
|
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;
|
|
}
|