diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/__tests__/tool-executor-workflow-action.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/__tests__/tool-executor-workflow-action.spec.ts new file mode 100644 index 00000000000..62658b3a397 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/__tests__/tool-executor-workflow-action.spec.ts @@ -0,0 +1,192 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool'; +import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool'; +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { ToolExecutorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action'; +import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; +import { + type WorkflowAction, + WorkflowActionType, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +jest.mock( + 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util', + () => ({ + renderRichTextToHtml: jest.fn().mockResolvedValue('

rendered html

'), + }), +); + +const { renderRichTextToHtml } = jest.requireMock( + 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util', +); + +const baseSettings: WorkflowActionSettings = { + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + input: {}, +}; + +const emailInput = { + connectedAccountId: 'account-1', + recipients: { to: 'test@example.com' }, + subject: 'Test', +}; + +const buildEmailStep = ( + type: 'SEND_EMAIL' | 'DRAFT_EMAIL', + input: Record, +): WorkflowAction => + ({ + id: 'step-1', + type: WorkflowActionType[type], + name: type === 'SEND_EMAIL' ? 'Send Email' : 'Draft Email', + valid: true, + settings: { ...baseSettings, input }, + }) as WorkflowAction; + +describe('ToolExecutorWorkflowAction', () => { + let action: ToolExecutorWorkflowAction; + let mockSendEmailTool: jest.Mocked>; + let mockDraftEmailTool: jest.Mocked>; + + beforeEach(async () => { + jest.clearAllMocks(); + + const toolResult = { + result: { success: true }, + error: undefined, + }; + + mockSendEmailTool = { execute: jest.fn().mockResolvedValue(toolResult) }; + mockDraftEmailTool = { execute: jest.fn().mockResolvedValue(toolResult) }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ToolExecutorWorkflowAction, + { provide: HttpTool, useValue: { execute: jest.fn() } }, + { provide: SendEmailTool, useValue: mockSendEmailTool }, + { provide: DraftEmailTool, useValue: mockDraftEmailTool }, + ], + }).compile(); + + action = module.get(ToolExecutorWorkflowAction); + }); + + const executeWithBody = ( + body: string | undefined, + type: 'SEND_EMAIL' | 'DRAFT_EMAIL' = 'SEND_EMAIL', + ) => + action.execute({ + currentStepId: 'step-1', + steps: [buildEmailStep(type, { ...emailInput, body })], + context: { + trigger: { + name: 'John', + email: 'john@example.com', + }, + }, + runInfo: { + workspaceId: 'workspace-1', + workflowRunId: 'run-1', + }, + }); + + describe('email body handling', () => { + it('should render TipTap JSON body to HTML', async () => { + const tipTapBody = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world' }], + }, + ], + }); + + await executeWithBody(tipTapBody); + + expect(renderRichTextToHtml).toHaveBeenCalledWith(JSON.parse(tipTapBody)); + expect(mockSendEmailTool.execute).toHaveBeenCalledWith( + expect.objectContaining({ body: '

rendered html

' }), + expect.any(Object), + ); + }); + + it('should resolve variableTag nodes inside TipTap JSON before rendering', async () => { + const tipTapBodyWithVariable = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'variableTag', + attrs: { variable: '{{trigger.name}}' }, + }, + ], + }, + ], + }); + + await executeWithBody(tipTapBodyWithVariable); + + expect(renderRichTextToHtml).toHaveBeenCalledWith({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'John' }], + }, + ], + }); + }); + + it('should pass plain text body through without rendering', async () => { + await executeWithBody('{{trigger.name}}\n{{trigger.email}}'); + + expect(renderRichTextToHtml).not.toHaveBeenCalled(); + expect(mockSendEmailTool.execute).toHaveBeenCalledWith( + expect.objectContaining({ body: 'John\njohn@example.com' }), + expect.any(Object), + ); + }); + + it('should treat non-TipTap JSON as plain text', async () => { + await executeWithBody('{"key":"value"}'); + + expect(renderRichTextToHtml).not.toHaveBeenCalled(); + expect(mockSendEmailTool.execute).toHaveBeenCalledWith( + expect.objectContaining({ body: '{"key":"value"}' }), + expect.any(Object), + ); + }); + + it('should handle empty string body without crashing', async () => { + await executeWithBody(''); + + expect(renderRichTextToHtml).not.toHaveBeenCalled(); + expect(mockSendEmailTool.execute).toHaveBeenCalled(); + }); + + it('should handle undefined body without crashing', async () => { + await executeWithBody(undefined); + + expect(renderRichTextToHtml).not.toHaveBeenCalled(); + expect(mockSendEmailTool.execute).toHaveBeenCalled(); + }); + + it('should apply the same body handling for DRAFT_EMAIL', async () => { + await executeWithBody('{{trigger.name}}', 'DRAFT_EMAIL'); + + expect(renderRichTextToHtml).not.toHaveBeenCalled(); + expect(mockDraftEmailTool.execute).toHaveBeenCalledWith( + expect.objectContaining({ body: 'John' }), + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts index b096ddc5e4e..bf7e5e9c311 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts @@ -1,6 +1,12 @@ import { Injectable } from '@nestjs/common'; +import type { JSONContent } from '@tiptap/core'; -import { resolveInput, resolveRichTextVariables } from 'twenty-shared/utils'; +import { + isDefined, + parseJson, + resolveInput, + resolveRichTextVariables, +} from 'twenty-shared/utils'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; @@ -35,6 +41,22 @@ export class ToolExecutorWorkflowAction implements WorkflowAction { ]); } + private async resolveEmailBody( + body: string, + context: Record, + ): Promise { + const bodyWithResolvedVariables = resolveRichTextVariables(body, context); + const tipTapDocument = isDefined(bodyWithResolvedVariables) + ? parseJson(bodyWithResolvedVariables) + : null; + + if (isDefined(tipTapDocument) && tipTapDocument.type === 'doc') { + return renderRichTextToHtml(tipTapDocument); + } + + return bodyWithResolvedVariables ?? body; + } + async execute({ currentStepId, steps, @@ -67,14 +89,11 @@ export class ToolExecutorWorkflowAction implements WorkflowAction { ) { const emailInput = toolInput as WorkflowSendEmailActionInput; - if (emailInput.body) { - const resolvedBody = resolveRichTextVariables(emailInput.body, context); - const bodyJson = JSON.parse(resolvedBody!); - const htmlBody = await renderRichTextToHtml(bodyJson); - + if (isDefined(emailInput.body)) { + const emailBody = await this.resolveEmailBody(emailInput.body, context); toolInput = { ...emailInput, - body: htmlBody, + body: emailBody, }; } }