feat: Attachement for Send Email workflow node (#15044)
## Description - This PR addresses the one issue out of https://github.com/twentyhq/core-team-issues/issues/1685 - Added backend support for workflow node to support attachement - updated send email schema, core utility - added workflowattachmentRow and workflowsendEmailAttachment file to handle file attachment in email workflow - updated Google and Microsoft to use MailComposer which unifies with SMTP provider and improves mail structure ## Visual Appearance https://github.com/user-attachments/assets/16478569-0a83-417e-a85e-70e41fe83343 --------- Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
@@ -22,12 +22,15 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { MessagingModule } from 'src/modules/messaging/messaging.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity]),
|
||||
TypeOrmModule.forFeature([RoleEntity, FileEntity]),
|
||||
TokenModule,
|
||||
FileModule,
|
||||
FeatureFlagModule,
|
||||
RecordCrudModule,
|
||||
ObjectMetadataModule,
|
||||
|
||||
+2
@@ -7,4 +7,6 @@ export enum SendEmailToolExceptionCode {
|
||||
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
|
||||
INVALID_EMAIL = 'INVALID_EMAIL',
|
||||
WORKSPACE_ID_NOT_FOUND = 'WORKSPACE_ID_NOT_FOUND',
|
||||
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||
INVALID_FILE_ID = 'INVALID_FILE_ID',
|
||||
}
|
||||
|
||||
+9
-1
@@ -1,15 +1,23 @@
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
import { workflowFileSchema } from 'twenty-shared/workflow';
|
||||
|
||||
export const SendEmailInputZodSchema = z.object({
|
||||
email: z.email().describe('The recipient email address'),
|
||||
subject: z.string().describe('The email subject line'),
|
||||
body: z.string().describe('The email body content (HTML or plain text)'),
|
||||
connectedAccountId: z
|
||||
.uuid()
|
||||
.string()
|
||||
.refine((val) => isValidUuid(val))
|
||||
.describe(
|
||||
'The UUID of the connected account to send the email from. Provide this only if you have it; otherwise, leave blank.',
|
||||
)
|
||||
.optional(),
|
||||
files: z
|
||||
.array(workflowFileSchema)
|
||||
.describe('Array of file objects to attach to the email')
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const SendEmailToolParametersZodSchema = z.object({
|
||||
|
||||
+78
-2
@@ -1,11 +1,15 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { render, toPlainText } from '@react-email/render';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { reactMarkupFromJSON } from 'twenty-emails';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils';
|
||||
import {
|
||||
SendEmailToolException,
|
||||
SendEmailToolExceptionCode,
|
||||
@@ -19,6 +23,9 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
|
||||
import { parseEmailBody } from 'src/utils/parse-email-body';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
|
||||
@Injectable()
|
||||
export class SendEmailTool implements Tool {
|
||||
@@ -32,6 +39,9 @@ export class SendEmailTool implements Tool {
|
||||
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly sendMessageService: MessagingSendMessageService,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
private readonly fileService: FileService,
|
||||
) {}
|
||||
|
||||
private async getConnectedAccount(
|
||||
@@ -90,10 +100,70 @@ export class SendEmailTool implements Tool {
|
||||
return allAccounts[0].id;
|
||||
}
|
||||
|
||||
private async getAttachments(
|
||||
files: Array<{ id: string; name: string; type: string }>,
|
||||
workspaceId: string,
|
||||
): Promise<MessageAttachment[]> {
|
||||
if (files.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileIds = files.map((file) => file.id);
|
||||
|
||||
const fileEntities = await this.fileRepository.find({
|
||||
where: { id: In(fileIds) },
|
||||
});
|
||||
|
||||
const fileEntityMap = new Map(
|
||||
fileEntities.map((entity) => [entity.id, entity]),
|
||||
);
|
||||
|
||||
const filesNotFound: string[] = [];
|
||||
|
||||
for (const fileMetadata of files) {
|
||||
if (!fileEntityMap.has(fileMetadata.id)) {
|
||||
filesNotFound.push(`${fileMetadata.name} (${fileMetadata.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesNotFound.length > 0) {
|
||||
throw new SendEmailToolException(
|
||||
`Files not found: ${filesNotFound.join(', ')}`,
|
||||
SendEmailToolExceptionCode.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const attachments: MessageAttachment[] = [];
|
||||
|
||||
for (const fileMetadata of files) {
|
||||
const fileEntity = fileEntityMap.get(fileMetadata.id)!;
|
||||
|
||||
const { folderPath, filename } = extractFolderPathAndFilename(
|
||||
fileEntity.fullPath,
|
||||
);
|
||||
|
||||
const stream = await this.fileService.getFileStream(
|
||||
folderPath,
|
||||
filename,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const buffer = await streamToBuffer(stream);
|
||||
|
||||
attachments.push({
|
||||
filename: fileMetadata.name,
|
||||
content: buffer,
|
||||
contentType: fileMetadata.type,
|
||||
});
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
async execute(parameters: SendEmailInput): Promise<ToolOutput> {
|
||||
const { workspaceId } = this.scopedWorkspaceContextFactory.create();
|
||||
|
||||
const { email, subject, body } = parameters;
|
||||
const { email, subject, body, files } = parameters;
|
||||
let { connectedAccountId } = parameters;
|
||||
|
||||
try {
|
||||
@@ -127,6 +197,8 @@ export class SendEmailTool implements Tool {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const attachments = await this.getAttachments(files || [], workspaceId);
|
||||
|
||||
const parsedBody = parseEmailBody(body);
|
||||
const reactMarkup = reactMarkupFromJSON(parsedBody);
|
||||
const htmlBody = await render(reactMarkup);
|
||||
@@ -144,11 +216,14 @@ export class SendEmailTool implements Tool {
|
||||
subject: safeSubject,
|
||||
body: textBody,
|
||||
html: safeHtmlBody,
|
||||
attachments,
|
||||
},
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
this.logger.log(`Email sent successfully to ${email}`);
|
||||
this.logger.log(
|
||||
`Email sent successfully to ${email}${attachments.length > 0 ? ` with ${attachments.length} attachments` : ''}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -157,6 +232,7 @@ export class SendEmailTool implements Tool {
|
||||
recipient: email,
|
||||
subject: safeSubject,
|
||||
connectedAccountId,
|
||||
attachmentCount: attachments.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user