feat: add draft email workflow action (#17793)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
neo773
2026-02-13 22:50:20 +05:30
committed by GitHub
parent ebfaff0a53
commit 84afbb4d2c
64 changed files with 1451 additions and 614 deletions
@@ -98,6 +98,7 @@ describe('GoogleAPIScopesService', () => {
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
@@ -98,6 +98,7 @@ describe('GoogleAPIScopesService', () => {
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
@@ -9,5 +9,6 @@ export const getGoogleApisOauthScopes = () => {
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
];
};
@@ -19,4 +19,5 @@ export enum FeatureFlagKey {
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
}
@@ -21,7 +21,8 @@ import {
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
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 { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@@ -35,6 +36,7 @@ export class ActionToolProvider implements ToolProvider {
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly draftEmailTool: DraftEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly permissionsService: PermissionsService,
@@ -43,6 +45,7 @@ export class ActionToolProvider implements ToolProvider {
this.toolMap = new Map<string, Tool>([
['http_request', this.httpTool],
['send_email', this.sendEmailTool],
['draft_email', this.draftEmailTool],
['search_help_center', this.searchHelpCenterTool],
['code_interpreter', this.codeInterpreterTool],
]);
@@ -96,6 +99,13 @@ export class ActionToolProvider implements ToolProvider {
descriptors.push(
this.buildDescriptor('send_email', this.sendEmailTool, includeSchemas),
);
descriptors.push(
this.buildDescriptor(
'draft_email',
this.draftEmailTool,
includeSchemas,
),
);
}
descriptors.push(
@@ -6,14 +6,18 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
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 { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module';
@Module({
imports: [
MessagingImportManagerModule,
MessagingSendManagerModule,
TypeOrmModule.forFeature([FileEntity]),
FileModule,
JwtModule,
@@ -22,9 +26,18 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
providers: [
HttpTool,
SendEmailTool,
DraftEmailTool,
EmailComposerService,
SearchHelpCenterTool,
CodeInterpreterTool,
],
exports: [
HttpTool,
SendEmailTool,
DraftEmailTool,
EmailComposerService,
SearchHelpCenterTool,
CodeInterpreterTool,
],
exports: [HttpTool, SendEmailTool, SearchHelpCenterTool, CodeInterpreterTool],
})
export class ToolModule {}
@@ -0,0 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
@Injectable()
export class DraftEmailTool implements Tool {
private readonly logger = new Logger(DraftEmailTool.name);
description =
'Create a draft email using a connected account. The email will be saved as a draft, not sent.';
inputSchema = EmailToolInputZodSchema;
constructor(
private readonly emailComposerService: EmailComposerService,
private readonly messageOutboundService: MessagingMessageOutboundService,
) {}
async execute(
parameters: EmailToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
try {
const result = await this.emailComposerService.composeEmail(
parameters,
context,
);
if (!result.success) {
return result.output;
}
const { data } = result;
await this.createDraft(data);
this.logger.log(
`Draft created successfully for ${data.toRecipientsDisplay}${data.attachments.length > 0 ? ` with ${data.attachments.length} attachments` : ''}`,
);
return {
success: true,
message: `Draft created successfully for ${data.toRecipientsDisplay}`,
result: {
recipients: data.recipients.to,
ccRecipients: data.recipients.cc,
bccRecipients: data.recipients.bcc,
subject: data.sanitizedSubject,
connectedAccountId: data.connectedAccount.id,
attachmentCount: data.attachments.length,
},
};
} catch (error) {
if (error instanceof EmailToolException) {
return {
success: false,
message: 'Failed to create draft',
error: error.message,
};
}
this.logger.error(`Failed to create draft: ${error}`);
return {
success: false,
message: 'Failed to create draft',
error:
error instanceof Error ? error.message : 'Failed to create draft',
};
}
}
private async createDraft(data: ComposedEmail): Promise<void> {
await this.messageOutboundService.createDraft(
{
to: data.recipients.to,
cc: data.recipients.cc.length > 0 ? data.recipients.cc : undefined,
bcc: data.recipients.bcc.length > 0 ? data.recipients.bcc : undefined,
subject: data.sanitizedSubject,
body: data.plainTextBody,
html: data.sanitizedHtmlBody,
attachments: data.attachments,
},
data.connectedAccount,
);
}
}
@@ -15,37 +15,27 @@ import { z } from 'zod';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import {
SendEmailToolException,
SendEmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception';
import { SendEmailInputZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
EmailToolException,
EmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { type EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@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.';
inputSchema = SendEmailInputZodSchema;
export class EmailComposerService {
private readonly logger = new Logger(EmailComposerService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly sendMessageService: MessagingSendMessageService,
private readonly messagingAccountAuthenticationService: MessagingAccountAuthenticationService,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
@@ -57,9 +47,9 @@ export class SendEmailTool implements Tool {
workspaceId: string,
) {
if (!isValidUuid(connectedAccountId)) {
throw new SendEmailToolException(
throw new EmailToolException(
`Connected Account ID is not a valid UUID`,
SendEmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID,
EmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID,
);
}
@@ -83,9 +73,9 @@ export class SendEmailTool implements Tool {
});
if (!isDefined(connectedAccount)) {
throw new SendEmailToolException(
throw new EmailToolException(
`Connected Account '${connectedAccountId}' not found`,
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
@@ -110,9 +100,9 @@ export class SendEmailTool implements Tool {
const allAccounts = await connectedAccountRepository.find();
if (!allAccounts || allAccounts.length === 0) {
throw new SendEmailToolException(
throw new EmailToolException(
'No connected accounts found for this workspace',
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
@@ -122,7 +112,7 @@ export class SendEmailTool implements Tool {
);
}
private normalizeRecipients(parameters: SendEmailInput): {
private normalizeRecipients(parameters: EmailToolInput): {
to: string[];
cc: string[];
bcc: string[];
@@ -132,18 +122,18 @@ export class SendEmailTool implements Tool {
!parameters.recipients.to ||
parameters.recipients.to.trim().length === 0
) {
throw new SendEmailToolException(
throw new EmailToolException(
'No recipients specified',
SendEmailToolExceptionCode.INVALID_EMAIL,
EmailToolExceptionCode.INVALID_EMAIL,
);
}
const to = parseCommaSeparatedEmails(parameters.recipients.to);
if (to.length === 0) {
throw new SendEmailToolException(
throw new EmailToolException(
'No valid recipients specified',
SendEmailToolExceptionCode.INVALID_EMAIL,
EmailToolExceptionCode.INVALID_EMAIL,
);
}
@@ -202,9 +192,9 @@ export class SendEmailTool implements Tool {
}
if (filesNotFound.length > 0) {
throw new SendEmailToolException(
throw new EmailToolException(
`Files not found: ${filesNotFound.join(', ')}`,
SendEmailToolExceptionCode.FILE_NOT_FOUND,
EmailToolExceptionCode.FILE_NOT_FOUND,
);
}
@@ -235,10 +225,10 @@ export class SendEmailTool implements Tool {
return attachments;
}
async execute(
parameters: SendEmailInput,
async composeEmail(
parameters: EmailToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
): Promise<EmailComposerResult> {
const { workspaceId } = context;
const { subject, body, files } = parameters;
let { connectedAccountId } = parameters;
@@ -248,11 +238,16 @@ export class SendEmailTool implements Tool {
try {
recipients = this.normalizeRecipients(parameters);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Invalid recipients';
return {
success: false,
message: 'No recipients specified',
error:
error instanceof Error ? error.message : 'No recipients specified',
output: {
success: false,
message: errorMessage,
error: errorMessage,
},
};
}
@@ -261,108 +256,76 @@ export class SendEmailTool implements Tool {
if (invalidEmails.length > 0) {
return {
success: false,
message: `Invalid email addresses: ${invalidEmails.join(', ')}`,
error: `Invalid email addresses: ${invalidEmails.join(', ')}`,
output: {
success: false,
message: `Invalid email addresses: ${invalidEmails.join(', ')}`,
error: `Invalid email addresses: ${invalidEmails.join(', ')}`,
},
};
}
const toRecipientsDisplay = recipients.to.join(', ');
try {
if (!connectedAccountId) {
connectedAccountId =
await this.getOrThrowFirstConnectedAccountId(workspaceId);
}
const connectedAccount = await this.getConnectedAccount(
connectedAccountId,
workspaceId,
);
const messageChannel = connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
);
if (!isDefined(messageChannel)) {
throw new SendEmailToolException(
`No message channel found for connected account '${connectedAccountId}'`,
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
const { accessToken, refreshToken } =
await this.messagingAccountAuthenticationService.validateAndRefreshConnectedAccountAuthentication(
{
connectedAccount,
workspaceId,
messageChannelId: messageChannel.id,
},
);
const connectedAccountWithFreshTokens = {
...connectedAccount,
accessToken,
refreshToken,
};
const attachments = await this.getAttachments(files || [], workspaceId);
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
const htmlBody = await render(reactMarkup);
const textBody = toPlainText(htmlBody);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const safeHtmlBody = purify.sanitize(htmlBody || '');
const safeSubject = purify.sanitize(subject || '');
await this.sendMessageService.sendMessage(
{
to: recipients.to,
cc: recipients.cc.length > 0 ? recipients.cc : undefined,
bcc: recipients.bcc.length > 0 ? recipients.bcc : undefined,
subject: safeSubject,
body: textBody,
html: safeHtmlBody,
attachments,
},
connectedAccountWithFreshTokens,
);
this.logger.log(
`Email sent successfully to ${toRecipientsDisplay}${attachments.length > 0 ? ` with ${attachments.length} attachments` : ''}`,
);
return {
success: true,
message: `Email sent successfully to ${toRecipientsDisplay}`,
result: {
recipients: recipients.to,
ccRecipients: recipients.cc,
bccRecipients: recipients.bcc,
subject: safeSubject,
connectedAccountId,
attachmentCount: attachments.length,
},
};
} catch (error) {
if (error instanceof SendEmailToolException) {
return {
success: false,
message: `Failed to send email to ${toRecipientsDisplay}`,
error: error.message,
};
}
this.logger.error(`Failed to send email: ${error}`);
return {
success: false,
message: `Failed to send email to ${toRecipientsDisplay}`,
error: error instanceof Error ? error.message : 'Failed to send email',
};
if (!connectedAccountId) {
connectedAccountId =
await this.getOrThrowFirstConnectedAccountId(workspaceId);
}
const connectedAccount = await this.getConnectedAccount(
connectedAccountId,
workspaceId,
);
const messageChannel = connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
);
if (!isDefined(messageChannel)) {
throw new EmailToolException(
`No message channel found for connected account '${connectedAccountId}'`,
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
const { accessToken, refreshToken } =
await this.messagingAccountAuthenticationService.validateAndRefreshConnectedAccountAuthentication(
{
connectedAccount,
workspaceId,
messageChannelId: messageChannel.id,
},
);
const connectedAccountWithFreshTokens = {
...connectedAccount,
accessToken,
refreshToken,
};
const attachments = await this.getAttachments(files || [], workspaceId);
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
const htmlBody = await render(reactMarkup);
const plainTextBody = toPlainText(htmlBody);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const sanitizedHtmlBody = purify.sanitize(htmlBody || '');
const sanitizedSubject = purify.sanitize(subject || '');
return {
success: true,
data: {
recipients,
toRecipientsDisplay,
sanitizedSubject,
plainTextBody,
sanitizedHtmlBody,
attachments,
connectedAccount: connectedAccountWithFreshTokens,
},
};
}
}
@@ -19,7 +19,7 @@ const EmailRecipientsZodSchema = z.object({
.default(''),
});
export const SendEmailInputZodSchema = z.object({
export const EmailToolInputZodSchema = z.object({
recipients: EmailRecipientsZodSchema.describe(
'Recipients object with to, cc, and bcc fields (comma-separated)',
),
@@ -4,7 +4,7 @@ import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum SendEmailToolExceptionCode {
export enum EmailToolExceptionCode {
INVALID_CONNECTED_ACCOUNT_ID = 'INVALID_CONNECTED_ACCOUNT_ID',
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
INVALID_EMAIL = 'INVALID_EMAIL',
@@ -13,37 +13,36 @@ export enum SendEmailToolExceptionCode {
INVALID_FILE_ID = 'INVALID_FILE_ID',
}
const getSendEmailToolExceptionUserFriendlyMessage = (
code: SendEmailToolExceptionCode,
const getEmailToolExceptionUserFriendlyMessage = (
code: EmailToolExceptionCode,
) => {
switch (code) {
case SendEmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID:
case EmailToolExceptionCode.INVALID_CONNECTED_ACCOUNT_ID:
return msg`Invalid connected account ID.`;
case SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
case EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
return msg`Connected account not found.`;
case SendEmailToolExceptionCode.INVALID_EMAIL:
case EmailToolExceptionCode.INVALID_EMAIL:
return msg`Invalid email address.`;
case SendEmailToolExceptionCode.WORKSPACE_ID_NOT_FOUND:
case EmailToolExceptionCode.WORKSPACE_ID_NOT_FOUND:
return msg`Workspace not found.`;
case SendEmailToolExceptionCode.FILE_NOT_FOUND:
case EmailToolExceptionCode.FILE_NOT_FOUND:
return msg`File not found.`;
case SendEmailToolExceptionCode.INVALID_FILE_ID:
case EmailToolExceptionCode.INVALID_FILE_ID:
return msg`Invalid file ID.`;
default:
assertUnreachable(code);
}
};
export class SendEmailToolException extends CustomException<SendEmailToolExceptionCode> {
export class EmailToolException extends CustomException<EmailToolExceptionCode> {
constructor(
message: string,
code: SendEmailToolExceptionCode,
code: EmailToolExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getSendEmailToolExceptionUserFriendlyMessage(code),
userFriendlyMessage ?? getEmailToolExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,95 @@
import { Injectable, Logger } from '@nestjs/common';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.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.';
inputSchema = EmailToolInputZodSchema;
constructor(
private readonly emailComposerService: EmailComposerService,
private readonly messageOutboundService: MessagingMessageOutboundService,
) {}
async execute(
parameters: EmailToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
try {
const result = await this.emailComposerService.composeEmail(
parameters,
context,
);
if (!result.success) {
return result.output;
}
const { data } = result;
await this.sendEmail(data);
this.logger.log(
`Email sent successfully to ${data.toRecipientsDisplay}${data.attachments.length > 0 ? ` with ${data.attachments.length} attachments` : ''}`,
);
return {
success: true,
message: `Email sent successfully to ${data.toRecipientsDisplay}`,
result: {
recipients: data.recipients.to,
ccRecipients: data.recipients.cc,
bccRecipients: data.recipients.bcc,
subject: data.sanitizedSubject,
connectedAccountId: data.connectedAccount.id,
attachmentCount: data.attachments.length,
},
};
} catch (error) {
if (error instanceof EmailToolException) {
return {
success: false,
message: 'Failed to send email',
error: error.message,
};
}
this.logger.error(`Failed to send email: ${error}`);
return {
success: false,
message: 'Failed to send email',
error: error instanceof Error ? error.message : 'Failed to send email',
};
}
}
private async sendEmail(data: ComposedEmail): Promise<void> {
await this.messageOutboundService.sendMessage(
{
to: data.recipients.to,
cc: data.recipients.cc.length > 0 ? data.recipients.cc : undefined,
bcc: data.recipients.bcc.length > 0 ? data.recipients.bcc : undefined,
subject: data.sanitizedSubject,
body: data.plainTextBody,
html: data.sanitizedHtmlBody,
attachments: data.attachments,
},
data.connectedAccount,
);
}
}
@@ -0,0 +1,12 @@
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
export type ComposedEmail = {
recipients: { to: string[]; cc: string[]; bcc: string[] };
toRecipientsDisplay: string;
sanitizedSubject: string;
plainTextBody: string;
sanitizedHtmlBody: string;
attachments: MessageAttachment[];
connectedAccount: ConnectedAccountWorkspaceEntity;
};
@@ -0,0 +1,7 @@
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type ComposedEmail } from './composed-email.type';
export type EmailComposerResult =
| { success: true; data: ComposedEmail }
| { success: false; output: ToolOutput };
@@ -0,0 +1,5 @@
import { type z } from 'zod';
import { type EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
export type EmailToolInput = z.infer<typeof EmailToolInputZodSchema>;
@@ -1,4 +1,4 @@
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
describe('SendEmailTool - parseCommaSeparatedEmails', () => {
it('should parse comma-separated emails into array', () => {
@@ -1,5 +0,0 @@
import { type z } from 'zod';
import { type SendEmailInputZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
export type SendEmailInput = z.infer<typeof SendEmailInputZodSchema>;
@@ -244,6 +244,7 @@ describe('WorkspaceEntityManager', () => {
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
IS_FILES_FIELD_MIGRATED: false,
IS_DRAFT_EMAIL_ENABLED: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {