feat(Send Email Node): New operation sendAndWait (#12775)

This commit is contained in:
Michael Kret
2025-01-24 15:59:43 +02:00
committed by GitHub
parent d48cc36061
commit a197fbb21b
14 changed files with 261 additions and 111 deletions

View File

@@ -135,7 +135,6 @@ describe('Node Creator', () => {
'OpenThesaurus',
'Spontit',
'Vonage',
'Send Email',
'Toggl Trigger',
];
const doubleActionNode = 'OpenWeatherMap';

View File

@@ -0,0 +1,68 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import { SEND_AND_WAIT_OPERATION, type IExecuteFunctions } from 'n8n-workflow';
import { EmailSendV2, versionDescription } from '../../v2/EmailSendV2.node';
import * as utils from '../../v2/utils';
const transporter = { sendMail: jest.fn() };
jest.mock('../../v2/utils', () => {
const originalModule = jest.requireActual('../../v2/utils');
return {
...originalModule,
configureTransport: jest.fn(() => transporter),
};
});
describe('Test EmailSendV2, email => sendAndWait', () => {
let emailSendV2: EmailSendV2;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
emailSendV2 = new EmailSendV2(versionDescription);
mockExecuteFunctions = mock<IExecuteFunctions>();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should send message and put execution to wait', async () => {
const items = [{ json: { data: 'test' } }];
//node
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION);
//operation
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('from@mail.com');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('to@mail.com');
mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId');
mockExecuteFunctions.getCredentials.mockResolvedValue({});
mockExecuteFunctions.putExecutionToWait.mockImplementation();
mockExecuteFunctions.getInputData.mockReturnValue(items);
//getSendAndWaitConfig
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({});
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval');
// configureWaitTillDate
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); //options.limitWaitTime.values
const result = await emailSendV2.execute.call(mockExecuteFunctions);
expect(result).toEqual([items]);
expect(utils.configureTransport).toHaveBeenCalledTimes(1);
expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1);
expect(transporter.sendMail).toHaveBeenCalledWith({
from: 'from@mail.com',
html: expect.stringContaining('href="http://localhost/waiting-webhook/nodeID?approved=true"'),
subject: 'my subject',
to: 'to@mail.com',
});
});
});

View File

@@ -5,11 +5,15 @@ import type {
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionType, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import * as send from './send.operation';
import * as sendAndWait from './sendAndWait.operation';
import { smtpConnectionTest } from './utils';
import { sendAndWaitWebhooksDescription } from '../../../utils/sendAndWait/descriptions';
import { sendAndWaitWebhook } from '../../../utils/sendAndWait/utils';
const versionDescription: INodeTypeDescription = {
export const versionDescription: INodeTypeDescription = {
displayName: 'Send Email',
name: 'emailSend',
icon: 'fa:envelope',
@@ -30,6 +34,7 @@ const versionDescription: INodeTypeDescription = {
testedBy: 'smtpConnectionTest',
},
],
webhooks: sendAndWaitWebhooksDescription,
properties: [
{
displayName: 'Resource',
@@ -47,7 +52,7 @@ const versionDescription: INodeTypeDescription = {
{
displayName: 'Operation',
name: 'operation',
type: 'hidden',
type: 'options',
noDataExpression: true,
default: 'send',
options: [
@@ -56,9 +61,15 @@ const versionDescription: INodeTypeDescription = {
value: 'send',
action: 'Send an Email',
},
{
name: 'Send and Wait for Response',
value: SEND_AND_WAIT_OPERATION,
action: 'Send message and wait for response',
},
],
},
...send.description,
...sendAndWait.description,
],
};
@@ -73,13 +84,22 @@ export class EmailSendV2 implements INodeType {
}
methods = {
credentialTest: { smtpConnectionTest: send.smtpConnectionTest },
credentialTest: { smtpConnectionTest },
};
webhook = sendAndWaitWebhook;
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
let returnData: INodeExecutionData[][] = [];
const operation = this.getNodeParameter('operation', 0) as string;
returnData = await send.execute.call(this);
if (operation === SEND_AND_WAIT_OPERATION) {
returnData = await sendAndWait.execute.call(this);
}
if (operation === 'send') {
returnData = await send.execute.call(this);
}
return returnData;
}

View File

@@ -0,0 +1,23 @@
import type { INodeProperties } from 'n8n-workflow';
export const fromEmailProperty: INodeProperties = {
displayName: 'From Email',
name: 'fromEmail',
type: 'string',
default: '',
required: true,
placeholder: 'admin@example.com',
description:
'Email address of the sender. You can also specify a name: Nathan Doe &lt;nate@n8n.io&gt;.',
};
export const toEmailProperty: INodeProperties = {
displayName: 'To Email',
name: 'toEmail',
type: 'string',
default: '',
required: true,
placeholder: 'info@example.com',
description:
'Email address of the recipient. You can also specify a name: Nathan Doe &lt;nate@n8n.io&gt;.',
};

View File

@@ -1,43 +1,22 @@
import type {
ICredentialsDecrypted,
ICredentialTestFunctions,
IDataObject,
IExecuteFunctions,
INodeCredentialTestResult,
INodeExecutionData,
INodeProperties,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { createTransport } from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { updateDisplayOptions } from '@utils/utilities';
import { fromEmailProperty, toEmailProperty } from './descriptions';
import { configureTransport, type EmailSendOptions } from './utils';
import { appendAttributionOption } from '../../../utils/descriptions';
const properties: INodeProperties[] = [
// TODO: Add choice for text as text or html (maybe also from name)
{
displayName: 'From Email',
name: 'fromEmail',
type: 'string',
default: '',
required: true,
placeholder: 'admin@example.com',
description:
'Email address of the sender. You can also specify a name: Nathan Doe &lt;nate@n8n.io&gt;.',
},
{
displayName: 'To Email',
name: 'toEmail',
type: 'string',
default: '',
required: true,
placeholder: 'info@example.com',
description:
'Email address of the recipient. You can also specify a name: Nathan Doe &lt;nate@n8n.io&gt;.',
},
fromEmailProperty,
toEmailProperty,
{
displayName: 'Subject',
@@ -194,72 +173,11 @@ const displayOptions = {
export const description = updateDisplayOptions(displayOptions, properties);
type EmailSendOptions = {
appendAttribution?: boolean;
allowUnauthorizedCerts?: boolean;
attachments?: string;
ccEmail?: string;
bccEmail?: string;
replyTo?: string;
};
function configureTransport(credentials: IDataObject, options: EmailSendOptions) {
const connectionOptions: SMTPTransport.Options = {
host: credentials.host as string,
port: credentials.port as number,
secure: credentials.secure as boolean,
};
if (credentials.secure === false) {
connectionOptions.ignoreTLS = credentials.disableStartTls as boolean;
}
if (typeof credentials.hostName === 'string' && credentials.hostName) {
connectionOptions.name = credentials.hostName;
}
if (credentials.user || credentials.password) {
connectionOptions.auth = {
user: credentials.user as string,
pass: credentials.password as string,
};
}
if (options.allowUnauthorizedCerts === true) {
connectionOptions.tls = {
rejectUnauthorized: false,
};
}
return createTransport(connectionOptions);
}
export async function smtpConnectionTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const credentials = credential.data!;
const transporter = configureTransport(credentials, {});
try {
await transporter.verify();
return {
status: 'OK',
message: 'Connection successful!',
};
} catch (error) {
return {
status: 'Error',
message: error.message,
};
} finally {
transporter.close();
}
}
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
const instanceId = this.getInstanceId();
const credentials = await this.getCredentials('smtp');
const returnData: INodeExecutionData[] = [];
let item: INodeExecutionData;
@@ -274,8 +192,6 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
const emailFormat = this.getNodeParameter('emailFormat', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as EmailSendOptions;
const credentials = await this.getCredentials('smtp');
const transporter = configureTransport(credentials, options);
const mailOptions: IDataObject = {

View File

@@ -0,0 +1,53 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { fromEmailProperty, toEmailProperty } from './descriptions';
import { configureTransport } from './utils';
import { createEmailBody } from '../../../utils/sendAndWait/email-templates';
import {
configureWaitTillDate,
createButton,
getSendAndWaitConfig,
getSendAndWaitProperties,
} from '../../../utils/sendAndWait/utils';
export const description: INodeProperties[] = getSendAndWaitProperties(
[fromEmailProperty, toEmailProperty],
'email',
);
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const fromEmail = this.getNodeParameter('fromEmail', 0) as string;
const toEmail = this.getNodeParameter('toEmail', 0) as string;
const config = getSendAndWaitConfig(this);
const buttons: string[] = [];
for (const option of config.options) {
buttons.push(createButton(config.url, option.label, option.value, option.style));
}
const instanceId = this.getInstanceId();
const htmlBody = createEmailBody(config.message, buttons.join('\n'), instanceId);
const mailOptions: IDataObject = {
from: fromEmail,
to: toEmail,
subject: config.title,
html: htmlBody,
};
const credentials = await this.getCredentials('smtp');
const transporter = configureTransport(credentials, {});
await transporter.sendMail(mailOptions);
const waitTill = configureWaitTillDate(this);
await this.putExecutionToWait(waitTill);
return [this.getInputData()];
}

View File

@@ -0,0 +1,70 @@
import type {
IDataObject,
ICredentialsDecrypted,
ICredentialTestFunctions,
INodeCredentialTestResult,
} from 'n8n-workflow';
import { createTransport } from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
export type EmailSendOptions = {
appendAttribution?: boolean;
allowUnauthorizedCerts?: boolean;
attachments?: string;
ccEmail?: string;
bccEmail?: string;
replyTo?: string;
};
export function configureTransport(credentials: IDataObject, options: EmailSendOptions) {
const connectionOptions: SMTPTransport.Options = {
host: credentials.host as string,
port: credentials.port as number,
secure: credentials.secure as boolean,
};
if (credentials.secure === false) {
connectionOptions.ignoreTLS = credentials.disableStartTls as boolean;
}
if (typeof credentials.hostName === 'string' && credentials.hostName) {
connectionOptions.name = credentials.hostName;
}
if (credentials.user || credentials.password) {
connectionOptions.auth = {
user: credentials.user as string,
pass: credentials.password as string,
};
}
if (options.allowUnauthorizedCerts === true) {
connectionOptions.tls = {
rejectUnauthorized: false,
};
}
return createTransport(connectionOptions);
}
export async function smtpConnectionTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const credentials = credential.data!;
const transporter = configureTransport(credentials, {});
try {
await transporter.verify();
return {
status: 'OK',
message: 'Connection successful!',
};
} catch (error) {
return {
status: 'Error',
message: error.message,
};
} finally {
transporter.close();
}
}

View File

@@ -13,7 +13,7 @@ import { labelFields, labelOperations } from './LabelDescription';
import { getGmailAliases, getLabels, getThreadMessages } from './loadOptions';
import { messageFields, messageOperations } from './MessageDescription';
import { threadFields, threadOperations } from './ThreadDescription';
import { sendAndWaitWebhooks } from '../../../../utils/sendAndWait/descriptions';
import { sendAndWaitWebhooksDescription } from '../../../../utils/sendAndWait/descriptions';
import type { IEmail } from '../../../../utils/sendAndWait/interfaces';
import {
configureWaitTillDate,
@@ -69,7 +69,7 @@ const versionDescription: INodeTypeDescription = {
},
},
],
webhooks: sendAndWaitWebhooks,
webhooks: sendAndWaitWebhooksDescription,
properties: [
{
displayName: 'Authentication',

View File

@@ -51,6 +51,9 @@ describe('Test MicrosoftOutlookV2, message => sendAndWait', () => {
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({});
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval');
// configureWaitTillDate
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); //options.limitWaitTime.values
const result = await microsoftOutlook.execute.call(mockExecuteFunctions);
expect(result).toEqual([items]);

View File

@@ -9,7 +9,7 @@ import * as folder from './folder';
import * as folderMessage from './folderMessage';
import * as message from './message';
import * as messageAttachment from './messageAttachment';
import { sendAndWaitWebhooks } from '../../../../../utils/sendAndWait/descriptions';
import { sendAndWaitWebhooksDescription } from '../../../../../utils/sendAndWait/descriptions';
export const description: INodeTypeDescription = {
displayName: 'Microsoft Outlook',
@@ -31,7 +31,7 @@ export const description: INodeTypeDescription = {
required: true,
},
],
webhooks: sendAndWaitWebhooks,
webhooks: sendAndWaitWebhooksDescription,
properties: [
{
displayName: 'Resource',

View File

@@ -1,10 +1,5 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import {
NodeApiError,
NodeOperationError,
SEND_AND_WAIT_OPERATION,
WAIT_INDEFINITELY,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import * as calendar from './calendar';
import * as contact from './contact';
@@ -15,6 +10,7 @@ import * as folderMessage from './folderMessage';
import * as message from './message';
import * as messageAttachment from './messageAttachment';
import type { MicrosoftOutlook } from './node.type';
import { configureWaitTillDate } from '../../../../../utils/sendAndWait/utils';
export async function router(this: IExecuteFunctions) {
const items = this.getInputData();
@@ -36,7 +32,9 @@ export async function router(this: IExecuteFunctions) {
) {
await message[microsoftOutlook.operation].execute.call(this, 0, items);
await this.putExecutionToWait(WAIT_INDEFINITELY);
const waitTill = configureWaitTillDate(this);
await this.putExecutionToWait(waitTill);
return [items];
}

View File

@@ -41,7 +41,7 @@ import { reactionFields, reactionOperations } from './ReactionDescription';
import { starFields, starOperations } from './StarDescription';
import { userFields, userOperations } from './UserDescription';
import { userGroupFields, userGroupOperations } from './UserGroupDescription';
import { sendAndWaitWebhooks } from '../../../utils/sendAndWait/descriptions';
import { sendAndWaitWebhooksDescription } from '../../../utils/sendAndWait/descriptions';
import {
configureWaitTillDate,
getSendAndWaitProperties,
@@ -81,7 +81,7 @@ export class SlackV2 implements INodeType {
},
},
],
webhooks: sendAndWaitWebhooks,
webhooks: sendAndWaitWebhooksDescription,
properties: [
{
displayName: 'Authentication',

View File

@@ -21,7 +21,7 @@ import {
getPropertyName,
} from './GenericFunctions';
import { appendAttributionOption } from '../../utils/descriptions';
import { sendAndWaitWebhooks } from '../../utils/sendAndWait/descriptions';
import { sendAndWaitWebhooksDescription } from '../../utils/sendAndWait/descriptions';
import {
configureWaitTillDate,
getSendAndWaitProperties,
@@ -49,7 +49,7 @@ export class Telegram implements INodeType {
required: true,
},
],
webhooks: sendAndWaitWebhooks,
webhooks: sendAndWaitWebhooksDescription,
properties: [
{
displayName: 'Resource',

View File

@@ -1,6 +1,6 @@
import type { IWebhookDescription } from 'n8n-workflow';
export const sendAndWaitWebhooks: IWebhookDescription[] = [
export const sendAndWaitWebhooksDescription: IWebhookDescription[] = [
{
name: 'default',
httpMethod: 'GET',