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:
+1
@@ -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',
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+1
@@ -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',
|
||||
];
|
||||
};
|
||||
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
+11
-1
@@ -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 {}
|
||||
|
||||
+96
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+98
-135
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)',
|
||||
),
|
||||
+12
-13
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+95
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
};
|
||||
+7
@@ -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 };
|
||||
+5
@@ -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
-1
@@ -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', () => {
|
||||
|
||||
-5
@@ -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>;
|
||||
+1
@@ -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: {
|
||||
|
||||
+3
@@ -10,6 +10,7 @@ import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects
|
||||
import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
|
||||
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
|
||||
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
|
||||
import { ImapFindDraftsFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service';
|
||||
import { ImapFindSentFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service';
|
||||
import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service';
|
||||
import { ImapGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service';
|
||||
@@ -39,6 +40,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
|
||||
ImapMessagesImportErrorHandler,
|
||||
ImapSyncService,
|
||||
ImapMessageParserService,
|
||||
ImapFindDraftsFolderService,
|
||||
ImapFindSentFolderService,
|
||||
ImapMessageTextExtractorService,
|
||||
],
|
||||
@@ -46,6 +48,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
|
||||
ImapGetMessagesService,
|
||||
ImapGetMessageListService,
|
||||
ImapClientProvider,
|
||||
ImapFindDraftsFolderService,
|
||||
ImapFindSentFolderService,
|
||||
],
|
||||
})
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type ImapFlow, type ListResponse } from 'imapflow';
|
||||
|
||||
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
|
||||
import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-manager/drivers/utils/get-standard-folder-by-regex';
|
||||
|
||||
type DraftsFolderResult = {
|
||||
name: string;
|
||||
path: string;
|
||||
} | null;
|
||||
|
||||
@Injectable()
|
||||
export class ImapFindDraftsFolderService {
|
||||
private readonly logger = new Logger(ImapFindDraftsFolderService.name);
|
||||
|
||||
public async findOrCreateDraftsFolder(
|
||||
client: ImapFlow,
|
||||
): Promise<DraftsFolderResult> {
|
||||
try {
|
||||
const list = await client.list();
|
||||
|
||||
const specialUseDraftsFolder = this.findDraftsFolderBySpecialUse(list);
|
||||
|
||||
if (specialUseDraftsFolder) {
|
||||
return specialUseDraftsFolder;
|
||||
}
|
||||
|
||||
const regexDraftsFolder = this.findDraftsFolderByRegex(list);
|
||||
|
||||
if (regexDraftsFolder) {
|
||||
return regexDraftsFolder;
|
||||
}
|
||||
|
||||
return await this.createDraftsFolder(client);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error finding drafts folder: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private findDraftsFolderBySpecialUse(
|
||||
list: ListResponse[],
|
||||
): DraftsFolderResult {
|
||||
for (const folder of list) {
|
||||
if (folder.specialUse && folder.specialUse.includes('\\Drafts')) {
|
||||
this.logger.debug(
|
||||
`Found drafts folder via special-use flag: ${folder.path}`,
|
||||
);
|
||||
|
||||
return {
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private findDraftsFolderByRegex(list: ListResponse[]): DraftsFolderResult {
|
||||
for (const folder of list) {
|
||||
if (getStandardFolderByRegex(folder.name) === StandardFolder.DRAFTS) {
|
||||
this.logger.debug(
|
||||
`Found drafts folder via pattern match: ${folder.path}`,
|
||||
);
|
||||
|
||||
return {
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createDraftsFolder(
|
||||
client: ImapFlow,
|
||||
): Promise<DraftsFolderResult> {
|
||||
try {
|
||||
await client.mailboxCreate('Drafts');
|
||||
|
||||
this.logger.debug('Created drafts folder: Drafts');
|
||||
|
||||
return {
|
||||
name: 'Drafts',
|
||||
path: 'Drafts',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create drafts folder: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -68,7 +68,7 @@ export class ImapFindSentFolderService {
|
||||
): Promise<SentFolderResult> {
|
||||
for (const folder of list) {
|
||||
if (folder.specialUse && folder.specialUse.includes('\\Sent')) {
|
||||
this.logger.log(
|
||||
this.logger.debug(
|
||||
`Found sent folder via special-use flag: ${folder.path}`,
|
||||
);
|
||||
|
||||
@@ -108,7 +108,7 @@ export class ImapFindSentFolderService {
|
||||
);
|
||||
|
||||
if (messageCount > 0) {
|
||||
this.logger.log(
|
||||
this.logger.debug(
|
||||
`Selected sent folder via pattern match: ${folder.path}`,
|
||||
);
|
||||
|
||||
@@ -120,7 +120,7 @@ export class ImapFindSentFolderService {
|
||||
}
|
||||
|
||||
if (regexCandidateFolders.length > 0) {
|
||||
this.logger.log(
|
||||
this.logger.debug(
|
||||
`Using first regex candidate sent folder: ${regexCandidateFolders[0].path} (no messages found in any regex candidate)`,
|
||||
);
|
||||
|
||||
|
||||
-3
@@ -48,7 +48,6 @@ import { MessagingMessagesImportService } from 'src/modules/messaging/message-im
|
||||
import { MessagingProcessFolderActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-folder-actions.service';
|
||||
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
|
||||
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
|
||||
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
|
||||
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
|
||||
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
|
||||
@Module({
|
||||
@@ -101,7 +100,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingGetMessagesService,
|
||||
MessageImportExceptionHandlerService,
|
||||
MessagingCursorService,
|
||||
MessagingSendMessageService,
|
||||
MessagingAccountAuthenticationService,
|
||||
MessagingProcessFolderActionsService,
|
||||
MessagingProcessGroupEmailActionsService,
|
||||
@@ -109,7 +107,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingDeleteGroupEmailMessagesService,
|
||||
],
|
||||
exports: [
|
||||
MessagingSendMessageService,
|
||||
MessagingAccountAuthenticationService,
|
||||
MessagingMessageListFetchCronCommand,
|
||||
MessagingMessagesImportCronCommand,
|
||||
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { google } from 'googleapis';
|
||||
import MailComposer from 'nodemailer/lib/mail-composer';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import {
|
||||
MessageImportDriverException,
|
||||
MessageImportDriverExceptionCode,
|
||||
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
|
||||
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
|
||||
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
|
||||
import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
|
||||
|
||||
type EmailAddress = string | string[];
|
||||
|
||||
type SendMessageInput = {
|
||||
body: string;
|
||||
subject: string;
|
||||
to: EmailAddress;
|
||||
cc?: EmailAddress;
|
||||
bcc?: EmailAddress;
|
||||
html: string;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MessagingSendMessageService {
|
||||
constructor(
|
||||
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
|
||||
private readonly smtpClientProvider: SmtpClientProvider,
|
||||
private readonly imapClientProvider: ImapClientProvider,
|
||||
) {}
|
||||
|
||||
public async sendMessage(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
switch (connectedAccount.provider) {
|
||||
case ConnectedAccountProvider.GOOGLE: {
|
||||
const oAuth2Client =
|
||||
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
const gmailClient = google.gmail({
|
||||
version: 'v1',
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const peopleClient = google.people({
|
||||
version: 'v1',
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const { data: gmailData } = await gmailClient.users.getProfile({
|
||||
userId: 'me',
|
||||
});
|
||||
|
||||
const fromEmail = gmailData.emailAddress;
|
||||
|
||||
const { data: peopleData } = await peopleClient.people.get({
|
||||
resourceName: 'people/me',
|
||||
personFields: 'names',
|
||||
});
|
||||
|
||||
const fromName = peopleData?.names?.[0]?.displayName;
|
||||
|
||||
const mail = new MailComposer({
|
||||
from: isDefined(fromName)
|
||||
? `"${mimeEncode(fromName)}" <${fromEmail}>`
|
||||
: `${fromEmail}`,
|
||||
to: sendMessageInput.to,
|
||||
cc: sendMessageInput.cc,
|
||||
bcc: sendMessageInput.bcc,
|
||||
subject: sendMessageInput.subject,
|
||||
text: sendMessageInput.body,
|
||||
html: sendMessageInput.html,
|
||||
...(sendMessageInput.attachments &&
|
||||
sendMessageInput.attachments.length > 0
|
||||
? {
|
||||
attachments: sendMessageInput.attachments.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const compiledMessage = mail.compile();
|
||||
|
||||
compiledMessage.keepBcc = true;
|
||||
|
||||
const messageBuffer = await compiledMessage.build();
|
||||
const encodedMessage = Buffer.from(messageBuffer).toString('base64');
|
||||
|
||||
await gmailClient.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
raw: encodedMessage,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case ConnectedAccountProvider.MICROSOFT: {
|
||||
const microsoftClient =
|
||||
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
const message = {
|
||||
subject: sendMessageInput.subject,
|
||||
body: {
|
||||
contentType: 'HTML',
|
||||
content: sendMessageInput.html,
|
||||
},
|
||||
toRecipients: toMicrosoftRecipients(sendMessageInput.to),
|
||||
ccRecipients: toMicrosoftRecipients(sendMessageInput.cc),
|
||||
bccRecipients: toMicrosoftRecipients(sendMessageInput.bcc),
|
||||
...(sendMessageInput.attachments &&
|
||||
sendMessageInput.attachments.length > 0
|
||||
? {
|
||||
attachments: sendMessageInput.attachments.map((attachment) => ({
|
||||
'@odata.type': '#microsoft.graph.fileAttachment',
|
||||
name: attachment.filename,
|
||||
contentType: attachment.contentType,
|
||||
contentBytes: attachment.content.toString('base64'),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await microsoftClient
|
||||
.api(`/me/messages`)
|
||||
.post(message);
|
||||
|
||||
z.string().parse(response.id);
|
||||
|
||||
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
|
||||
|
||||
break;
|
||||
}
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
|
||||
const { handle, connectionParameters, messageChannels } =
|
||||
connectedAccount;
|
||||
|
||||
const smtpClient =
|
||||
await this.smtpClientProvider.getSmtpClient(connectedAccount);
|
||||
|
||||
if (!isDefined(handle)) {
|
||||
throw new MessageImportDriverException(
|
||||
'Handle is required',
|
||||
MessageImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
const mail = new MailComposer({
|
||||
from: handle,
|
||||
to: sendMessageInput.to,
|
||||
cc: sendMessageInput.cc,
|
||||
bcc: sendMessageInput.bcc,
|
||||
subject: sendMessageInput.subject,
|
||||
text: sendMessageInput.body,
|
||||
html: sendMessageInput.html,
|
||||
...(sendMessageInput.attachments &&
|
||||
sendMessageInput.attachments.length > 0
|
||||
? {
|
||||
attachments: sendMessageInput.attachments.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const messageBuffer = await mail.compile().build();
|
||||
|
||||
await smtpClient.sendMail({
|
||||
from: handle,
|
||||
to: sendMessageInput.to,
|
||||
cc: sendMessageInput.cc,
|
||||
bcc: sendMessageInput.bcc,
|
||||
raw: messageBuffer,
|
||||
});
|
||||
|
||||
if (isDefined(connectionParameters?.IMAP)) {
|
||||
const imapClient =
|
||||
await this.imapClientProvider.getClient(connectedAccount);
|
||||
|
||||
const messageChannel = messageChannels.find(
|
||||
(channel) => channel.handle === handle,
|
||||
);
|
||||
|
||||
const sentFolder = messageChannel?.messageFolders.find(
|
||||
(messageFolder) => messageFolder.isSentFolder,
|
||||
);
|
||||
|
||||
if (isDefined(sentFolder) && isDefined(sentFolder.name)) {
|
||||
await imapClient.append(sentFolder.name, messageBuffer);
|
||||
}
|
||||
|
||||
await this.imapClientProvider.closeClient(imapClient);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
assertUnreachable(
|
||||
connectedAccount.provider,
|
||||
`Provider ${connectedAccount.provider} not supported for sending messages`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-18
@@ -4,9 +4,7 @@ import { google } from 'googleapis';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
|
||||
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
|
||||
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
|
||||
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
|
||||
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
|
||||
|
||||
jest.mock('nodemailer/lib/mail-composer', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
@@ -16,8 +14,8 @@ jest.mock('nodemailer/lib/mail-composer', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
let service: MessagingSendMessageService;
|
||||
describe('GmailMessageOutboundService', () => {
|
||||
let service: GmailMessageOutboundService;
|
||||
|
||||
const mockSend = jest.fn().mockResolvedValue({ data: { id: 'message-id' } });
|
||||
|
||||
@@ -54,7 +52,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MessagingSendMessageService,
|
||||
GmailMessageOutboundService,
|
||||
{
|
||||
provide: OAuth2ClientManagerService,
|
||||
useValue: {
|
||||
@@ -63,19 +61,11 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
.mockResolvedValue(mockOAuth2Client),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SmtpClientProvider,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ImapClientProvider,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MessagingSendMessageService>(
|
||||
MessagingSendMessageService,
|
||||
service = module.get<GmailMessageOutboundService>(
|
||||
GmailMessageOutboundService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,7 +95,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
expect(mockSend).toHaveBeenCalledWith({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
raw: Buffer.from('mocked-email-content').toString('base64'),
|
||||
raw: Buffer.from('mocked-email-content').toString('base64url'),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -137,7 +127,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
expect(mockSend).toHaveBeenCalledWith({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
raw: Buffer.from('mocked-email-content').toString('base64'),
|
||||
raw: Buffer.from('mocked-email-content').toString('base64url'),
|
||||
},
|
||||
});
|
||||
});
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type gmail_v1, google } from 'googleapis';
|
||||
import MailComposer from 'nodemailer/lib/mail-composer';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
|
||||
|
||||
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
|
||||
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
|
||||
import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
|
||||
|
||||
@Injectable()
|
||||
export class GmailMessageOutboundService implements MessageOutboundDriver {
|
||||
constructor(
|
||||
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
|
||||
) {}
|
||||
|
||||
async sendMessage(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
const { gmailClient, encodedMessage } = await this.composeGmailMessage(
|
||||
connectedAccount,
|
||||
sendMessageInput,
|
||||
);
|
||||
|
||||
await gmailClient.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
raw: encodedMessage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createDraft(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
const { gmailClient, encodedMessage } = await this.composeGmailMessage(
|
||||
connectedAccount,
|
||||
sendMessageInput,
|
||||
);
|
||||
|
||||
await gmailClient.users.drafts.create({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
message: {
|
||||
raw: encodedMessage,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async composeGmailMessage(
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
sendMessageInput: SendMessageInput,
|
||||
): Promise<{
|
||||
gmailClient: gmail_v1.Gmail;
|
||||
encodedMessage: string;
|
||||
}> {
|
||||
const oAuth2Client =
|
||||
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
const gmailClient = google.gmail({
|
||||
version: 'v1',
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const peopleClient = google.people({
|
||||
version: 'v1',
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const { data: gmailData } = await gmailClient.users.getProfile({
|
||||
userId: 'me',
|
||||
});
|
||||
|
||||
const fromEmail = gmailData.emailAddress;
|
||||
|
||||
const { data: peopleData } = await peopleClient.people.get({
|
||||
resourceName: 'people/me',
|
||||
personFields: 'names',
|
||||
});
|
||||
|
||||
const fromName = peopleData?.names?.[0]?.displayName;
|
||||
|
||||
const from = isDefined(fromName)
|
||||
? `"${mimeEncode(fromName)}" <${fromEmail}>`
|
||||
: `${fromEmail}`;
|
||||
|
||||
const mail = new MailComposer(
|
||||
toMailComposerOptions(from, sendMessageInput),
|
||||
);
|
||||
|
||||
const compiledMessage = mail.compile();
|
||||
|
||||
compiledMessage.keepBcc = true;
|
||||
|
||||
const messageBuffer = await compiledMessage.build();
|
||||
const encodedMessage = Buffer.from(messageBuffer).toString('base64url');
|
||||
|
||||
return { gmailClient, encodedMessage };
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import MailComposer from 'nodemailer/lib/mail-composer';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
|
||||
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
|
||||
import { ImapFindDraftsFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service';
|
||||
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
|
||||
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
|
||||
import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
|
||||
|
||||
@Injectable()
|
||||
export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
|
||||
constructor(
|
||||
private readonly smtpClientProvider: SmtpClientProvider,
|
||||
private readonly imapClientProvider: ImapClientProvider,
|
||||
private readonly imapFindDraftsFolderService: ImapFindDraftsFolderService,
|
||||
) {}
|
||||
|
||||
async sendMessage(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
const { handle, connectionParameters, messageChannels } = connectedAccount;
|
||||
|
||||
const smtpClient =
|
||||
await this.smtpClientProvider.getSmtpClient(connectedAccount);
|
||||
|
||||
this.assertHandleIsDefined(handle);
|
||||
|
||||
const messageBuffer = await this.compileRawMessage(
|
||||
handle,
|
||||
sendMessageInput,
|
||||
);
|
||||
|
||||
await smtpClient.sendMail({
|
||||
from: handle,
|
||||
to: sendMessageInput.to,
|
||||
cc: sendMessageInput.cc,
|
||||
bcc: sendMessageInput.bcc,
|
||||
raw: messageBuffer,
|
||||
});
|
||||
|
||||
if (isDefined(connectionParameters?.IMAP)) {
|
||||
const imapClient =
|
||||
await this.imapClientProvider.getClient(connectedAccount);
|
||||
|
||||
const messageChannel = messageChannels.find(
|
||||
(channel) => channel.handle === handle,
|
||||
);
|
||||
|
||||
const sentFolder = messageChannel?.messageFolders.find(
|
||||
(messageFolder) => messageFolder.isSentFolder,
|
||||
);
|
||||
|
||||
if (isDefined(sentFolder) && isDefined(sentFolder.name)) {
|
||||
await imapClient.append(sentFolder.name, messageBuffer);
|
||||
}
|
||||
|
||||
await this.imapClientProvider.closeClient(imapClient);
|
||||
}
|
||||
}
|
||||
|
||||
async createDraft(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
const { handle, connectionParameters } = connectedAccount;
|
||||
|
||||
this.assertHandleIsDefined(handle);
|
||||
|
||||
if (!isDefined(connectionParameters?.IMAP)) {
|
||||
throw new Error('IMAP connection is required to create drafts');
|
||||
}
|
||||
|
||||
const messageBuffer = await this.compileRawMessage(
|
||||
handle,
|
||||
sendMessageInput,
|
||||
);
|
||||
|
||||
const imapClient =
|
||||
await this.imapClientProvider.getClient(connectedAccount);
|
||||
|
||||
try {
|
||||
const draftsFolder =
|
||||
await this.imapFindDraftsFolderService.findOrCreateDraftsFolder(
|
||||
imapClient,
|
||||
);
|
||||
|
||||
if (!isDefined(draftsFolder)) {
|
||||
throw new Error('No drafts folder found and could not create one');
|
||||
}
|
||||
const DRAFT_FLAG = '\\Draft';
|
||||
|
||||
await imapClient.append(draftsFolder.path, messageBuffer, [DRAFT_FLAG]);
|
||||
} finally {
|
||||
await this.imapClientProvider.closeClient(imapClient);
|
||||
}
|
||||
}
|
||||
|
||||
private async compileRawMessage(
|
||||
from: string,
|
||||
sendMessageInput: SendMessageInput,
|
||||
): Promise<Buffer> {
|
||||
const mail = new MailComposer(
|
||||
toMailComposerOptions(from, sendMessageInput),
|
||||
);
|
||||
|
||||
return mail.compile().build();
|
||||
}
|
||||
|
||||
private assertHandleIsDefined(
|
||||
handle: string | null,
|
||||
): asserts handle is string {
|
||||
if (!isDefined(handle)) {
|
||||
throw new Error('Handle is required');
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
|
||||
|
||||
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
|
||||
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
|
||||
constructor(
|
||||
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
|
||||
) {}
|
||||
|
||||
async sendMessage(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
const microsoftClient =
|
||||
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
const message = this.composeMicrosoftMessage(sendMessageInput);
|
||||
|
||||
const response = await microsoftClient.api(`/me/messages`).post(message);
|
||||
|
||||
z.string().parse(response.id);
|
||||
|
||||
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
|
||||
}
|
||||
|
||||
async createDraft(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
const microsoftClient =
|
||||
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
const message = this.composeMicrosoftMessage(sendMessageInput);
|
||||
|
||||
await microsoftClient.api(`/me/messages`).post(message);
|
||||
}
|
||||
|
||||
private composeMicrosoftMessage(
|
||||
sendMessageInput: SendMessageInput,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
subject: sendMessageInput.subject,
|
||||
body: {
|
||||
contentType: 'HTML',
|
||||
content: sendMessageInput.html,
|
||||
},
|
||||
toRecipients: toMicrosoftRecipients(sendMessageInput.to),
|
||||
ccRecipients: toMicrosoftRecipients(sendMessageInput.cc),
|
||||
bccRecipients: toMicrosoftRecipients(sendMessageInput.bcc),
|
||||
...(sendMessageInput.attachments &&
|
||||
sendMessageInput.attachments.length > 0
|
||||
? {
|
||||
attachments: sendMessageInput.attachments.map((attachment) => ({
|
||||
'@odata.type': '#microsoft.graph.fileAttachment',
|
||||
name: attachment.filename,
|
||||
contentType: attachment.contentType,
|
||||
contentBytes: attachment.content.toString('base64'),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
|
||||
|
||||
export type MessageOutboundDriver = {
|
||||
sendMessage(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void>;
|
||||
|
||||
createDraft(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void>;
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
|
||||
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
|
||||
import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
|
||||
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
|
||||
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
|
||||
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
|
||||
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
OAuth2ClientManagerModule,
|
||||
MessagingIMAPDriverModule,
|
||||
MessagingSmtpDriverModule,
|
||||
],
|
||||
providers: [
|
||||
GmailMessageOutboundService,
|
||||
MicrosoftMessageOutboundService,
|
||||
ImapSmtpMessageOutboundService,
|
||||
MessagingMessageOutboundService,
|
||||
],
|
||||
exports: [MessagingMessageOutboundService],
|
||||
})
|
||||
export class MessagingSendManagerModule {}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
|
||||
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
|
||||
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
|
||||
import { SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
|
||||
|
||||
@Injectable()
|
||||
export class MessagingMessageOutboundService {
|
||||
constructor(
|
||||
private readonly gmailMessageOutboundService: GmailMessageOutboundService,
|
||||
private readonly microsoftMessageOutboundService: MicrosoftMessageOutboundService,
|
||||
private readonly imapSmtpMessageOutboundService: ImapSmtpMessageOutboundService,
|
||||
) {}
|
||||
|
||||
public async sendMessage(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
switch (connectedAccount.provider) {
|
||||
case ConnectedAccountProvider.GOOGLE:
|
||||
return this.gmailMessageOutboundService.sendMessage(
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
case ConnectedAccountProvider.MICROSOFT:
|
||||
return this.microsoftMessageOutboundService.sendMessage(
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
return this.imapSmtpMessageOutboundService.sendMessage(
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
default:
|
||||
assertUnreachable(
|
||||
connectedAccount.provider,
|
||||
`Provider ${connectedAccount.provider} not supported for sending messages`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async createDraft(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity,
|
||||
): Promise<void> {
|
||||
switch (connectedAccount.provider) {
|
||||
case ConnectedAccountProvider.GOOGLE:
|
||||
return this.gmailMessageOutboundService.createDraft(
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
case ConnectedAccountProvider.MICROSOFT:
|
||||
return this.microsoftMessageOutboundService.createDraft(
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
return this.imapSmtpMessageOutboundService.createDraft(
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
default:
|
||||
assertUnreachable(
|
||||
connectedAccount.provider,
|
||||
`Provider ${connectedAccount.provider} not supported for creating drafts`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
type EmailAddress = string | string[];
|
||||
|
||||
export type SendMessageInput = {
|
||||
body: string;
|
||||
subject: string;
|
||||
to: EmailAddress;
|
||||
cc?: EmailAddress;
|
||||
bcc?: EmailAddress;
|
||||
html: string;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
}[];
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
|
||||
|
||||
export const toMailComposerOptions = (
|
||||
from: string,
|
||||
sendMessageInput: SendMessageInput,
|
||||
) => {
|
||||
return {
|
||||
from,
|
||||
to: sendMessageInput.to,
|
||||
cc: sendMessageInput.cc,
|
||||
bcc: sendMessageInput.bcc,
|
||||
subject: sendMessageInput.subject,
|
||||
text: sendMessageInput.body,
|
||||
html: sendMessageInput.html,
|
||||
...(sendMessageInput.attachments && sendMessageInput.attachments.length > 0
|
||||
? {
|
||||
attachments: sendMessageInput.attachments.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
+22
@@ -249,6 +249,28 @@ export class WorkflowVersionStepOperationsWorkspaceService {
|
||||
},
|
||||
};
|
||||
}
|
||||
case WorkflowActionType.DRAFT_EMAIL: {
|
||||
return {
|
||||
builtStep: {
|
||||
...baseStep,
|
||||
name: 'Draft Email',
|
||||
type: WorkflowActionType.DRAFT_EMAIL,
|
||||
settings: {
|
||||
...BASE_STEP_DEFINITION,
|
||||
input: {
|
||||
connectedAccountId: '',
|
||||
recipients: {
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
},
|
||||
subject: '',
|
||||
body: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case WorkflowActionType.CREATE_RECORD: {
|
||||
const activeObjectMetadataItem =
|
||||
await this.objectMetadataRepository.findOne({
|
||||
|
||||
+2
@@ -51,6 +51,8 @@ export class WorkflowActionFactory {
|
||||
return this.logicFunctionWorkflowAction;
|
||||
case WorkflowActionType.SEND_EMAIL:
|
||||
return this.toolExecutorWorkflowAction;
|
||||
case WorkflowActionType.DRAFT_EMAIL:
|
||||
return this.toolExecutorWorkflowAction;
|
||||
case WorkflowActionType.CREATE_RECORD:
|
||||
return this.createRecordWorkflowAction;
|
||||
case WorkflowActionType.UPSERT_RECORD:
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowDraftEmailAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
export const isWorkflowDraftEmailAction = (
|
||||
action: WorkflowAction,
|
||||
): action is WorkflowDraftEmailAction => {
|
||||
return action.type === WorkflowActionType.DRAFT_EMAIL;
|
||||
};
|
||||
+12
-6
@@ -4,8 +4,9 @@ import { resolveInput, resolveRichTextVariables } from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
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 { 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 { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
|
||||
@@ -20,10 +21,12 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
|
||||
constructor(
|
||||
private readonly httpTool: HttpTool,
|
||||
private readonly sendEmailTool: SendEmailTool,
|
||||
private readonly draftEmailTool: DraftEmailTool,
|
||||
) {
|
||||
this.toolsByActionType = new Map<WorkflowActionType, Tool>([
|
||||
[WorkflowActionType.HTTP_REQUEST, this.httpTool],
|
||||
[WorkflowActionType.SEND_EMAIL, this.sendEmailTool],
|
||||
[WorkflowActionType.DRAFT_EMAIL, this.draftEmailTool],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -47,13 +50,16 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
|
||||
|
||||
let toolInput = step.settings.input;
|
||||
|
||||
if (step.type === WorkflowActionType.SEND_EMAIL) {
|
||||
const sendEmailInput = toolInput as WorkflowSendEmailActionInput;
|
||||
if (
|
||||
step.type === WorkflowActionType.SEND_EMAIL ||
|
||||
step.type === WorkflowActionType.DRAFT_EMAIL
|
||||
) {
|
||||
const emailInput = toolInput as WorkflowSendEmailActionInput;
|
||||
|
||||
if (sendEmailInput.body) {
|
||||
if (emailInput.body) {
|
||||
toolInput = {
|
||||
...sendEmailInput,
|
||||
body: resolveRichTextVariables(sendEmailInput.body, context),
|
||||
...emailInput,
|
||||
body: resolveRichTextVariables(emailInput.body, context),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -2,6 +2,7 @@ export enum WorkflowActionType {
|
||||
CODE = 'CODE',
|
||||
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
|
||||
SEND_EMAIL = 'SEND_EMAIL',
|
||||
DRAFT_EMAIL = 'DRAFT_EMAIL',
|
||||
CREATE_RECORD = 'CREATE_RECORD',
|
||||
UPDATE_RECORD = 'UPDATE_RECORD',
|
||||
DELETE_RECORD = 'DELETE_RECORD',
|
||||
|
||||
+6
@@ -51,6 +51,11 @@ export type WorkflowSendEmailAction = BaseWorkflowAction & {
|
||||
settings: WorkflowSendEmailActionSettings;
|
||||
};
|
||||
|
||||
export type WorkflowDraftEmailAction = BaseWorkflowAction & {
|
||||
type: WorkflowActionType.DRAFT_EMAIL;
|
||||
settings: WorkflowSendEmailActionSettings;
|
||||
};
|
||||
|
||||
export type WorkflowCreateRecordAction = BaseWorkflowAction & {
|
||||
type: WorkflowActionType.CREATE_RECORD;
|
||||
settings: WorkflowCreateRecordActionSettings;
|
||||
@@ -119,6 +124,7 @@ export type WorkflowAction =
|
||||
| WorkflowCodeAction
|
||||
| WorkflowLogicFunctionAction
|
||||
| WorkflowSendEmailAction
|
||||
| WorkflowDraftEmailAction
|
||||
| WorkflowCreateRecordAction
|
||||
| WorkflowUpdateRecordAction
|
||||
| WorkflowDeleteRecordAction
|
||||
|
||||
Reference in New Issue
Block a user