Compare commits

...

2 Commits

Author SHA1 Message Date
neo773 396565654a fix send email workflow
fixes JSON parse crash when body is rich text with variables
2026-04-21 16:45:21 +05:30
neo773 ea630d25a9 Revert "fix compute folders to update util (#19749)"
This reverts commit 64470baa1e.
2026-04-21 13:13:14 +05:30
5 changed files with 161 additions and 93 deletions
@@ -370,7 +370,6 @@ describe('SyncMessageFoldersService', () => {
id: 'folder-1',
externalId: 'inbox-ext',
name: 'INBOX',
isSynced: true,
isSentFolder: false,
parentFolderId: null,
});
@@ -378,7 +377,6 @@ describe('SyncMessageFoldersService', () => {
createMockDiscoveredFolder({
externalId: 'inbox-ext',
name: 'INBOX',
isSynced: true,
isSentFolder: false,
parentFolderId: null,
}),
@@ -476,7 +474,6 @@ describe('SyncMessageFoldersService', () => {
createMockDiscoveredFolder({
externalId: 'unchanged-ext',
name: 'Unchanged',
isSynced: true,
}),
createMockDiscoveredFolder({
externalId: 'new-ext',
@@ -537,7 +534,6 @@ describe('SyncMessageFoldersService', () => {
createMockDiscoveredFolder({
externalId: 'inbox-ext',
name: 'INBOX',
isSynced: true,
}),
];
const messageChannel = createMockMessageChannel({
@@ -93,72 +93,6 @@ describe('computeFoldersToUpdate', () => {
expect(result.size).toBe(0);
});
it('should detect isSynced change from false to true', () => {
const discoveredFolders = [
{
name: 'Inbox',
externalId: 'INBOX',
isSynced: true,
isSentFolder: false,
parentFolderId: null,
},
];
const existingFolders = [
{
id: 'folder-id',
name: 'Inbox',
externalId: 'INBOX',
isSynced: false,
isSentFolder: false,
parentFolderId: null,
syncCursor: 'cursor',
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
];
const result = computeFoldersToUpdate({
discoveredFolders,
existingFolders,
});
expect(result.size).toBe(1);
expect(result.get('folder-id')?.isSynced).toBe(true);
});
it('should detect isSynced change from true to false', () => {
const discoveredFolders = [
{
name: 'Promotions',
externalId: 'promo-1',
isSynced: false,
isSentFolder: false,
parentFolderId: null,
},
];
const existingFolders = [
{
id: 'folder-id',
name: 'Promotions',
externalId: 'promo-1',
isSynced: true,
isSentFolder: false,
parentFolderId: null,
syncCursor: 'cursor',
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
];
const result = computeFoldersToUpdate({
discoveredFolders,
existingFolders,
});
expect(result.size).toBe(1);
expect(result.get('folder-id')?.isSynced).toBe(false);
});
it('should treat empty string parentFolderId same as null', () => {
const discoveredFolders = [
{
@@ -14,24 +14,12 @@ export const computeFoldersToUpdate = ({
}: {
discoveredFolders: DiscoveredMessageFolder[];
existingFolders: MessageFolder[];
}): Map<
string,
Pick<
MessageFolderEntity,
'name' | 'isSynced' | 'isSentFolder' | 'parentFolderId'
>
> => {
}): Map<string, Partial<MessageFolderEntity>> => {
const existingFoldersByExternalId = new Map(
existingFolders.map((folder) => [folder.externalId, folder]),
);
const foldersToUpdate = new Map<
string,
Pick<
MessageFolderEntity,
'name' | 'isSynced' | 'isSentFolder' | 'parentFolderId'
>
>();
const foldersToUpdate = new Map<string, Partial<MessageFolderEntity>>();
for (const discoveredFolder of discoveredFolders) {
const existingFolder = existingFoldersByExternalId.get(
@@ -48,14 +36,12 @@ export const computeFoldersToUpdate = ({
const discoveredFolderData = {
name: discoveredFolder.name,
isSynced: discoveredFolder.isSynced,
isSentFolder: discoveredFolder.isSentFolder,
parentFolderId,
};
const existingFolderData = {
name: existingFolder.name,
isSynced: existingFolder.isSynced,
isSentFolder: existingFolder.isSentFolder,
parentFolderId: isNonEmptyString(existingFolder.parentFolderId)
? existingFolder.parentFolderId
@@ -0,0 +1,133 @@
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 { 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('<p>rendered html</p>'),
}),
);
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 buildSendEmailStep = (input: Record<string, unknown>) => ({
id: 'step-1',
type: WorkflowActionType.SEND_EMAIL as const,
name: 'Send Email',
valid: true,
settings: { ...baseSettings, input },
});
describe('ToolExecutorWorkflowAction', () => {
let action: ToolExecutorWorkflowAction;
let mockSendEmailTool: jest.Mocked<Pick<SendEmailTool, 'execute'>>;
beforeEach(async () => {
jest.clearAllMocks();
mockSendEmailTool = {
execute: jest.fn().mockResolvedValue({
result: { success: true },
error: undefined,
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ToolExecutorWorkflowAction,
{ provide: HttpTool, useValue: { execute: jest.fn() } },
{ provide: SendEmailTool, useValue: mockSendEmailTool },
{ provide: DraftEmailTool, useValue: { execute: jest.fn() } },
],
}).compile();
action = module.get(ToolExecutorWorkflowAction);
});
const executeWithBody = (body: string | undefined) =>
action.execute({
currentStepId: 'step-1',
steps: [
buildSendEmailStep({
connectedAccountId: 'account-1',
recipients: { to: 'test@example.com' },
subject: 'Test',
body,
}),
],
context: {
trigger: {
name: 'John',
email: 'john@example.com',
},
},
runInfo: {
workspaceId: 'workspace-1',
workflowRunId: 'run-1',
attemptCount: 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: '<p>rendered html</p>' }),
expect.any(Object),
);
});
it('should pass plain text body through without rendering', async () => {
const plainTextBody =
'{{trigger.name}}\n{{trigger.email}}';
await executeWithBody(plainTextBody);
expect(renderRichTextToHtml).not.toHaveBeenCalled();
expect(mockSendEmailTool.execute).toHaveBeenCalledWith(
expect.objectContaining({ body: 'John\njohn@example.com' }),
expect.any(Object),
);
});
it('should not crash when body is undefined', async () => {
await executeWithBody(undefined);
expect(renderRichTextToHtml).not.toHaveBeenCalled();
expect(mockSendEmailTool.execute).toHaveBeenCalled();
});
});
});
@@ -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 renderEmailBody(
body: string,
context: Record<string, unknown>,
): Promise<string> {
const bodyWithResolvedVariables = resolveRichTextVariables(body, context);
const tipTapDocument = isDefined(bodyWithResolvedVariables)
? parseJson<JSONContent>(bodyWithResolvedVariables)
: null;
if (isDefined(tipTapDocument) && isDefined(tipTapDocument.type)) {
return renderRichTextToHtml(tipTapDocument);
}
return 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.renderEmailBody(emailInput.body, context);
toolInput = {
...emailInput,
body: htmlBody,
body: emailBody,
};
}
}