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(), +});