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
@@ -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 (
<StyledChip data-chip deletable={!readonly}>
<AvatarChip
Icon={IconMapping[getFileType(file.name)]}
IconBackgroundColor={iconColors[getFileType(file.name)]}
/>
<StyledLabel title={file.name}>{file.name}</StyledLabel>
{!readonly && (
<StyledDelete onClick={onRemove}>
<IconX size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledDelete>
)}
</StyledChip>
);
};
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<StyledContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFileInput
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
/>
<StyledUploadArea
hasFiles={files.length > 0}
onClick={handleAddFileClick}
>
{files.length > 0 ? (
<StyledChipsContainer>
{files.map((file: WorkflowAttachmentType) => (
<WorkflowAttachmentChip
key={file.id}
file={file}
onRemove={() => handleRemoveFile(file.id)}
/>
))}
</StyledChipsContainer>
) : (
<StyledUploadAreaLabel>
<IconUpload size={theme.icon.size.sm} />
<span>{t`Upload file`}</span>
</StyledUploadAreaLabel>
)}
</StyledUploadArea>
</StyledContainer>
);
};
@@ -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<WorkflowFile | null> => {
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 };
};
@@ -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}
/>
<WorkflowSendEmailAttachments
label="Attachments"
files={formData.files}
onChange={(files) => {
handleFieldChange('files', files);
}}
/>
<FormAdvancedTextFieldInput
label="Body"
placeholder="Enter email body"
@@ -28,6 +28,7 @@ const DEFAULT_ACTION: WorkflowSendEmailAction = {
email: '',
subject: '',
body: '',
files: [],
},
outputSchema: {},
errorHandlingOptions: {
@@ -52,6 +53,7 @@ const CONFIGURED_ACTION: WorkflowSendEmailAction = {
email: 'test@twenty.com',
subject: 'Welcome to Twenty!',
body: 'Dear Tim,\n\nWelcome to Twenty! We are excited to have you on board.\n\nBest regards,\nThe Team',
files: [],
},
outputSchema: {},
errorHandlingOptions: {
@@ -0,0 +1,7 @@
export type WorkflowAttachmentType = {
id: string;
name: string;
size: number;
type: string;
createdAt: string;
};
@@ -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) {
@@ -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>(
MessagingSendMessageService,
);
oAuth2ClientManagerService = module.get<OAuth2ClientManagerService>(
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: '<p>This is <strong>HTML</strong> content</p>',
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(
'<p>This is <strong>HTML</strong> content</p>',
);
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: '<p>HTML content</p>',
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'),
},
});
});
});
@@ -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();
@@ -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'
@@ -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';
@@ -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([]),
}),
});
@@ -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(),
});