Decouple Send Email node from workflows (#13322)

- Renamed `WorkflowActionAdapter` to `ToolExecutorWorkflowAction`
- Renamed `settingPermission` table to `permissionFlag` and `setting`
column to `flag`
- Decoupled the send email logic from workflows to tools
- Add new `Tools Permission` section in FE

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-07-24 16:01:33 +05:30
committed by GitHub
parent eb404478c3
commit e93adde4b8
98 changed files with 1076 additions and 705 deletions
@@ -1,7 +0,0 @@
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { Tool } from 'src/engine/core-modules/tool/types/tool.type';
export const TOOLS: Map<ToolType, Tool> = new Map([
[ToolType.HTTP_REQUEST, new HttpTool()],
]);
@@ -1,3 +1,4 @@
export enum ToolType {
HTTP_REQUEST = 'HTTP_REQUEST',
SEND_EMAIL = 'SEND_EMAIL',
}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Injectable()
export class ToolRegistryService {
private readonly toolFactories: Map<ToolType, () => Tool>;
constructor(private readonly sendEmailTool: SendEmailTool) {
this.toolFactories = new Map<ToolType, () => Tool>([
[ToolType.HTTP_REQUEST, () => new HttpTool()],
[
ToolType.SEND_EMAIL,
() => ({
description: this.sendEmailTool.description,
parameters: this.sendEmailTool.parameters,
execute: (params) =>
this.sendEmailTool.execute(params as SendEmailInput),
flag: PermissionFlagType.SEND_EMAIL_TOOL,
}),
],
]);
}
getTool(toolType: ToolType): Tool {
const factory = this.toolFactories.get(toolType);
if (!factory) {
throw new Error(`Unknown tool type: ${toolType}`);
}
return factory();
}
getAllToolTypes(): ToolType[] {
return Array.from(this.toolFactories.keys());
}
}
@@ -0,0 +1,14 @@
import { CustomException } from 'src/utils/custom-exception';
export class SendEmailToolException extends CustomException {
constructor(message: string, code: SendEmailToolExceptionCode) {
super(message, code);
}
}
export enum SendEmailToolExceptionCode {
INVALID_CONNECTED_ACCOUNT_ID = 'INVALID_CONNECTED_ACCOUNT_ID',
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
INVALID_EMAIL = 'INVALID_EMAIL',
WORKSPACE_ID_NOT_FOUND = 'WORKSPACE_ID_NOT_FOUND',
}
@@ -0,0 +1,23 @@
import { z } from 'zod';
export const SendEmailInputZodSchema = z.object({
email: z.string().email().describe('The recipient email address'),
subject: z.string().describe('The email subject line'),
body: z.string().describe('The email body content (HTML or plain text)'),
connectedAccountId: z
.string()
.uuid()
.describe(
'The UUID of the connected account to send the email from. Provide this only if you have it; otherwise, leave blank.',
)
.optional(),
});
export const SendEmailToolParametersZodSchema = z.object({
toolDescription: z
.string()
.describe(
"A clear, human-readable status message describing the email being sent. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Sending email to customer about order status'). Explain what email you are sending and to whom in natural language.",
),
input: SendEmailInputZodSchema,
});
@@ -0,0 +1,155 @@
import { Injectable, Logger } from '@nestjs/common';
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { z } from 'zod';
import {
SendEmailToolException,
SendEmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception';
import { SendEmailToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
import { SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
@Injectable()
export class SendEmailTool implements Tool {
private readonly logger = new Logger(SendEmailTool.name);
description =
'Send an email using a connected account. Requires SEND_EMAIL_TOOL permission.';
parameters = SendEmailToolParametersZodSchema;
constructor(
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly sendMessageService: MessagingSendMessageService,
) {}
private async getConnectedAccount(
connectedAccountId: string,
workspaceId: string,
) {
if (!isValidUuid(connectedAccountId)) {
throw new SendEmailToolException(
`Connected Account ID is not a valid UUID`,
SendEmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID,
);
}
const connectedAccountRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount = await connectedAccountRepository.findOneBy({
id: connectedAccountId,
});
if (!isDefined(connectedAccount)) {
throw new SendEmailToolException(
`Connected Account '${connectedAccountId}' not found`,
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
return connectedAccount;
}
private async getOrThrowFirstConnectedAccountId(
workspaceId: string,
): Promise<string> {
const connectedAccountRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const allAccounts = await connectedAccountRepository.find();
if (!allAccounts || allAccounts.length === 0) {
throw new SendEmailToolException(
'No connected accounts found for this workspace',
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
return allAccounts[0].id;
}
async execute(parameters: SendEmailInput): Promise<ToolOutput> {
const { workspaceId } = this.scopedWorkspaceContextFactory.create();
const { email, subject, body } = parameters;
let { connectedAccountId } = parameters;
try {
const emailSchema = z.string().trim().email('Invalid email');
const emailValidation = emailSchema.safeParse(email);
if (!emailValidation.success) {
throw new SendEmailToolException(
`Email '${email}' is invalid`,
SendEmailToolExceptionCode.INVALID_EMAIL,
);
}
if (!workspaceId) {
throw new SendEmailToolException(
'Workspace ID not found',
SendEmailToolExceptionCode.WORKSPACE_ID_NOT_FOUND,
);
}
if (!connectedAccountId) {
connectedAccountId =
await this.getOrThrowFirstConnectedAccountId(workspaceId);
}
const connectedAccount = await this.getConnectedAccount(
connectedAccountId,
workspaceId,
);
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const safeBody = purify.sanitize(body || '');
const safeSubject = purify.sanitize(subject || '');
await this.sendMessageService.sendMessage(
{
to: email,
subject: safeSubject,
body: safeBody,
},
connectedAccount,
);
this.logger.log(`Email sent successfully to ${email}`);
return {
result: {
success: true,
message: `Email sent successfully to ${email}`,
},
};
} catch (error) {
if (error instanceof SendEmailToolException) {
return {
error: error.message,
};
}
this.logger.error(`Failed to send email: ${error}`);
return {
error: error instanceof Error ? error.message : 'Failed to send email',
};
}
}
}
@@ -0,0 +1,5 @@
import { z } from 'zod';
import { SendEmailInputZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
export type SendEmailInput = z.infer<typeof SendEmailInputZodSchema>;
@@ -3,9 +3,11 @@ import { ZodType } from 'zod';
import { ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
export type Tool = {
description: string;
parameters: JSONSchema7 | ZodType;
execute(input: ToolInput): Promise<ToolOutput>;
flag?: PermissionFlagType;
};