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:
Harshit Singh
2025-10-22 22:15:28 +05:30
committed by GitHub
parent e9ab54766c
commit 4b846a42a2
16 changed files with 567 additions and 117 deletions
@@ -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,
@@ -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',
}
@@ -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({
@@ -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) {