From c28ccb3e5c4c58094da802b9f7c0673ef24cdaa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 30 Oct 2024 14:55:06 +0100 Subject: [PATCH] feat(Mailhook Node): New node --- .../@n8n/api-types/src/frontend-settings.ts | 1 + packages/cli/src/services/frontend.service.ts | 2 + packages/cli/src/services/url.service.ts | 4 + .../email/user-management-mailer.ts | 2 +- .../src/workflow-execute-additional-data.ts | 4 +- packages/core/src/NodeExecuteFunctions.ts | 3 + packages/editor-ui/package.json | 1 + packages/editor-ui/src/Interface.ts | 48 +--------- .../editor-ui/src/components/NodeWebhooks.vue | 16 ++++ .../src/composables/useCanvasOperations.ts | 4 +- .../src/composables/useNodeHelpers.ts | 7 ++ .../src/composables/useWorkflowHelpers.ts | 5 ++ packages/editor-ui/src/constants.ts | 7 +- .../src/plugins/i18n/locales/en.json | 8 ++ packages/editor-ui/src/stores/root.store.ts | 9 ++ .../editor-ui/src/stores/settings.store.ts | 1 + packages/editor-ui/src/views/NodeView.vue | 4 +- .../nodes/Mailhook/Mailhook.node.ts | 87 +++++++++++++++++++ .../nodes-base/nodes/Mailhook/mailhook.svg | 4 + packages/nodes-base/package.json | 1 + packages/workflow/src/Interfaces.ts | 2 + pnpm-lock.yaml | 3 + 22 files changed, 170 insertions(+), 53 deletions(-) create mode 100644 packages/nodes-base/nodes/Mailhook/Mailhook.node.ts create mode 100644 packages/nodes-base/nodes/Mailhook/mailhook.svg diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 6b2f3231d3..b18b1d9455 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -48,6 +48,7 @@ export interface FrontendSettings { }; timezone: string; urlBaseWebhook: string; + domain: string; urlBaseEditor: string; versionCli: string; nodeJsVersion: string; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 6cad4a4f24..340c7c6da3 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -100,6 +100,7 @@ export class FrontendService { workflowCallerPolicyDefaultOption: this.globalConfig.workflows.callerPolicyDefaultOption, timezone: this.globalConfig.generic.timezone, urlBaseWebhook: this.urlService.getWebhookBaseUrl(), + domain: this.urlService.getDomain(), urlBaseEditor: instanceBaseUrl, binaryDataMode: config.getEnv('binaryDataManager.mode'), nodeJsVersion: process.version.replace(/^v/, ''), @@ -250,6 +251,7 @@ export class FrontendService { // Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel` const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); this.settings.urlBaseWebhook = this.urlService.getWebhookBaseUrl(); + this.settings.domain = this.urlService.getDomain(); this.settings.urlBaseEditor = instanceBaseUrl; this.settings.oauthCallbackUrls = { oauth1: `${instanceBaseUrl}/${restEndpoint}/oauth1-credential/callback`, diff --git a/packages/cli/src/services/url.service.ts b/packages/cli/src/services/url.service.ts index 43b53f28ad..a2e77d6d9c 100644 --- a/packages/cli/src/services/url.service.ts +++ b/packages/cli/src/services/url.service.ts @@ -21,6 +21,10 @@ export class UrlService { return urlBaseWebhook; } + getDomain() { + return process.env.DOMAIN ?? new URL(this.getInstanceBaseUrl()).hostname; + } + /** Return the n8n instance base URL without trailing slash */ getInstanceBaseUrl(): string { const n8nBaseUrl = config.getEnv('editorBaseUrl') || this.getWebhookBaseUrl(); diff --git a/packages/cli/src/user-management/email/user-management-mailer.ts b/packages/cli/src/user-management/email/user-management-mailer.ts index b5df958d7d..7237de492d 100644 --- a/packages/cli/src/user-management/email/user-management-mailer.ts +++ b/packages/cli/src/user-management/email/user-management-mailer.ts @@ -202,7 +202,7 @@ export class UserManagementMailer { private get basePayload() { const baseUrl = this.urlService.getInstanceBaseUrl(); - const domain = new URL(baseUrl).hostname; + const domain = this.urlService.getDomain(); return { baseUrl, domain }; } } diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 2deae842fc..224cdf6a9a 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -971,7 +971,8 @@ export async function getBase( currentNodeParameters?: INodeParameters, executionTimeoutTimestamp?: number, ): Promise { - const urlBaseWebhook = Container.get(UrlService).getWebhookBaseUrl(); + const urlService = Container.get(UrlService); + const urlBaseWebhook = urlService.getWebhookBaseUrl(); const globalConfig = Container.get(GlobalConfig); @@ -988,6 +989,7 @@ export async function getBase( webhookBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhook, webhookWaitingBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhookWaiting, webhookTestBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhookTest, + domain: urlService.getDomain(), currentNodeParameters, executionTimeoutTimestamp, userId, diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 10c44efced..18b279b48f 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -4572,6 +4572,9 @@ export function getExecuteHookFunctions( webhookData?.isTest, ); }, + getDomain(): string { + return additionalData.domain; + }, getWebhookName(): string { if (webhookData === undefined) { throw new ApplicationError('Only supported in webhook functions'); diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 5a4280252e..2c7626375a 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -64,6 +64,7 @@ "luxon": "catalog:", "n8n-design-system": "workspace:*", "n8n-workflow": "workspace:*", + "nanoid": "catalog:", "pinia": "^2.2.4", "prettier": "^3.3.3", "qrcode.vue": "^3.3.4", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e639f537df..e0fd549ecb 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -8,7 +8,7 @@ import type { IVersionNotificationSettings, } from '@n8n/api-types'; import type { Scope } from '@n8n/permissions'; -import type { IMenuItem, NodeCreatorTag } from 'n8n-design-system'; +import type { NodeCreatorTag } from 'n8n-design-system'; import type { GenericValue, IConnections, @@ -883,6 +883,7 @@ export interface RootState { pushRef: string; urlBaseWebhook: string; urlBaseEditor: string; + domain: string; instanceId: string; binaryDataMode: 'default' | 'filesystem' | 's3'; } @@ -890,51 +891,6 @@ export interface RootState { export interface NodeMetadataMap { [nodeName: string]: INodeMetadata; } -export interface IRootState { - activeExecutions: IExecutionsCurrentSummaryExtended[]; - activeWorkflows: string[]; - activeActions: string[]; - activeCredentialType: string | null; - baseUrl: string; - defaultLocale: string; - endpointForm: string; - endpointFormTest: string; - endpointFormWaiting: string; - endpointWebhook: string; - endpointWebhookTest: string; - endpointWebhookWaiting: string; - executionId: string | null; - executingNode: string[]; - executionWaitingForWebhook: boolean; - pushConnectionActive: boolean; - saveDataErrorExecution: string; - saveDataSuccessExecution: string; - saveManualExecutions: boolean; - timezone: string; - stateIsDirty: boolean; - executionTimeout: number; - maxExecutionTimeout: number; - versionCli: string; - oauthCallbackUrls: object; - n8nMetadata: object; - workflowExecutionData: IExecutionResponse | null; - workflowExecutionPairedItemMappings: { [itemId: string]: Set }; - lastSelectedNode: string | null; - lastSelectedNodeOutputIndex: number | null; - nodeViewOffsetPosition: XYPosition; - nodeViewMoveInProgress: boolean; - selectedNodes: INodeUi[]; - pushRef: string; - urlBaseEditor: string; - urlBaseWebhook: string; - workflow: IWorkflowDb; - workflowsById: IWorkflowsMap; - sidebarMenuItems: IMenuItem[]; - instanceId: string; - nodeMetadata: NodeMetadataMap; - subworkflowExecutionError: Error | null; - binaryDataMode: string; -} export interface CommunityPackageMap { [name: string]: PublicInstalledPackage; diff --git a/packages/editor-ui/src/components/NodeWebhooks.vue b/packages/editor-ui/src/components/NodeWebhooks.vue index a23dc14561..a9bd061b98 100644 --- a/packages/editor-ui/src/components/NodeWebhooks.vue +++ b/packages/editor-ui/src/components/NodeWebhooks.vue @@ -4,6 +4,7 @@ import { useToast } from '@/composables/useToast'; import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, + MAILHOOK_TRIGGER_NODE_TYPE, OPEN_URL_PANEL_TRIGGER_NODE_TYPES, PRODUCTION_ONLY_TRIGGER_NODE_TYPES, } from '@/constants'; @@ -95,6 +96,18 @@ const baseText = computed(() => { copyMessage: i18n.baseText('nodeWebhooks.showMessage.message.formTrigger'), }; + case MAILHOOK_TRIGGER_NODE_TYPE: + return { + toggleTitle: i18n.baseText('nodeWebhooks.webhookUrls.mailhookTrigger'), + clickToDisplay: i18n.baseText('nodeWebhooks.clickToDisplayWebhookUrls.mailhookTrigger'), + clickToHide: i18n.baseText('nodeWebhooks.clickToHideWebhookUrls.mailhookTrigger'), + clickToCopy: i18n.baseText('nodeWebhooks.clickToCopyWebhookUrls.mailhookTrigger'), + testUrl: i18n.baseText('nodeWebhooks.mailhook.testEmailId'), + productionUrl: i18n.baseText('nodeWebhooks.mailhook.productionEmailId'), + copyTitle: i18n.baseText('nodeWebhooks.showMessage.title.mailhookTrigger'), + copyMessage: i18n.baseText('nodeWebhooks.showMessage.message.mailhookTrigger'), + }; + default: return { toggleTitle: i18n.baseText('nodeWebhooks.webhookUrls'), @@ -127,6 +140,9 @@ function copyWebhookUrl(webhookData: IWebhookDescription): void { function getWebhookUrlDisplay(webhookData: IWebhookDescription): string { if (props.node) { + if (props.node.type === MAILHOOK_TRIGGER_NODE_TYPE) { + return workflowHelpers.getMailhookEmailId(props.node); + } return workflowHelpers.getWebhookUrl( webhookData, props.node, diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index 70e3f048c3..0dfe36c73d 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -1085,7 +1085,7 @@ export function useCanvasOperations({ router }: { router: ReturnType n.webhookId === node.webhookId, ); if (isDuplicate) { - node.webhookId = uuid(); + nodeHelpers.assignWebhookId(node); if (node.parameters.path) { node.parameters.path = node.webhookId as string; diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts index f2f33a5ef7..d1cc8a1dbf 100644 --- a/packages/editor-ui/src/composables/useNodeHelpers.ts +++ b/packages/editor-ui/src/composables/useNodeHelpers.ts @@ -1,11 +1,13 @@ import { ref, nextTick } from 'vue'; import { useRoute } from 'vue-router'; +import { nanoid } from 'nanoid'; import { v4 as uuid } from 'uuid'; import type { Connection, ConnectionDetachedParams } from '@jsplumb/core'; import { useHistoryStore } from '@/stores/history.store'; import { CUSTOM_API_CALL_KEY, FORM_TRIGGER_NODE_TYPE, + MAILHOOK_TRIGGER_NODE_TYPE, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_FILLED_AT_EXECUTION_TIME, SPLIT_IN_BATCHES_NODE_TYPE, @@ -1247,6 +1249,10 @@ export function useNodeHelpers() { canvasStore.jsPlumbInstance?.setSuspendDrawing(false, true); } + function assignWebhookId(node: INodeUi) { + node.webhookId = node.type === MAILHOOK_TRIGGER_NODE_TYPE ? nanoid(16).toLowerCase() : uuid(); + } + return { hasProxyAuth, isCustomApiCallSelected, @@ -1281,5 +1287,6 @@ export function useNodeHelpers() { removeConnectionByConnectionInfo, addPinDataConnections, removePinDataConnections, + assignWebhookId, }; } diff --git a/packages/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/editor-ui/src/composables/useWorkflowHelpers.ts index 87492ac545..9606b70880 100644 --- a/packages/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/editor-ui/src/composables/useWorkflowHelpers.ts @@ -706,6 +706,10 @@ export function useWorkflowHelpers(options: { router: ReturnType { pushRef: randomString(10).toLowerCase(), urlBaseWebhook: 'http://localhost:5678/', urlBaseEditor: 'http://localhost:5678', + domain: 'localhost', instanceId: '', binaryDataMode: 'default', }); @@ -54,6 +55,8 @@ export const useRootStore = defineStore(STORES.ROOT, () => { () => `${state.value.urlBaseEditor}${state.value.endpointWebhookWaiting}`, ); + const domain = computed(() => state.value.domain); + const pushRef = computed(() => state.value.pushRef); const binaryDataMode = computed(() => state.value.binaryDataMode); @@ -142,6 +145,10 @@ export const useRootStore = defineStore(STORES.ROOT, () => { state.value.endpointWebhookWaiting = endpointWebhookWaiting; }; + const setDomain = (domain: string) => { + state.value.domain = domain; + }; + const setTimezone = (timezone: string) => { state.value.timezone = timezone; setGlobalState({ defaultTimezone: timezone }); @@ -189,6 +196,7 @@ export const useRootStore = defineStore(STORES.ROOT, () => { webhookUrl, webhookTestUrl, webhookWaitingUrl, + domain, restUrl, restCloudApiContext, restApiContext, @@ -213,6 +221,7 @@ export const useRootStore = defineStore(STORES.ROOT, () => { setEndpointWebhook, setEndpointWebhookTest, setEndpointWebhookWaiting, + setDomain, setTimezone, setExecutionTimeout, setMaxExecutionTimeout, diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 119fc20ac1..673f70bb3d 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -243,6 +243,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { rootStore.setEndpointWebhook(fetchedSettings.endpointWebhook); rootStore.setEndpointWebhookTest(fetchedSettings.endpointWebhookTest); rootStore.setEndpointWebhookWaiting(fetchedSettings.endpointWebhookWaiting); + rootStore.setDomain(fetchedSettings.domain); rootStore.setTimezone(fetchedSettings.timezone); rootStore.setExecutionTimeout(fetchedSettings.executionTimeout); rootStore.setMaxExecutionTimeout(fetchedSettings.maxExecutionTimeout); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 3cb0acc227..4b451573eb 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1923,7 +1923,7 @@ export default defineComponent({ this.workflowHelpers.getCurrentWorkflow().nodes, ).some((n) => n.webhookId === node.webhookId); if (isDuplicate) { - node.webhookId = uuid(); + this.nodeHelpers.assignWebhookId(node); if (node.parameters.path) { node.parameters.path = node.webhookId as string; @@ -2389,7 +2389,7 @@ export default defineComponent({ newNodeData.name = this.uniqueNodeName(localizedName); if (nodeTypeData.webhooks?.length) { - newNodeData.webhookId = uuid(); + this.nodeHelpers.assignWebhookId(newNodeData); } await this.nodeHelpers.addNodes([newNodeData], undefined, trackHistory); diff --git a/packages/nodes-base/nodes/Mailhook/Mailhook.node.ts b/packages/nodes-base/nodes/Mailhook/Mailhook.node.ts new file mode 100644 index 0000000000..3bf932df7f --- /dev/null +++ b/packages/nodes-base/nodes/Mailhook/Mailhook.node.ts @@ -0,0 +1,87 @@ +import { + IDataObject, + IWebhookFunctions, + INodeTypeDescription, + INodeType, + IWebhookResponseData, + NodeConnectionType, + IHookFunctions, +} from 'n8n-workflow'; + +// TODO: this should be picked up from a config object +const baseUrl = 'http://localhost:8080/rest/mailhooks'; +const apiRequest = async ( + context: IHookFunctions, + operation: 'checkExists' | 'create' | 'delete', +) => { + const { webhookId } = context.getNode(); + const domain = context.getDomain(); + const method = operation === 'checkExists' ? 'GET' : operation === 'create' ? 'POST' : 'DELETE'; + let url = baseUrl; + if (operation !== 'create') { + url += `/${webhookId}@${domain}`; + } + const response = await context.helpers.httpRequest({ + url, + method, + returnFullResponse: true, + ignoreHttpStatusErrors: true, + }); + return response.statusCode === 204; +}; + +export class Mailhook implements INodeType { + description: INodeTypeDescription = { + displayName: 'Mailhook', + name: 'mailhook', + icon: 'file:mailhook.svg', + group: ['trigger'], + version: 1, + description: 'Start a workflow on a Mailhook trigger', + defaults: { + name: 'Mailhook', + color: '#4363AE', + }, + inputs: [], + outputs: [NodeConnectionType.Main], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + ndvHideMethod: true, + }, + ], + properties: [], + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + return await apiRequest(this, 'checkExists'); + }, + async create(this: IHookFunctions): Promise { + return await apiRequest(this, 'create'); + }, + async delete(this: IHookFunctions): Promise { + return await apiRequest(this, 'delete'); + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + // The data to return and so start the workflow with + const returnData: IDataObject[] = []; + returnData.push({ + headers: this.getHeaderData(), + params: this.getParamsData(), + query: this.getQueryData(), + body: this.getBodyData(), + }); + + return { + workflowData: [this.helpers.returnJsonArray(returnData)], + }; + } +} diff --git a/packages/nodes-base/nodes/Mailhook/mailhook.svg b/packages/nodes-base/nodes/Mailhook/mailhook.svg new file mode 100644 index 0000000000..556b73f917 --- /dev/null +++ b/packages/nodes-base/nodes/Mailhook/mailhook.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index f81ce3b248..98fb4a9874 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -604,6 +604,7 @@ "dist/nodes/Mailcheck/Mailcheck.node.js", "dist/nodes/Mailchimp/Mailchimp.node.js", "dist/nodes/Mailchimp/MailchimpTrigger.node.js", + "dist/nodes/Mailhook/Mailhook.node.js", "dist/nodes/MailerLite/MailerLite.node.js", "dist/nodes/MailerLite/MailerLiteTrigger.node.js", "dist/nodes/Mailgun/Mailgun.node.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 4f92a219e6..35a83d8ab9 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1111,6 +1111,7 @@ export interface IHookFunctions getWebhookName(): string; getWebhookDescription(name: string): IWebhookDescription | undefined; getNodeWebhookUrl: (name: string) => string | undefined; + getDomain(): string; getNodeParameter( parameterName: string, fallbackValue?: any, @@ -2306,6 +2307,7 @@ export interface IWorkflowExecuteAdditionalData { webhookBaseUrl: string; webhookWaitingBaseUrl: string; webhookTestBaseUrl: string; + domain: string; currentNodeParameters?: INodeParameters; executionTimeoutTimestamp?: number; userId?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f527da5bb3..3fd2b0b7b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1444,6 +1444,9 @@ importers: n8n-workflow: specifier: workspace:* version: link:../workflow + nanoid: + specifier: 'catalog:' + version: 3.3.6 pinia: specifier: ^2.2.4 version: 2.2.4(typescript@5.6.2)(vue@3.5.11(typescript@5.6.2))