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:
+84
@@ -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>
|
||||
);
|
||||
};
|
||||
+152
@@ -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 };
|
||||
};
|
||||
+23
-3
@@ -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"
|
||||
|
||||
+2
@@ -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: {
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+2
@@ -7,4 +7,6 @@ export enum SendEmailToolExceptionCode {
|
||||
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
|
||||
INVALID_EMAIL = 'INVALID_EMAIL',
|
||||
WORKSPACE_ID_NOT_FOUND = 'WORKSPACE_ID_NOT_FOUND',
|
||||
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||
INVALID_FILE_ID = 'INVALID_FILE_ID',
|
||||
}
|
||||
|
||||
+9
-1
@@ -1,15 +1,23 @@
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
import { workflowFileSchema } from 'twenty-shared/workflow';
|
||||
|
||||
export const SendEmailInputZodSchema = z.object({
|
||||
email: z.email().describe('The recipient email address'),
|
||||
subject: z.string().describe('The email subject line'),
|
||||
body: z.string().describe('The email body content (HTML or plain text)'),
|
||||
connectedAccountId: z
|
||||
.uuid()
|
||||
.string()
|
||||
.refine((val) => isValidUuid(val))
|
||||
.describe(
|
||||
'The UUID of the connected account to send the email from. Provide this only if you have it; otherwise, leave blank.',
|
||||
)
|
||||
.optional(),
|
||||
files: z
|
||||
.array(workflowFileSchema)
|
||||
.describe('Array of file objects to attach to the email')
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const SendEmailToolParametersZodSchema = z.object({
|
||||
|
||||
+78
-2
@@ -1,11 +1,15 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { render, toPlainText } from '@react-email/render';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { reactMarkupFromJSON } from 'twenty-emails';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils';
|
||||
import {
|
||||
SendEmailToolException,
|
||||
SendEmailToolExceptionCode,
|
||||
@@ -19,6 +23,9 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
|
||||
import { parseEmailBody } from 'src/utils/parse-email-body';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
|
||||
@Injectable()
|
||||
export class SendEmailTool implements Tool {
|
||||
@@ -32,6 +39,9 @@ export class SendEmailTool implements Tool {
|
||||
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly sendMessageService: MessagingSendMessageService,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
private readonly fileService: FileService,
|
||||
) {}
|
||||
|
||||
private async getConnectedAccount(
|
||||
@@ -90,10 +100,70 @@ export class SendEmailTool implements Tool {
|
||||
return allAccounts[0].id;
|
||||
}
|
||||
|
||||
private async getAttachments(
|
||||
files: Array<{ id: string; name: string; type: string }>,
|
||||
workspaceId: string,
|
||||
): Promise<MessageAttachment[]> {
|
||||
if (files.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileIds = files.map((file) => file.id);
|
||||
|
||||
const fileEntities = await this.fileRepository.find({
|
||||
where: { id: In(fileIds) },
|
||||
});
|
||||
|
||||
const fileEntityMap = new Map(
|
||||
fileEntities.map((entity) => [entity.id, entity]),
|
||||
);
|
||||
|
||||
const filesNotFound: string[] = [];
|
||||
|
||||
for (const fileMetadata of files) {
|
||||
if (!fileEntityMap.has(fileMetadata.id)) {
|
||||
filesNotFound.push(`${fileMetadata.name} (${fileMetadata.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesNotFound.length > 0) {
|
||||
throw new SendEmailToolException(
|
||||
`Files not found: ${filesNotFound.join(', ')}`,
|
||||
SendEmailToolExceptionCode.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const attachments: MessageAttachment[] = [];
|
||||
|
||||
for (const fileMetadata of files) {
|
||||
const fileEntity = fileEntityMap.get(fileMetadata.id)!;
|
||||
|
||||
const { folderPath, filename } = extractFolderPathAndFilename(
|
||||
fileEntity.fullPath,
|
||||
);
|
||||
|
||||
const stream = await this.fileService.getFileStream(
|
||||
folderPath,
|
||||
filename,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const buffer = await streamToBuffer(stream);
|
||||
|
||||
attachments.push({
|
||||
filename: fileMetadata.name,
|
||||
content: buffer,
|
||||
contentType: fileMetadata.type,
|
||||
});
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
async execute(parameters: SendEmailInput): Promise<ToolOutput> {
|
||||
const { workspaceId } = this.scopedWorkspaceContextFactory.create();
|
||||
|
||||
const { email, subject, body } = parameters;
|
||||
const { email, subject, body, files } = parameters;
|
||||
let { connectedAccountId } = parameters;
|
||||
|
||||
try {
|
||||
@@ -127,6 +197,8 @@ export class SendEmailTool implements Tool {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const attachments = await this.getAttachments(files || [], workspaceId);
|
||||
|
||||
const parsedBody = parseEmailBody(body);
|
||||
const reactMarkup = reactMarkupFromJSON(parsedBody);
|
||||
const htmlBody = await render(reactMarkup);
|
||||
@@ -144,11 +216,14 @@ export class SendEmailTool implements Tool {
|
||||
subject: safeSubject,
|
||||
body: textBody,
|
||||
html: safeHtmlBody,
|
||||
attachments,
|
||||
},
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
this.logger.log(`Email sent successfully to ${email}`);
|
||||
this.logger.log(
|
||||
`Email sent successfully to ${email}${attachments.length > 0 ? ` with ${attachments.length} attachments` : ''}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -157,6 +232,7 @@ export class SendEmailTool implements Tool {
|
||||
recipient: email,
|
||||
subject: safeSubject,
|
||||
connectedAccountId,
|
||||
attachmentCount: attachments.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
+68
-80
@@ -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'),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+60
-30
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user