diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx
new file mode 100644
index 0000000000..e051f8f2b0
--- /dev/null
+++ b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx
@@ -0,0 +1,84 @@
+import { getFileType } from '@/activities/files/utils/getFileType';
+import { IconMapping } from '@/file/utils/fileIconMappings';
+import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
+import { type WorkflowAttachmentType } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType';
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { AvatarChip } from 'twenty-ui/components';
+import { IconX } from 'twenty-ui/display';
+
+type WorkflowAttachmentChipProps = {
+ file: WorkflowAttachmentType;
+ onRemove: () => void;
+ readonly?: boolean;
+};
+
+const StyledChip = styled.div<{ deletable: boolean }>`
+ align-items: center;
+ background-color: ${({ theme }) => theme.background.transparent.light};
+ border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-radius: ${({ theme }) => theme.border.radius.sm};
+ column-gap: ${({ theme }) => theme.spacing(1)};
+ display: inline-flex;
+ flex-direction: row;
+ flex-shrink: 0;
+ max-width: 140px;
+ padding-left: ${({ theme }) => theme.spacing(1)};
+`;
+
+const StyledLabel = styled.span`
+ color: ${({ theme }) => theme.font.color.primary};
+ font-size: ${({ theme }) => theme.font.size.sm};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const StyledDelete = styled.button`
+ height: 20px;
+ width: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+ cursor: pointer;
+ font-size: ${({ theme }) => theme.font.size.sm};
+ user-select: none;
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ color: ${({ theme }) => theme.font.color.tertiary};
+ border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
+ border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
+
+ &:hover {
+ background-color: ${({ theme }) => theme.background.transparent.medium};
+ color: ${({ theme }) => theme.font.color.primary};
+ }
+`;
+
+export const WorkflowAttachmentChip = ({
+ file,
+ onRemove,
+ readonly = false,
+}: WorkflowAttachmentChipProps) => {
+ const iconColors = useFileCategoryColors();
+ const theme = useTheme();
+
+ return (
+
+
+ {file.name}
+
+ {!readonly && (
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx
new file mode 100644
index 0000000000..8237de576e
--- /dev/null
+++ b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx
@@ -0,0 +1,152 @@
+import { InputLabel } from '@/ui/input/components/InputLabel';
+import { WorkflowAttachmentChip } from '@/advanced-text-editor/components/WorkflowAttachmentChip';
+import { useUploadWorkflowFile } from '@/advanced-text-editor/hooks/useUploadWorkflowFile';
+
+import { type WorkflowAttachmentType } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType';
+import styled from '@emotion/styled';
+import { useLingui } from '@lingui/react/macro';
+import { type ChangeEvent, useRef } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+import { IconUpload } from 'twenty-ui/display';
+import { useTheme } from '@emotion/react';
+
+type WorkflowSendEmailAttachmentsProps = {
+ files: WorkflowAttachmentType[];
+ onChange: (files: WorkflowAttachmentType[]) => void;
+ label?: string;
+};
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const StyledFileInput = styled.input`
+ display: none;
+`;
+
+const StyledUploadArea = styled.div<{ hasFiles: boolean }>`
+ background-color: ${({ theme }) => theme.background.transparent.lighter};
+ border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-radius: ${({ theme }) => theme.border.radius.sm};
+ display: flex;
+ flex-direction: column;
+ min-height: ${({ hasFiles }) => (hasFiles ? 'auto' : '24px')};
+ justify-content: center;
+ padding-top: ${({ theme }) => theme.spacing(1)};
+ padding-bottom: ${({ theme }) => theme.spacing(1)};
+ padding-left: ${({ theme }) => theme.spacing(2)};
+ padding-right: ${({ theme }) => theme.spacing(2)};
+
+ &:hover {
+ background-color: ${({ theme }) => theme.background.transparent.light};
+ border-color: ${({ theme }) => theme.border.color.strong};
+ }
+`;
+
+const StyledChipsContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: ${({ theme }) => theme.spacing(1)};
+`;
+
+const StyledUploadAreaLabel = styled.div`
+ justify-content: center;
+ color: ${({ theme }) => theme.font.color.tertiary};
+ display: flex;
+ font-size: ${({ theme }) => theme.font.size.sm};
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+ color: ${({ theme }) => theme.font.color.secondary};
+ gap: ${({ theme }) => theme.spacing(1)};
+`;
+
+export const WorkflowSendEmailAttachments = ({
+ files,
+ label,
+ onChange,
+}: WorkflowSendEmailAttachmentsProps) => {
+ const fileInputRef = useRef(null);
+ const { uploadWorkflowFile } = useUploadWorkflowFile();
+ const { t } = useLingui();
+ const theme = useTheme();
+
+ const handleAddFileClick = (e: React.MouseEvent) => {
+ const target = e.target as HTMLElement;
+
+ const isInsideChip = target.closest('[data-chip]') !== null;
+ const isInsideButton = target.closest('button') !== null;
+ const isSvgOrPath = target.tagName === 'svg' || target.tagName === 'path';
+
+ if (isInsideChip || isInsideButton || isSvgOrPath) {
+ return;
+ }
+
+ if (fileInputRef.current !== null) {
+ fileInputRef.current.click();
+ }
+ };
+
+ const onUploadFiles = async (filesToUpload: File[]) => {
+ const uploadedFiles = await Promise.all(
+ filesToUpload.map((file) => uploadWorkflowFile(file)),
+ );
+
+ const successfulUploads = uploadedFiles.filter(
+ (file): file is WorkflowAttachmentType => file !== null,
+ );
+
+ if (successfulUploads.length > 0) {
+ onChange([...files, ...successfulUploads]);
+ }
+ };
+
+ const handleFileChange = (event: ChangeEvent) => {
+ const selectedFiles = event.target.files;
+ if (isDefined(selectedFiles)) {
+ onUploadFiles(Array.from(selectedFiles));
+ }
+ if (fileInputRef.current !== null) {
+ fileInputRef.current.value = '';
+ }
+ };
+
+ const handleRemoveFile = (fileId: string) => {
+ onChange(files.filter((file) => file.id !== fileId));
+ };
+
+ return (
+
+ {label ? {label} : null}
+
+
+
+ 0}
+ onClick={handleAddFileClick}
+ >
+ {files.length > 0 ? (
+
+ {files.map((file: WorkflowAttachmentType) => (
+ handleRemoveFile(file.id)}
+ />
+ ))}
+
+ ) : (
+
+
+ {t`Upload file`}
+
+ )}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/advanced-text-editor/hooks/useUploadWorkflowFile.ts b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useUploadWorkflowFile.ts
new file mode 100644
index 0000000000..56749a4f0c
--- /dev/null
+++ b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useUploadWorkflowFile.ts
@@ -0,0 +1,59 @@
+import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { isDefined } from 'twenty-shared/utils';
+import { useCreateFileMutation } from '~/generated-metadata/graphql';
+import { logError } from '~/utils/logError';
+
+type WorkflowFile = {
+ id: string;
+ name: string;
+ size: number;
+ type: string;
+ createdAt: string;
+};
+
+export const useUploadWorkflowFile = () => {
+ const coreClient = useApolloCoreClient();
+ const [createFile] = useCreateFileMutation({ client: coreClient });
+ const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
+
+ const uploadWorkflowFile = async (
+ file: File,
+ ): Promise => {
+ try {
+ const result = await createFile({
+ variables: { file },
+ });
+
+ const uploadedFile = result?.data?.createFile;
+
+ if (!isDefined(uploadedFile)) {
+ throw new Error('File upload failed');
+ }
+
+ const workflowFile: WorkflowFile = {
+ id: uploadedFile.id,
+ name: uploadedFile.name,
+ size: uploadedFile.size,
+ type: uploadedFile.type,
+ createdAt: uploadedFile.createdAt,
+ };
+
+ enqueueSuccessSnackBar({
+ message: `File "${file.name}" uploaded successfully`,
+ });
+
+ return workflowFile;
+ } catch (error) {
+ logError(`Failed to upload workflow file "${file.name}": ${error}`);
+
+ enqueueErrorSnackBar({
+ message: `Failed to upload "${file.name}"`,
+ });
+
+ return null;
+ }
+ };
+
+ return { uploadWorkflowFile };
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
index 81db9b1df7..71ab500d75 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
@@ -7,7 +7,6 @@ import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
-import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -17,7 +16,6 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
-import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { SEND_EMAIL_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/SendEmailAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
@@ -33,8 +31,12 @@ import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
+import { WorkflowSendEmailAttachments } from '@/advanced-text-editor/components/WorkflowSendEmailAttachments';
+import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
+import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
const EMAIL_EDITOR_MIN_HEIGHT = 340;
+
const EMAIL_EDITOR_MAX_WIDTH = 600;
type WorkflowEditActionSendEmailProps = {
@@ -49,11 +51,20 @@ type WorkflowEditActionSendEmailProps = {
};
};
+type WorkflowFile = {
+ id: string;
+ name: string;
+ size: number;
+ type: string;
+ createdAt: string;
+};
+
type SendEmailFormData = {
connectedAccountId: string;
email: string;
subject: string;
body: string;
+ files: WorkflowFile[];
};
export const WorkflowEditActionSendEmail = ({
@@ -80,6 +91,7 @@ export const WorkflowEditActionSendEmail = ({
email: action.settings.input.email,
subject: action.settings.input.subject ?? '',
body: action.settings.input.body ?? '',
+ files: action.settings.input.files ?? [],
});
const checkConnectedAccountScopes = async (
@@ -138,6 +150,7 @@ export const WorkflowEditActionSendEmail = ({
email: formData.email,
subject: formData.subject,
body: formData.body,
+ files: formData.files,
},
},
});
@@ -180,7 +193,7 @@ export const WorkflowEditActionSendEmail = ({
return attachmentAbsoluteURL;
};
- const handleImageUploadError = (error: Error, file: File) => {
+ const handleImageUploadError = (_: Error, file: File) => {
enqueueErrorSnackBar({
message: t`Failed to upload image: `.concat(file.name),
});
@@ -318,6 +331,13 @@ export const WorkflowEditActionSendEmail = ({
}}
VariablePicker={WorkflowVariablePicker}
/>
+ {
+ handleFieldChange('files', files);
+ }}
+ />
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({
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
index 9733019ede..c8f97245d7 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
@@ -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,
+ 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 {
+ 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 {
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) {
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts
index 6b3110d638..9b78d616dc 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts
@@ -7,28 +7,50 @@ import { ImapClientProvider } from 'src/modules/messaging/message-import-manager
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';
+jest.mock('nodemailer/lib/mail-composer', () => {
+ return jest.fn().mockImplementation(() => ({
+ compile: jest.fn().mockReturnValue({
+ build: jest.fn().mockResolvedValue(Buffer.from('mocked-email-content')),
+ }),
+ }));
+});
+
describe('MessagingSendMessageService - Gmail HTML Support', () => {
let service: MessagingSendMessageService;
- let oAuth2ClientManagerService: OAuth2ClientManagerService;
+
+ const mockSend = jest.fn().mockResolvedValue({ data: { id: 'message-id' } });
+
+ const mockGmailClient = {
+ users: {
+ messages: {
+ send: mockSend,
+ },
+ getProfile: jest
+ .fn()
+ .mockResolvedValue({ data: { emailAdress: 'test@example.com' } }),
+ },
+ };
+
+ const mockPeopleClient = {
+ people: {
+ get: jest.fn().mockResolvedValue({
+ data: {
+ names: [
+ {
+ displayName: 'Test User',
+ },
+ ],
+ },
+ }),
+ },
+ };
+
+ const mockOAuth2Client = {
+ gmail: jest.fn().mockReturnValue(mockGmailClient),
+ people: jest.fn().mockReturnValue(mockPeopleClient),
+ };
beforeEach(async () => {
- const mockGmailClient = {
- users: {
- messages: {
- send: jest.fn().mockResolvedValue({ data: { id: 'message-id' } }),
- },
- },
- };
-
- const mockOAuth2Client = {
- gmail: jest.fn().mockReturnValue(mockGmailClient),
- userinfo: {
- get: jest.fn().mockResolvedValue({
- data: { email: 'test@example.com', name: 'Test User' },
- }),
- },
- };
-
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagingSendMessageService,
@@ -54,9 +76,10 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
service = module.get(
MessagingSendMessageService,
);
- oAuth2ClientManagerService = module.get(
- OAuth2ClientManagerService,
- );
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
});
it('should send multipart/alternative email with both text and HTML parts via Gmail', async () => {
@@ -65,6 +88,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
subject: 'Test HTML Email',
body: 'This is plain text content',
html: 'This is HTML content
',
+ attachments: [],
};
const connectedAccount = {
@@ -75,60 +99,28 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
await service.sendMessage(sendMessageInput, connectedAccount);
- const mockOAuth2Client =
- await oAuth2ClientManagerService.getGoogleOAuth2Client(connectedAccount);
- const gmailClient = mockOAuth2Client.gmail({ version: 'v1' });
- const sendCall = gmailClient.users.messages.send as jest.Mock;
-
- expect(sendCall).toHaveBeenCalledTimes(1);
-
- const sentMessage = sendCall.mock.calls[0][0];
- const rawMessage = Buffer.from(
- sentMessage.requestBody.raw,
- 'base64',
- ).toString();
-
- expect(rawMessage).toContain('MIME-Version: 1.0');
- expect(rawMessage).toContain(
- 'Content-Type: multipart/alternative; boundary=',
- );
- expect(rawMessage).toContain('Content-Type: text/plain; charset="UTF-8"');
- expect(rawMessage).toContain('Content-Type: text/html; charset="UTF-8"');
- expect(rawMessage).toContain('This is plain text content');
- expect(rawMessage).toContain(
- 'This is HTML content
',
- );
- expect(rawMessage).toContain('To: recipient@example.com');
- expect(rawMessage).toContain('Subject:');
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ expect(mockSend).toHaveBeenCalledWith({
+ userId: 'me',
+ requestBody: {
+ raw: Buffer.from('mocked-email-content').toString('base64'),
+ },
+ });
});
- it('should handle missing fromName gracefully', async () => {
- const mockGmailClient = {
- users: {
- messages: {
- send: jest.fn().mockResolvedValue({ data: { id: 'message-id' } }),
- },
- },
- };
-
- const mockOAuth2ClientNoName = {
- gmail: jest.fn().mockReturnValue(mockGmailClient),
- userinfo: {
- get: jest.fn().mockResolvedValue({
- data: { email: 'test@example.com' }, // No name field
- }),
- },
- };
-
- (
- oAuth2ClientManagerService.getGoogleOAuth2Client as jest.Mock
- ).mockResolvedValueOnce(mockOAuth2ClientNoName);
-
+ it('should send email with attachments via Gmail', async () => {
const sendMessageInput = {
to: 'recipient@example.com',
- subject: 'Test Email',
+ subject: 'Test Email with Attachments',
body: 'Plain text',
html: 'HTML content
',
+ attachments: [
+ {
+ filename: 'test.pdf',
+ content: Buffer.from('test-pdf-content'),
+ contentType: 'application/pdf',
+ },
+ ],
};
const connectedAccount = {
@@ -139,16 +131,12 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
await service.sendMessage(sendMessageInput, connectedAccount);
- const sendCall = mockGmailClient.users.messages.send as jest.Mock;
-
- expect(sendCall).toHaveBeenCalledTimes(1);
-
- const rawMessage = Buffer.from(
- sendCall.mock.calls[0][0].requestBody.raw,
- 'base64',
- ).toString();
-
- expect(rawMessage).toContain('From: test@example.com');
- expect(rawMessage).not.toContain('""');
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ expect(mockSend).toHaveBeenCalledWith({
+ userId: 'me',
+ requestBody: {
+ raw: Buffer.from('mocked-email-content').toString('base64'),
+ },
+ });
});
});
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
index 80335ea785..5b9e2a7970 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
@@ -21,6 +21,11 @@ interface SendMessageInput {
subject: string;
to: string;
html: string;
+ attachments?: {
+ filename: string;
+ content: Buffer;
+ contentType: string;
+ }[];
}
@Injectable()
@@ -46,41 +51,45 @@ export class MessagingSendMessageService {
version: 'v1',
});
- const { data } = await oAuth2Client.userinfo.get();
+ const peopleClient = oAuth2Client.people({
+ version: 'v1',
+ });
- const fromEmail = data.email;
- const fromName = data.name;
- const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ const { data: gmailData } = await gmailClient.users.getProfile({
+ userId: 'me',
+ });
- const headers: string[] = [];
+ const fromEmail = gmailData.emailAddress;
- if (isDefined(fromName)) {
- headers.push(`From: "${mimeEncode(fromName)}" <${fromEmail}>`);
- } else {
- headers.push(`From: ${fromEmail}`);
- }
+ const { data: peopleData } = await peopleClient.people.get({
+ resourceName: 'people/me',
+ personFields: 'names',
+ });
- headers.push(
- `To: ${sendMessageInput.to}`,
- `Subject: ${mimeEncode(sendMessageInput.subject)}`,
- 'MIME-Version: 1.0',
- `Content-Type: multipart/alternative; boundary="${boundary}"`,
- '',
- `--${boundary}`,
- 'Content-Type: text/plain; charset="UTF-8"',
- '',
- sendMessageInput.body,
- '',
- `--${boundary}`,
- 'Content-Type: text/html; charset="UTF-8"',
- '',
- sendMessageInput.html,
- '',
- `--${boundary}--`,
- );
+ const fromName = peopleData?.names?.[0]?.displayName;
- const message = headers.join('\n');
- const encodedMessage = Buffer.from(message).toString('base64');
+ const mail = new MailComposer({
+ from: isDefined(fromName)
+ ? `"${mimeEncode(fromName)}" <${fromEmail}>`
+ : `${fromEmail}`,
+ to: sendMessageInput.to,
+ 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();
+ const encodedMessage = Buffer.from(messageBuffer).toString('base64');
await gmailClient.users.messages.send({
userId: 'me',
@@ -103,6 +112,17 @@ export class MessagingSendMessageService {
content: sendMessageInput.html,
},
toRecipients: [{ emailAddress: { address: sendMessageInput.to } }],
+ ...(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
@@ -148,6 +168,16 @@ export class MessagingSendMessageService {
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();
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/types/message.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/types/message.ts
index 3a37379a0f..28e2c63d99 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/types/message.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/types/message.ts
@@ -21,6 +21,12 @@ export type Message = Omit<
direction: MessageDirection;
};
+export type MessageAttachment = {
+ filename: string;
+ content: Buffer;
+ contentType: string;
+};
+
export type MessageParticipant = Omit<
MessageParticipantWorkspaceEntity,
| 'id'
diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts
index e5a23014a0..543e2dd41d 100644
--- a/packages/twenty-shared/src/workflow/index.ts
+++ b/packages/twenty-shared/src/workflow/index.ts
@@ -47,6 +47,7 @@ export { workflowWebhookTriggerSchema } from './schemas/webhook-trigger-schema';
export { workflowActionSchema } from './schemas/workflow-action-schema';
export { workflowDelayActionSchema } from './schemas/workflow-delay-action-schema';
export { workflowDelayActionSettingsSchema } from './schemas/workflow-delay-action-settings-schema';
+export { workflowFileSchema } from './schemas/workflow-file-action-schema';
export { workflowRunSchema } from './schemas/workflow-run-schema';
export { workflowRunStateSchema } from './schemas/workflow-run-state-schema';
export { workflowRunStateStepInfoSchema } from './schemas/workflow-run-state-step-info-schema';
diff --git a/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts
index 18f9c7169a..b7a0378d43 100644
--- a/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts
+++ b/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
+import { workflowFileSchema } from './workflow-file-action-schema';
export const workflowSendEmailActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
@@ -8,5 +9,6 @@ export const workflowSendEmailActionSettingsSchema =
email: z.string(),
subject: z.string().optional(),
body: z.string().optional(),
+ files: z.array(workflowFileSchema).optional().default([]),
}),
});
diff --git a/packages/twenty-shared/src/workflow/schemas/workflow-file-action-schema.ts b/packages/twenty-shared/src/workflow/schemas/workflow-file-action-schema.ts
new file mode 100644
index 0000000000..461f8fa43f
--- /dev/null
+++ b/packages/twenty-shared/src/workflow/schemas/workflow-file-action-schema.ts
@@ -0,0 +1,10 @@
+import { z } from 'zod';
+import { isValidUuid } from '../../utils/validation/isValidUuid';
+
+export const workflowFileSchema = z.object({
+ id: z.string().refine((val) => isValidUuid(val)),
+ name: z.string(),
+ size: z.number(),
+ type: z.string(),
+ createdAt: z.string(),
+});