From 41228b472de11affc8cd0821284427c2c9e8b421 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Mon, 7 Oct 2024 16:45:22 +0300 Subject: [PATCH] feat: Human in the loop (#10675) Co-authored-by: Giulio Andreini --- .../__tests__/waiting-webhooks.test.ts | 2 +- packages/cli/src/webhooks/waiting-webhooks.ts | 65 ++++- ...end-and-wait-no-action-required.handlebars | 73 +++++ packages/editor-ui/src/components/Node.vue | 5 +- .../src/composables/useCanvasMapping.ts | 7 +- .../src/composables/useRunWorkflow.ts | 1 + .../src/plugins/i18n/locales/en.json | 2 + .../editor-ui/src/stores/workflows.store.ts | 14 +- .../editor-ui/src/utils/executionUtils.ts | 13 +- .../nodes/Google/Gmail/GenericFunctions.ts | 33 +-- .../nodes/Google/Gmail/v1/GmailV1.node.ts | 2 +- .../nodes/Google/Gmail/v2/GmailV2.node.ts | 49 +++- .../Google/Gmail/v2/MessageDescription.ts | 7 +- .../nodes/Slack/V2/GenericFunctions.ts | 79 ++++++ .../nodes/Slack/V2/MessageDescription.ts | 259 +++++++++--------- .../nodes-base/nodes/Slack/V2/SlackV2.node.ts | 87 ++++-- .../nodes/Slack/test/v2/utils.test.ts | 146 ++++++++++ .../utils/sendAndWait/email-templates.ts | 141 ++++++++++ .../utils/sendAndWait/interfaces.ts | 15 + .../utils/sendAndWait/test/util.test.ts | 212 ++++++++++++++ .../nodes-base/utils/sendAndWait/utils.ts | 254 +++++++++++++++++ packages/nodes-base/utils/utilities.ts | 25 ++ packages/workflow/src/Constants.ts | 2 + packages/workflow/src/WorkflowDataProxy.ts | 1 + 24 files changed, 1298 insertions(+), 196 deletions(-) create mode 100644 packages/cli/templates/send-and-wait-no-action-required.handlebars create mode 100644 packages/nodes-base/nodes/Slack/test/v2/utils.test.ts create mode 100644 packages/nodes-base/utils/sendAndWait/email-templates.ts create mode 100644 packages/nodes-base/utils/sendAndWait/interfaces.ts create mode 100644 packages/nodes-base/utils/sendAndWait/test/util.test.ts create mode 100644 packages/nodes-base/utils/sendAndWait/utils.ts diff --git a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index 7a8dd8b854..892d87e773 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -63,7 +63,7 @@ describe('WaitingWebhooks', () => { * Arrange */ executionRepository.findSingleExecution.mockResolvedValue( - mock({ finished: true }), + mock({ finished: true, workflowData: { nodes: [] } }), ); /** diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 922de9d869..e644c065f3 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -1,5 +1,11 @@ import type express from 'express'; -import { NodeHelpers, Workflow } from 'n8n-workflow'; +import { + type INodes, + type IWorkflowBase, + NodeHelpers, + SEND_AND_WAIT_OPERATION, + Workflow, +} from 'n8n-workflow'; import { Service } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -42,6 +48,29 @@ export class WaitingWebhooks implements IWebhookManager { execution.data.executionData!.nodeExecutionStack[0].node.disabled = true; } + private isSendAndWaitRequest(nodes: INodes, suffix: string | undefined) { + return ( + suffix && + Object.keys(nodes).some( + (node) => + nodes[node].id === suffix && nodes[node].parameters.operation === SEND_AND_WAIT_OPERATION, + ) + ); + } + + private getWorkflow(workflowData: IWorkflowBase) { + return new Workflow({ + id: workflowData.id, + name: workflowData.name, + nodes: workflowData.nodes, + connections: workflowData.connections, + active: workflowData.active, + nodeTypes: this.nodeTypes, + staticData: workflowData.staticData, + settings: workflowData.settings, + }); + } + async executeWebhook( req: WaitingWebhookRequest, res: express.Response, @@ -66,10 +95,21 @@ export class WaitingWebhooks implements IWebhookManager { throw new ConflictError(`The execution "${executionId} is running already.`); } - if (execution.finished || execution.data.resultData.error) { + if (execution.data?.resultData?.error) { throw new ConflictError(`The execution "${executionId} has finished already.`); } + if (execution.finished) { + const { workflowData } = execution; + const { nodes } = this.getWorkflow(workflowData); + if (this.isSendAndWaitRequest(nodes, suffix)) { + res.render('send-and-wait-no-action-required', { isTestWebhook: false }); + return { noWebhookResponse: true }; + } else { + throw new ConflictError(`The execution "${executionId} has finished already.`); + } + } + const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; // Set the node as disabled so that the data does not get executed again as it would result @@ -83,17 +123,7 @@ export class WaitingWebhooks implements IWebhookManager { execution.data.resultData.runData[lastNodeExecuted].pop(); const { workflowData } = execution; - - const workflow = new Workflow({ - id: workflowData.id, - name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, - active: workflowData.active, - nodeTypes: this.nodeTypes, - staticData: workflowData.staticData, - settings: workflowData.settings, - }); + const workflow = this.getWorkflow(workflowData); const workflowStartNode = workflow.getNode(lastNodeExecuted); if (workflowStartNode === null) { @@ -116,8 +146,13 @@ export class WaitingWebhooks implements IWebhookManager { if (webhookData === undefined) { // If no data got found it means that the execution can not be started via a webhook. // Return 404 because we do not want to give any data if the execution exists or not. - const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`; - throw new NotFoundError(errorMessage); + if (this.isSendAndWaitRequest(workflow.nodes, suffix)) { + res.render('send-and-wait-no-action-required', { isTestWebhook: false }); + return { noWebhookResponse: true }; + } else { + const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`; + throw new NotFoundError(errorMessage); + } } const runExecutionData = execution.data; diff --git a/packages/cli/templates/send-and-wait-no-action-required.handlebars b/packages/cli/templates/send-and-wait-no-action-required.handlebars new file mode 100644 index 0000000000..7dcf99f10b --- /dev/null +++ b/packages/cli/templates/send-and-wait-no-action-required.handlebars @@ -0,0 +1,73 @@ + + + + + + + + No action required + + + + +
+
+
+
+

No action required

+
+
+ +
+
+ + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 0a02324a07..6fcc93be76 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -17,7 +17,7 @@ import type { NodeOperationError, Workflow, } from 'n8n-workflow'; -import { NodeConnectionType, NodeHelpers } from 'n8n-workflow'; +import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import type { StyleValue } from 'vue'; import { computed, onMounted, ref, watch } from 'vue'; import xss from 'xss'; @@ -336,6 +336,9 @@ const waiting = computed(() => { : i18n.baseText('node.theNodeIsWaitingFormCall'); return event; } + if (node?.parameters.operation === SEND_AND_WAIT_OPERATION) { + return i18n.baseText('node.theNodeIsWaitingUserInput'); + } const waitDate = new Date(workflowExecution.waitTill); if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall'); diff --git a/packages/editor-ui/src/composables/useCanvasMapping.ts b/packages/editor-ui/src/composables/useCanvasMapping.ts index 8308cbfcbf..fe33c1854c 100644 --- a/packages/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/editor-ui/src/composables/useCanvasMapping.ts @@ -36,7 +36,7 @@ import type { ITaskData, Workflow, } from 'n8n-workflow'; -import { NodeConnectionType, NodeHelpers } from 'n8n-workflow'; +import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import type { INodeUi } from '@/Interface'; import { CUSTOM_API_CALL_KEY, @@ -348,6 +348,11 @@ export function useCanvasMapping({ return acc; } + if (node?.parameters.operation === SEND_AND_WAIT_OPERATION) { + acc[node.id] = i18n.baseText('node.theNodeIsWaitingUserInput'); + return acc; + } + const waitDate = new Date(workflowExecution.waitTill); if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 4d06d5b572..81bc65ad38 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -378,6 +378,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { const allNodes = computed(() => workflow.value.nodes); const isWaitingExecution = computed(() => { - return allNodes.value.some((node) => node.type === WAIT_NODE_TYPE && node.disabled !== true); + return allNodes.value.some( + (node) => + (node.type === WAIT_NODE_TYPE || node.parameters.operation === SEND_AND_WAIT_OPERATION) && + node.disabled !== true, + ); }); // Names of all nodes currently on canvas. diff --git a/packages/editor-ui/src/utils/executionUtils.ts b/packages/editor-ui/src/utils/executionUtils.ts index 5c775c7608..d0f8916c8f 100644 --- a/packages/editor-ui/src/utils/executionUtils.ts +++ b/packages/editor-ui/src/utils/executionUtils.ts @@ -1,4 +1,11 @@ -import type { ExecutionStatus, IDataObject, INode, IPinData, IRunData } from 'n8n-workflow'; +import { + SEND_AND_WAIT_OPERATION, + type ExecutionStatus, + type IDataObject, + type INode, + type IPinData, + type IRunData, +} from 'n8n-workflow'; import type { ExecutionFilterType, ExecutionsQueryFilter } from '@/Interface'; import { isEmpty } from '@/utils/typesUtils'; import { FORM_TRIGGER_NODE_TYPE } from '../constants'; @@ -160,6 +167,10 @@ export const waitingNodeTooltip = () => { return `${message}${resumeUrl}`; } } + + if (lastNode?.parameters.operation === SEND_AND_WAIT_OPERATION) { + return i18n.baseText('ndv.output.sendAndWaitWaitingApproval'); + } } catch (error) { // do not throw error if could not compose tooltip } diff --git a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts index 78fc41769d..e856174b38 100644 --- a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts @@ -18,20 +18,6 @@ import { DateTime } from 'luxon'; import isEmpty from 'lodash/isEmpty'; -export interface IEmail { - from?: string; - to?: string; - cc?: string; - bcc?: string; - replyTo?: string; - inReplyTo?: string; - reference?: string; - subject: string; - body: string; - htmlBody?: string; - attachments?: IDataObject[]; -} - export interface IAttachments { type: string; name: string; @@ -40,6 +26,8 @@ export interface IAttachments { import MailComposer from 'nodemailer/lib/mail-composer'; import { getGoogleAccessToken } from '../GenericFunctions'; +import { escapeHtml } from '../../../utils/utilities'; +import type { IEmail } from '../../../utils/sendAndWait/interfaces'; export async function googleApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, @@ -516,22 +504,7 @@ export function unescapeSnippets(items: INodeExecutionData[]) { const result = items.map((item) => { const snippet = item.json.snippet as string; if (snippet) { - item.json.snippet = snippet.replace(/&|<|>|'|"/g, (match) => { - switch (match) { - case '&': - return '&'; - case '<': - return '<'; - case '>': - return '>'; - case ''': - return "'"; - case '"': - return '"'; - default: - return match; - } - }); + item.json.snippet = escapeHtml(snippet); } return item; }); diff --git a/packages/nodes-base/nodes/Google/Gmail/v1/GmailV1.node.ts b/packages/nodes-base/nodes/Google/Gmail/v1/GmailV1.node.ts index ff715d2c6d..c17c26eda9 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v1/GmailV1.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v1/GmailV1.node.ts @@ -13,7 +13,7 @@ import { } from 'n8n-workflow'; import isEmpty from 'lodash/isEmpty'; -import type { IEmail } from '../GenericFunctions'; +import type { IEmail } from '../../../../utils/sendAndWait/interfaces'; import { encodeEmail, extractEmail, diff --git a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts index 2e25b96c12..c5e37263b1 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts @@ -8,9 +8,14 @@ import type { INodeTypeBaseDescription, INodeTypeDescription, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { + NodeConnectionType, + NodeOperationError, + SEND_AND_WAIT_OPERATION, + WAIT_TIME_UNLIMITED, +} from 'n8n-workflow'; -import type { IEmail } from '../GenericFunctions'; +import type { IEmail } from '../../../../utils/sendAndWait/interfaces'; import { encodeEmail, googleApiRequest, @@ -33,6 +38,12 @@ import { draftFields, draftOperations } from './DraftDescription'; import { threadFields, threadOperations } from './ThreadDescription'; +import { + getSendAndWaitProperties, + createEmail, + sendAndWaitWebhook, +} from '../../../../utils/sendAndWait/utils'; + const versionDescription: INodeTypeDescription = { displayName: 'Gmail', name: 'gmail', @@ -67,6 +78,17 @@ const versionDescription: INodeTypeDescription = { }, }, ], + webhooks: [ + { + name: 'default', + httpMethod: 'GET', + responseMode: 'onReceived', + responseData: '', + path: '={{ $nodeId }}', + restartWebhook: true, + isFullPath: true, + }, + ], properties: [ { displayName: 'Authentication', @@ -125,6 +147,16 @@ const versionDescription: INodeTypeDescription = { //------------------------------- ...messageOperations, ...messageFields, + ...getSendAndWaitProperties([ + { + displayName: 'To', + name: 'sendTo', + type: 'string', + default: '', + required: true, + placeholder: 'e.g. info@example.com', + }, + ]), //------------------------------- // Thread Operations //------------------------------- @@ -221,6 +253,8 @@ export class GmailV2 implements INodeType { }, }; + webhook = sendAndWaitWebhook; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; @@ -229,6 +263,17 @@ export class GmailV2 implements INodeType { const nodeVersion = this.getNode().typeVersion; const instanceId = this.getInstanceId(); + if (resource === 'message' && operation === SEND_AND_WAIT_OPERATION) { + const email: IEmail = createEmail(this); + + await googleApiRequest.call(this, 'POST', '/gmail/v1/users/me/messages/send', { + raw: await encodeEmail(email), + }); + + await this.putExecutionToWait(new Date(WAIT_TIME_UNLIMITED)); + return [this.getInputData()]; + } + let responseData; for (let i = 0; i < items.length; i++) { diff --git a/packages/nodes-base/nodes/Google/Gmail/v2/MessageDescription.ts b/packages/nodes-base/nodes/Google/Gmail/v2/MessageDescription.ts index ff3f40eb9a..83b1e6fcb0 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v2/MessageDescription.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v2/MessageDescription.ts @@ -1,4 +1,4 @@ -import type { INodeProperties } from 'n8n-workflow'; +import { SEND_AND_WAIT_OPERATION, type INodeProperties } from 'n8n-workflow'; import { appendAttributionOption } from '../../../../utils/descriptions'; export const messageOperations: INodeProperties[] = [ @@ -58,6 +58,11 @@ export const messageOperations: INodeProperties[] = [ value: 'send', action: 'Send a message', }, + { + name: 'Send and Wait for Approval', + value: SEND_AND_WAIT_OPERATION, + action: 'Send a message and wait for approval', + }, ], default: 'send', }, diff --git a/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts b/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts index 5c00c3eb3f..48508f503e 100644 --- a/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts @@ -11,6 +11,7 @@ import type { import { NodeOperationError } from 'n8n-workflow'; import get from 'lodash/get'; +import { getSendAndWaitConfig } from '../../../utils/sendAndWait/utils'; export async function slackApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, @@ -219,3 +220,81 @@ export function validateJSON(json: string | undefined): any { } return result; } + +export function getTarget( + context: IExecuteFunctions, + itemIndex: number, + idType: 'user' | 'channel', +): string { + let target = ''; + + if (idType === 'channel') { + target = context.getNodeParameter('channelId', itemIndex, undefined, { + extractValue: true, + }) as string; + } else { + target = context.getNodeParameter('user', itemIndex, undefined, { + extractValue: true, + }) as string; + } + + if ( + idType === 'user' && + (context.getNodeParameter('user', itemIndex) as IDataObject).mode === 'username' + ) { + target = target.slice(0, 1) === '@' ? target : `@${target}`; + } + + return target; +} + +export function createSendAndWaitMessageBody(context: IExecuteFunctions) { + const select = context.getNodeParameter('select', 0) as 'user' | 'channel'; + const target = getTarget(context, 0, select); + + const config = getSendAndWaitConfig(context); + + const body: IDataObject = { + channel: target, + blocks: [ + { + type: 'divider', + }, + { + type: 'section', + text: { + type: 'plain_text', + text: config.message, + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'plain_text', + text: ' ', + }, + }, + { + type: 'divider', + }, + { + type: 'actions', + elements: config.options.map((option) => { + return { + type: 'button', + style: option.style === 'primary' ? 'primary' : undefined, + text: { + type: 'plain_text', + text: option.label, + emoji: true, + }, + url: `${config.url}?approved=${option.value}`, + }; + }), + }, + ], + }; + + return body; +} diff --git a/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts b/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts index 3100b258f5..de31614b44 100644 --- a/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts +++ b/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts @@ -1,4 +1,4 @@ -import type { INodeProperties } from 'n8n-workflow'; +import { SEND_AND_WAIT_OPERATION, type INodeProperties } from 'n8n-workflow'; export const messageOperations: INodeProperties[] = [ { @@ -32,6 +32,11 @@ export const messageOperations: INodeProperties[] = [ value: 'post', action: 'Send a message', }, + { + name: 'Send and Wait for Approval', + value: SEND_AND_WAIT_OPERATION, + action: 'Send a message and wait for approval', + }, { name: 'Update', value: 'update', @@ -42,6 +47,134 @@ export const messageOperations: INodeProperties[] = [ }, ]; +export const sendToSelector: INodeProperties = { + displayName: 'Send Message To', + name: 'select', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'], + operation: ['post'], + }, + }, + options: [ + { + name: 'Channel', + value: 'channel', + }, + { + name: 'User', + value: 'user', + }, + ], + default: '', + placeholder: 'Select...', +}; + +export const channelRLC: INodeProperties = { + displayName: 'Channel', + name: 'channelId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + placeholder: 'Select a channel...', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a channel...', + typeOptions: { + searchListMethod: 'getChannels', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Slack Channel ID', + }, + }, + ], + placeholder: 'C0122KQ70S7E', + }, + { + displayName: 'By Name', + name: 'name', + type: 'string', + placeholder: '#general', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://app.slack.com/client/TS9594PZK/B0556F47Z3A', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://app.slack.com/client/.*/([a-zA-Z0-9]{2,})', + errorMessage: 'Not a valid Slack Channel URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://app.slack.com/client/.*/([a-zA-Z0-9]{2,})', + }, + }, + ], + required: true, + description: 'The Slack channel to send to', +}; + +export const userRLC: INodeProperties = { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + placeholder: 'Select a user...', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'getUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Slack User ID', + }, + }, + ], + placeholder: 'U123AB45JGM', + }, + { + displayName: 'By username', + name: 'username', + type: 'string', + placeholder: '@username', + }, + ], +}; + export const messageFields: INodeProperties[] = [ /* ----------------------------------------------------------------------- */ /* message:getPermalink @@ -125,88 +258,9 @@ export const messageFields: INodeProperties[] = [ /* -------------------------------------------------------------------------- */ /* message:post */ /* -------------------------------------------------------------------------- */ + sendToSelector, { - displayName: 'Send Message To', - name: 'select', - type: 'options', - required: true, - displayOptions: { - show: { - resource: ['message'], - operation: ['post'], - }, - }, - options: [ - { - name: 'Channel', - value: 'channel', - }, - { - name: 'User', - value: 'user', - }, - ], - default: '', - placeholder: 'Select...', - }, - { - displayName: 'Channel', - name: 'channelId', - type: 'resourceLocator', - default: { mode: 'list', value: '' }, - placeholder: 'Select a channel...', - modes: [ - { - displayName: 'From List', - name: 'list', - type: 'list', - placeholder: 'Select a channel...', - typeOptions: { - searchListMethod: 'getChannels', - searchable: true, - }, - }, - { - displayName: 'By ID', - name: 'id', - type: 'string', - validation: [ - { - type: 'regex', - properties: { - regex: '[a-zA-Z0-9]{2,}', - errorMessage: 'Not a valid Slack Channel ID', - }, - }, - ], - placeholder: 'C0122KQ70S7E', - }, - { - displayName: 'By Name', - name: 'name', - type: 'string', - placeholder: '#general', - }, - { - displayName: 'By URL', - name: 'url', - type: 'string', - placeholder: 'https://app.slack.com/client/TS9594PZK/B0556F47Z3A', - validation: [ - { - type: 'regex', - properties: { - regex: 'http(s)?://app.slack.com/client/.*/([a-zA-Z0-9]{2,})', - errorMessage: 'Not a valid Slack Channel URL', - }, - }, - ], - extractValue: { - type: 'regex', - regex: 'https://app.slack.com/client/.*/([a-zA-Z0-9]{2,})', - }, - }, - ], + ...channelRLC, displayOptions: { show: { operation: ['post'], @@ -214,15 +268,9 @@ export const messageFields: INodeProperties[] = [ select: ['channel'], }, }, - required: true, - description: 'The Slack channel to send to', }, { - displayName: 'User', - name: 'user', - type: 'resourceLocator', - default: { mode: 'list', value: '' }, - placeholder: 'Select a user...', + ...userRLC, displayOptions: { show: { operation: ['post'], @@ -230,39 +278,6 @@ export const messageFields: INodeProperties[] = [ select: ['user'], }, }, - modes: [ - { - displayName: 'From List', - name: 'list', - type: 'list', - placeholder: 'Select a user...', - typeOptions: { - searchListMethod: 'getUsers', - searchable: true, - }, - }, - { - displayName: 'By ID', - name: 'id', - type: 'string', - validation: [ - { - type: 'regex', - properties: { - regex: '[a-zA-Z0-9]{2,}', - errorMessage: 'Not a valid Slack User ID', - }, - }, - ], - placeholder: 'U123AB45JGM', - }, - { - displayName: 'By username', - name: 'username', - type: 'string', - placeholder: '@username', - }, - ], }, { displayName: 'Message Type', diff --git a/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts b/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts index 7bde9cec1d..ed62328331 100644 --- a/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts +++ b/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts @@ -15,17 +15,36 @@ import type { JsonObject, } from 'n8n-workflow'; -import { BINARY_ENCODING, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { + BINARY_ENCODING, + NodeConnectionType, + NodeOperationError, + SEND_AND_WAIT_OPERATION, + WAIT_TIME_UNLIMITED, +} from 'n8n-workflow'; import moment from 'moment-timezone'; import { channelFields, channelOperations } from './ChannelDescription'; -import { messageFields, messageOperations } from './MessageDescription'; +import { + channelRLC, + messageFields, + messageOperations, + sendToSelector, + userRLC, +} from './MessageDescription'; import { starFields, starOperations } from './StarDescription'; import { fileFields, fileOperations } from './FileDescription'; import { reactionFields, reactionOperations } from './ReactionDescription'; import { userGroupFields, userGroupOperations } from './UserGroupDescription'; import { userFields, userOperations } from './UserDescription'; -import { slackApiRequest, slackApiRequestAllItems, getMessageContent } from './GenericFunctions'; +import { + slackApiRequest, + slackApiRequestAllItems, + getMessageContent, + getTarget, + createSendAndWaitMessageBody, +} from './GenericFunctions'; +import { getSendAndWaitProperties, sendAndWaitWebhook } from '../../../utils/sendAndWait/utils'; export class SlackV2 implements INodeType { description: INodeTypeDescription; @@ -60,6 +79,17 @@ export class SlackV2 implements INodeType { }, }, ], + webhooks: [ + { + name: 'default', + httpMethod: 'GET', + responseMode: 'onReceived', + responseData: '', + path: '={{ $nodeId }}', + restartWebhook: true, + isFullPath: true, + }, + ], properties: [ { displayName: 'Authentication', @@ -120,6 +150,25 @@ export class SlackV2 implements INodeType { ...channelFields, ...messageOperations, ...messageFields, + ...getSendAndWaitProperties([ + { ...sendToSelector, default: 'user' }, + { + ...channelRLC, + displayOptions: { + show: { + select: ['channel'], + }, + }, + }, + { + ...userRLC, + displayOptions: { + show: { + select: ['user'], + }, + }, + }, + ]).filter((p) => p.name !== 'subject'), ...starOperations, ...starFields, ...fileOperations, @@ -307,6 +356,8 @@ export class SlackV2 implements INodeType { }, }; + webhook = sendAndWaitWebhook; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; @@ -320,6 +371,18 @@ export class SlackV2 implements INodeType { const nodeVersion = this.getNode().typeVersion; const instanceId = this.getInstanceId(); + if (resource === 'message' && operation === SEND_AND_WAIT_OPERATION) { + await slackApiRequest.call( + this, + 'POST', + '/chat.postMessage', + createSendAndWaitMessageBody(this), + ); + + await this.putExecutionToWait(new Date(WAIT_TIME_UNLIMITED)); + return [this.getInputData()]; + } + for (let i = 0; i < length; i++) { try { responseData = { @@ -749,22 +812,8 @@ export class SlackV2 implements INodeType { if (resource === 'message') { //https://api.slack.com/methods/chat.postMessage if (operation === 'post') { - const select = this.getNodeParameter('select', i) as string; - let target = - select === 'channel' - ? (this.getNodeParameter('channelId', i, undefined, { - extractValue: true, - }) as string) - : (this.getNodeParameter('user', i, undefined, { - extractValue: true, - }) as string); - - if ( - select === 'user' && - (this.getNodeParameter('user', i) as IDataObject).mode === 'username' - ) { - target = target.slice(0, 1) === '@' ? target : `@${target}`; - } + const select = this.getNodeParameter('select', i) as 'user' | 'channel'; + const target = getTarget(this, i, select); const { sendAsUser } = this.getNodeParameter('otherOptions', i) as IDataObject; const content = getMessageContent.call(this, i, nodeVersion, instanceId); diff --git a/packages/nodes-base/nodes/Slack/test/v2/utils.test.ts b/packages/nodes-base/nodes/Slack/test/v2/utils.test.ts new file mode 100644 index 0000000000..634ee25900 --- /dev/null +++ b/packages/nodes-base/nodes/Slack/test/v2/utils.test.ts @@ -0,0 +1,146 @@ +import { type MockProxy, mock } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { getTarget, createSendAndWaitMessageBody } from '../../V2/GenericFunctions'; + +describe('Slack Utility Functions', () => { + let mockExecuteFunctions: MockProxy; + + beforeEach(() => { + mockExecuteFunctions = mock(); + mockExecuteFunctions.getNode.mockReturnValue({ name: 'Slack', typeVersion: 1 } as any); + jest.clearAllMocks(); + }); + + describe('getTarget', () => { + it('should return corect target id', () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + if (parameterName === 'user') { + return 'testUser'; + } + return 'testChannel'; + }); + expect(getTarget(mockExecuteFunctions, 0, 'channel')).toEqual('testChannel'); + + expect(getTarget(mockExecuteFunctions, 0, 'user')).toEqual('testUser'); + }); + }); + + describe('createSendAndWaitMessageBody', () => { + it('should create message with single button', () => { + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('channel'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('channelID'); + + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('subject'); + mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('localhost'); + mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('node123'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + + expect(createSendAndWaitMessageBody(mockExecuteFunctions)).toEqual({ + blocks: [ + { + type: 'divider', + }, + { + text: { + emoji: true, + text: 'message', + type: 'plain_text', + }, + type: 'section', + }, + { + text: { + text: ' ', + type: 'plain_text', + }, + type: 'section', + }, + { + type: 'divider', + }, + { + elements: [ + { + style: 'primary', + text: { + emoji: true, + text: 'Approve', + type: 'plain_text', + }, + type: 'button', + url: 'localhost/node123?approved=true', + }, + ], + type: 'actions', + }, + ], + channel: 'channelID', + }); + }); + + it('should create message with double buttona', () => { + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('channel'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('channelID'); + + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('subject'); + mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('localhost'); + mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('node123'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ approvalType: 'double' }); + + expect(createSendAndWaitMessageBody(mockExecuteFunctions)).toEqual({ + blocks: [ + { + type: 'divider', + }, + { + text: { + emoji: true, + text: 'message', + type: 'plain_text', + }, + type: 'section', + }, + { + text: { + text: ' ', + type: 'plain_text', + }, + type: 'section', + }, + { + type: 'divider', + }, + { + elements: [ + { + style: undefined, + text: { + emoji: true, + text: 'Disapprove', + type: 'plain_text', + }, + type: 'button', + url: 'localhost/node123?approved=false', + }, + + { + style: 'primary', + text: { + emoji: true, + text: 'Approve', + type: 'plain_text', + }, + type: 'button', + url: 'localhost/node123?approved=true', + }, + ], + type: 'actions', + }, + ], + channel: 'channelID', + }); + }); + }); +}); diff --git a/packages/nodes-base/utils/sendAndWait/email-templates.ts b/packages/nodes-base/utils/sendAndWait/email-templates.ts new file mode 100644 index 0000000000..0ae8d7689a --- /dev/null +++ b/packages/nodes-base/utils/sendAndWait/email-templates.ts @@ -0,0 +1,141 @@ +export const BUTTON_STYLE_SECONDARY = + 'display:inline-block; text-decoration:none; background-color:#fff; color:#4a4a4a; padding:12px 24px; font-family: Arial,sans-serif; font-size:14px;font-weight:600; border:1px solid #d1d1d1; border-radius:6px; min-width:120px; margin: 12px 6px 0 6px;'; +export const BUTTON_STYLE_PRIMARY = + 'display:inline-block; text-decoration:none; background-color:#ff6d5a; color: #fff; padding:12px 24px; font-family: Arial,sans-serif; font-size:14px;font-weight:600; border-radius:6px; min-width:120px; margin: 12px 2px 0 2px;'; + +export const ACTION_RECORDED_PAGE = ` + + + + + + + + Action recorded + + + + +
+
+
+
+

Got it, thanks

+

This page can be closed now

+
+
+ +
+
+ + +`; + +export function createEmailBody(message: string, buttons: string, instanceId?: string) { + const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : ''; + const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=send-and-wait${utm_campaign}`; + return ` + + + + + + + My form + + + + + + + +
+ + + + + + + +
+

${message}

+
+ ${buttons} +
+ + + + + + +
+ + + + + + +
+ Automated with + n8n +
+
+ + + + `; +} diff --git a/packages/nodes-base/utils/sendAndWait/interfaces.ts b/packages/nodes-base/utils/sendAndWait/interfaces.ts new file mode 100644 index 0000000000..bb7a20b9ea --- /dev/null +++ b/packages/nodes-base/utils/sendAndWait/interfaces.ts @@ -0,0 +1,15 @@ +import type { IDataObject } from 'n8n-workflow'; + +export interface IEmail { + from?: string; + to?: string; + cc?: string; + bcc?: string; + replyTo?: string; + inReplyTo?: string; + reference?: string; + subject: string; + body: string; + htmlBody?: string; + attachments?: IDataObject[]; +} diff --git a/packages/nodes-base/utils/sendAndWait/test/util.test.ts b/packages/nodes-base/utils/sendAndWait/test/util.test.ts new file mode 100644 index 0000000000..16e37c7790 --- /dev/null +++ b/packages/nodes-base/utils/sendAndWait/test/util.test.ts @@ -0,0 +1,212 @@ +import { type MockProxy, mock } from 'jest-mock-extended'; +import type { IExecuteFunctions, INodeProperties, IWebhookFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { + getSendAndWaitProperties, + getSendAndWaitConfig, + createEmail, + sendAndWaitWebhook, + MESSAGE_PREFIX, +} from '../utils'; + +describe('Send and Wait utils tests', () => { + let mockExecuteFunctions: MockProxy; + let mockWebhookFunctions: MockProxy; + + beforeEach(() => { + mockExecuteFunctions = mock(); + mockWebhookFunctions = mock(); + }); + + describe('getSendAndWaitProperties', () => { + it('should return properties with correct display options', () => { + const targetProperties: INodeProperties[] = [ + { + displayName: 'Test Property', + name: 'testProperty', + type: 'string', + default: '', + }, + ]; + + const result = getSendAndWaitProperties(targetProperties); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + displayOptions: { + show: { + resource: ['message'], + operation: ['sendAndWait'], + }, + }, + }), + ]), + ); + }); + }); + + describe('getSendAndWaitConfig', () => { + it('should return correct config for single approval', () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: { [key: string]: any } = { + message: 'Test message', + subject: 'Test subject', + 'approvalOptions.values': { + approvalType: 'single', + approveLabel: 'Approve', + buttonApprovalStyle: 'primary', + }, + }; + return params[parameterName]; + }); + + mockExecuteFunctions.evaluateExpression.mockImplementation((expression: string) => { + const expressions: { [key: string]: string } = { + '{{ $execution?.resumeUrl }}': 'http://localhost', + '{{ $nodeId }}': 'testNodeId', + }; + return expressions[expression]; + }); + + const config = getSendAndWaitConfig(mockExecuteFunctions); + + expect(config).toEqual({ + title: 'Test subject', + message: 'Test message', + url: 'http://localhost/testNodeId', + options: [ + { + label: 'Approve', + value: 'true', + style: 'primary', + }, + ], + }); + }); + + it('should return correct config for double approval', () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: { [key: string]: any } = { + message: 'Test message', + subject: 'Test subject', + 'approvalOptions.values': { + approvalType: 'double', + approveLabel: 'Approve', + buttonApprovalStyle: 'primary', + disapproveLabel: 'Reject', + buttonDisapprovalStyle: 'secondary', + }, + }; + return params[parameterName]; + }); + + mockExecuteFunctions.evaluateExpression.mockImplementation((expression: string) => { + const expressions: { [key: string]: string } = { + '{{ $execution?.resumeUrl }}': 'http://localhost', + '{{ $nodeId }}': 'testNodeId', + }; + return expressions[expression]; + }); + + const config = getSendAndWaitConfig(mockExecuteFunctions); + + expect(config.options).toHaveLength(2); + expect(config.options).toEqual( + expect.arrayContaining([ + { + label: 'Reject', + value: 'false', + style: 'secondary', + }, + { + label: 'Approve', + value: 'true', + style: 'primary', + }, + ]), + ); + }); + }); + + describe('createEmail', () => { + beforeEach(() => { + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: { [key: string]: any } = { + sendTo: 'test@example.com', + message: 'Test message', + subject: 'Test subject', + 'approvalOptions.values': { + approvalType: 'single', + approveLabel: 'Approve', + buttonApprovalStyle: 'primary', + }, + }; + return params[parameterName]; + }); + + mockExecuteFunctions.evaluateExpression.mockImplementation((expression: string) => { + const expressions: { [key: string]: string } = { + '{{ $execution?.resumeUrl }}': 'http://localhost', + '{{ $nodeId }}': 'testNodeId', + }; + return expressions[expression]; + }); + }); + + it('should create a valid email object', () => { + const email = createEmail(mockExecuteFunctions); + + expect(email).toEqual({ + to: 'test@example.com', + subject: `${MESSAGE_PREFIX}Test subject`, + body: '', + htmlBody: expect.stringContaining('Test message'), + }); + }); + + it('should throw NodeOperationError for invalid email address', () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: { [key: string]: any } = { + sendTo: 'invalid@@email.com', + message: 'Test message', + subject: 'Test subject', + 'approvalOptions.values': { + approvalType: 'single', + }, + }; + return params[parameterName]; + }); + + expect(() => createEmail(mockExecuteFunctions)).toThrow(NodeOperationError); + }); + }); + + describe('sendAndWaitWebhook', () => { + it('should handle approved webhook', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue({ + query: { approved: 'true' }, + } as any); + + const result = await sendAndWaitWebhook.call(mockWebhookFunctions); + + expect(result).toEqual({ + webhookResponse: expect.any(String), + workflowData: [[{ json: { data: { approved: true } } }]], + }); + }); + + it('should handle disapproved webhook', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue({ + query: { approved: 'false' }, + } as any); + + const result = await sendAndWaitWebhook.call(mockWebhookFunctions); + + expect(result).toEqual({ + webhookResponse: expect.any(String), + workflowData: [[{ json: { data: { approved: false } } }]], + }); + }); + }); +}); diff --git a/packages/nodes-base/utils/sendAndWait/utils.ts b/packages/nodes-base/utils/sendAndWait/utils.ts new file mode 100644 index 0000000000..fd633fe519 --- /dev/null +++ b/packages/nodes-base/utils/sendAndWait/utils.ts @@ -0,0 +1,254 @@ +import { NodeOperationError, SEND_AND_WAIT_OPERATION, updateDisplayOptions } from 'n8n-workflow'; +import type { INodeProperties, IExecuteFunctions, IWebhookFunctions } from 'n8n-workflow'; +import type { IEmail } from './interfaces'; +import { escapeHtml } from '../utilities'; +import { + ACTION_RECORDED_PAGE, + BUTTON_STYLE_PRIMARY, + BUTTON_STYLE_SECONDARY, + createEmailBody, +} from './email-templates'; + +type SendAndWaitConfig = { + title: string; + message: string; + url: string; + options: Array<{ label: string; value: string; style: string }>; +}; + +export const MESSAGE_PREFIX = 'ACTION REQUIRED: '; + +// Operation Properties ---------------------------------------------------------- +export function getSendAndWaitProperties( + targetProperties: INodeProperties[], + resource: string = 'message', + additionalProperties: INodeProperties[] = [], +) { + const buttonStyle: INodeProperties = { + displayName: 'Button Style', + name: 'buttonStyle', + type: 'options', + default: 'primary', + options: [ + { + name: 'Primary', + value: 'primary', + }, + { + name: 'Secondary', + value: 'secondary', + }, + ], + }; + const sendAndWait: INodeProperties[] = [ + ...targetProperties, + { + displayName: 'Subject', + name: 'subject', + type: 'string', + default: '', + required: true, + placeholder: 'e.g. Approval required', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + default: '', + required: true, + typeOptions: { + rows: 5, + }, + }, + { + displayName: 'Approval Options', + name: 'approvalOptions', + type: 'fixedCollection', + placeholder: 'Add option', + default: {}, + options: [ + { + displayName: 'Values', + name: 'values', + values: [ + { + displayName: 'Type of Approval', + name: 'approvalType', + type: 'options', + placeholder: 'Add option', + default: 'single', + options: [ + { + name: 'Approve Only', + value: 'single', + }, + { + name: 'Approve and Disapprove', + value: 'double', + }, + ], + }, + { + displayName: 'Approve Button Label', + name: 'approveLabel', + type: 'string', + default: 'Approve', + displayOptions: { + show: { + approvalType: ['single', 'double'], + }, + }, + }, + { + ...buttonStyle, + displayName: 'Approve Button Style', + name: 'buttonApprovalStyle', + displayOptions: { + show: { + approvalType: ['single', 'double'], + }, + }, + }, + { + displayName: 'Disapprove Button Label', + name: 'disapproveLabel', + type: 'string', + default: 'Decline', + displayOptions: { + show: { + approvalType: ['double'], + }, + }, + }, + { + ...buttonStyle, + displayName: 'Disapprove Button Style', + name: 'buttonDisapprovalStyle', + default: 'secondary', + displayOptions: { + show: { + approvalType: ['double'], + }, + }, + }, + ], + }, + ], + }, + ...additionalProperties, + { + displayName: + 'Use the wait node for more complex approval flows. More info', + name: 'useWaitNotice', + type: 'notice', + default: '', + }, + ]; + + return updateDisplayOptions( + { + show: { + resource: [resource], + operation: [SEND_AND_WAIT_OPERATION], + }, + }, + sendAndWait, + ); +} + +// Webhook Function -------------------------------------------------------------- +export async function sendAndWaitWebhook(this: IWebhookFunctions) { + const query = this.getRequestObject().query as { approved: 'false' | 'true' }; + const approved = query.approved === 'true'; + return { + webhookResponse: ACTION_RECORDED_PAGE, + workflowData: [[{ json: { data: { approved } } }]], + }; +} + +// Send and Wait Config ----------------------------------------------------------- +export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitConfig { + const message = escapeHtml((context.getNodeParameter('message', 0, '') as string).trim()); + const subject = escapeHtml(context.getNodeParameter('subject', 0, '') as string); + const resumeUrl = context.evaluateExpression('{{ $execution?.resumeUrl }}', 0) as string; + const nodeId = context.evaluateExpression('{{ $nodeId }}', 0) as string; + const approvalOptions = context.getNodeParameter('approvalOptions.values', 0, {}) as { + approvalType?: 'single' | 'double'; + approveLabel?: string; + buttonApprovalStyle?: string; + disapproveLabel?: string; + buttonDisapprovalStyle?: string; + }; + + const config: SendAndWaitConfig = { + title: subject, + message, + url: `${resumeUrl}/${nodeId}`, + options: [], + }; + + if (approvalOptions.approvalType === 'double') { + const approveLabel = escapeHtml(approvalOptions.approveLabel || 'Approve'); + const buttonApprovalStyle = approvalOptions.buttonApprovalStyle || 'primary'; + const disapproveLabel = escapeHtml(approvalOptions.disapproveLabel || 'Disapprove'); + const buttonDisapprovalStyle = approvalOptions.buttonDisapprovalStyle || 'secondary'; + + config.options.push({ + label: disapproveLabel, + value: 'false', + style: buttonDisapprovalStyle, + }); + config.options.push({ + label: approveLabel, + value: 'true', + style: buttonApprovalStyle, + }); + } else { + const label = escapeHtml(approvalOptions.approveLabel || 'Approve'); + const style = approvalOptions.buttonApprovalStyle || 'primary'; + config.options.push({ + label, + value: 'true', + style, + }); + } + + return config; +} + +function createButton(url: string, label: string, approved: string, style: string) { + let buttonStyle = BUTTON_STYLE_PRIMARY; + if (style === 'secondary') { + buttonStyle = BUTTON_STYLE_SECONDARY; + } + return `${label}`; +} + +export function createEmail(context: IExecuteFunctions) { + const to = (context.getNodeParameter('sendTo', 0, '') as string).trim(); + const config = getSendAndWaitConfig(context); + + if (to.indexOf('@') === -1 || (to.match(/@/g) || []).length > 1) { + const description = `The email address '${to}' in the 'To' field isn't valid or contains multiple addresses. Please provide only a single email address.`; + throw new NodeOperationError(context.getNode(), 'Invalid email address', { + description, + itemIndex: 0, + }); + } + + const buttons: string[] = []; + for (const option of config.options) { + buttons.push(createButton(config.url, option.label, option.value, option.style)); + } + + const instanceId = context.getInstanceId(); + + const email: IEmail = { + to, + subject: `${MESSAGE_PREFIX}${config.title}`, + body: '', + htmlBody: createEmailBody(config.message, buttons.join('\n'), instanceId), + }; + + return email; +} diff --git a/packages/nodes-base/utils/utilities.ts b/packages/nodes-base/utils/utilities.ts index 54949d87c3..5c0b7f9d2b 100644 --- a/packages/nodes-base/utils/utilities.ts +++ b/packages/nodes-base/utils/utilities.ts @@ -404,3 +404,28 @@ export const sanitizeDataPathKey = (item: IDataObject, key: string) => { } return key; }; + +/** + * Escape HTML + * + * @param {string} text The text to escape + */ +export function escapeHtml(text: string): string { + if (!text) return ''; + return text.replace(/&|<|>|'|"/g, (match) => { + switch (match) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case ''': + return "'"; + case '"': + return '"'; + default: + return match; + } + }); +} diff --git a/packages/workflow/src/Constants.ts b/packages/workflow/src/Constants.ts index 6ec9d47bb6..b9b435be4f 100644 --- a/packages/workflow/src/Constants.ts +++ b/packages/workflow/src/Constants.ts @@ -112,3 +112,5 @@ export const SINGLE_EXECUTION_NODES: { [key: string]: { [key: string]: NodeParam operation: [undefined], // default info }, }; + +export const SEND_AND_WAIT_OPERATION = 'sendAndWait'; diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 3cb7db901b..1efede36a5 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -1391,6 +1391,7 @@ export class WorkflowDataProxy { $thisItemIndex: this.itemIndex, $thisRunIndex: this.runIndex, $nodeVersion: that.workflow.getNode(that.activeNodeName)?.typeVersion, + $nodeId: that.workflow.getNode(that.activeNodeName)?.id, }; return new Proxy(base, {