Support variables file email attachment (#21613)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21613?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
neo773
2026-06-16 21:41:37 +05:30
committed by GitHub
parent d8d5991977
commit 1ad919955a
15 changed files with 256 additions and 60 deletions
@@ -1,13 +1,16 @@
import { useUploadWorkflowFile } from '@/advanced-text-editor/hooks/useUploadWorkflowFile';
import { AttachmentChip } from '@/file/components/AttachmentChip';
import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
import { VariableChip } from '@/object-record/record-field/ui/form-types/components/VariableChip';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { isString } from '@sniptt/guards';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useContext, useId } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type WorkflowAttachment } from 'twenty-shared/workflow';
import { type WorkflowEmailFiles } from 'twenty-shared/workflow';
import { IconUpload } from 'twenty-ui-deprecated/display';
import {
ThemeContext,
@@ -15,9 +18,11 @@ import {
} from 'twenty-ui-deprecated/theme-constants';
type WorkflowSendEmailAttachmentsProps = {
files: WorkflowAttachment[];
onChange: (files: WorkflowAttachment[]) => void;
files: WorkflowEmailFiles;
onChange: (files: WorkflowEmailFiles) => void;
label?: string;
readonly?: boolean;
VariablePicker?: VariablePickerComponent;
};
const StyledContainer = styled.div`
@@ -25,11 +30,24 @@ const StyledContainer = styled.div`
flex-direction: column;
`;
const StyledUploadArea = styled.div<{ hasFiles: boolean }>`
const StyledRow = styled.div`
display: flex;
flex-direction: row;
`;
const StyledUploadArea = styled.div<{ hasFiles: boolean; hasPicker: boolean }>`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
border-bottom-left-radius: ${themeCssVariables.border.radius.sm};
border-bottom-right-radius: ${({ hasPicker }) =>
hasPicker ? '0' : themeCssVariables.border.radius.sm};
border-right: ${({ hasPicker }) =>
hasPicker ? 'none' : `1px solid ${themeCssVariables.border.color.medium}`};
border-top-left-radius: ${themeCssVariables.border.radius.sm};
border-top-right-radius: ${({ hasPicker }) =>
hasPicker ? '0' : themeCssVariables.border.radius.sm};
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
min-height: ${({ hasFiles }) => (hasFiles ? 'auto' : '24px')};
@@ -51,6 +69,10 @@ const StyledChipsContainer = styled.div`
gap: ${themeCssVariables.spacing[1]};
`;
const StyledVariableChipWrapper = styled.span`
display: inline-flex;
`;
const StyledUploadAreaLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
display: flex;
@@ -64,7 +86,10 @@ export const WorkflowSendEmailAttachments = ({
files,
label,
onChange,
readonly,
VariablePicker,
}: WorkflowSendEmailAttachmentsProps) => {
const instanceId = useId();
const { theme } = useContext(ThemeContext);
const { uploadWorkflowFile } = useUploadWorkflowFile();
const { openFileUpload } = useFileUpload();
@@ -83,41 +108,76 @@ export const WorkflowSendEmailAttachments = ({
};
const handleAddFileClick = () => {
if (readonly) {
return;
}
openFileUpload({
multiple: true,
onUpload: handleUploadFiles,
});
};
const handleRemoveFile = (fileId: string) => {
onChange(files.filter((file) => file.id !== fileId));
const handleAddVariable = (variableName: string) => {
onChange([...files, variableName]);
};
const handleRemoveItem = (indexToRemove: number) => {
onChange(files.filter((_, index) => index !== indexToRemove));
};
const hasPicker = isDefined(VariablePicker) && !readonly;
return (
<StyledContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledUploadArea
hasFiles={files.length > 0}
onClick={handleAddFileClick}
>
{files.length > 0 ? (
<StyledChipsContainer>
{files.map((file: WorkflowAttachment) => (
<AttachmentChip
key={file.id}
file={file}
onRemove={() => handleRemoveFile(file.id)}
/>
))}
</StyledChipsContainer>
) : (
<StyledUploadAreaLabel>
<IconUpload size={theme.icon.size.sm} />
<span>{t`Upload file`}</span>
</StyledUploadAreaLabel>
)}
</StyledUploadArea>
<StyledRow>
<StyledUploadArea
hasFiles={files.length > 0}
hasPicker={hasPicker}
onClick={handleAddFileClick}
>
{files.length > 0 ? (
<StyledChipsContainer>
{files.map((file, index) =>
isString(file) ? (
<StyledVariableChipWrapper
key={index}
onClick={(event) => event.stopPropagation()}
>
<VariableChip
rawVariableName={file}
onRemove={
readonly ? undefined : () => handleRemoveItem(index)
}
/>
</StyledVariableChipWrapper>
) : (
<AttachmentChip
key={index}
file={file}
onRemove={() => handleRemoveItem(index)}
readonly={readonly}
/>
),
)}
</StyledChipsContainer>
) : (
<StyledUploadAreaLabel>
<IconUpload size={theme.icon.size.sm} />
<span>{t`Upload file`}</span>
</StyledUploadAreaLabel>
)}
</StyledUploadArea>
{hasPicker ? (
<VariablePicker
instanceId={instanceId}
onVariableSelect={handleAddVariable}
/>
) : null}
</StyledRow>
</StyledContainer>
);
};
@@ -1,6 +1,6 @@
import {
type EmailRecipients,
type WorkflowAttachment,
type WorkflowEmailFiles,
} from 'twenty-shared/workflow';
export type EmailFormData = {
@@ -8,6 +8,6 @@ export type EmailFormData = {
recipients: Required<EmailRecipients>;
subject: string;
body: string;
files: WorkflowAttachment[];
files: WorkflowEmailFiles;
inReplyTo: string;
};
@@ -385,9 +385,11 @@ export const WorkflowEditActionEmailBase = ({
<WorkflowSendEmailAttachments
label={t`Attachments`}
files={formData.files}
readonly={actionOptions.readonly}
onChange={(files) => {
handleFieldChange('files', files);
}}
VariablePicker={WorkflowVariablePicker}
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
@@ -81,16 +81,15 @@ export class FileService {
async getFileStreamById({
fileId,
workspaceId,
fileFolder,
allowedFileFolders = [FileFolder.Workflow],
}: {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
allowedFileFolders?: FileFolder[];
}): Promise<{ stream: Readable; mimeType: string } | null> {
const file = await this.fileRepository.findOne(workspaceId, {
where: {
id: fileId,
path: Like(`${fileFolder}/%`),
},
});
@@ -98,6 +97,12 @@ export class FileService {
return null;
}
const [fileFolder] = file.path.split('/');
if (!allowedFileFolders.includes(fileFolder as FileFolder)) {
return null;
}
const application = await this.applicationRepository.findOne({
where: {
id: file.applicationId,
@@ -116,7 +121,7 @@ export class FileService {
try {
const stream = await this.fileStorageService.readFile({
resourcePath: removeFileFolderFromFileEntityPath(file.path),
fileFolder,
fileFolder: fileFolder as FileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
});
@@ -0,0 +1,7 @@
import { FileFolder } from 'twenty-shared/types';
export const EMAIL_ATTACHMENT_FILE_FOLDERS = [
FileFolder.Workflow,
FileFolder.FilesField,
FileFolder.EmailAttachment,
];
@@ -1,7 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
@@ -34,7 +32,6 @@ export class DraftEmailTool implements Tool {
const result = await this.emailComposerService.composeEmail(
parameters,
context,
{ attachmentsFileFolder: FileFolder.Workflow },
);
if (!result.success) {
@@ -8,7 +8,6 @@ import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import {
ConnectedAccountProvider,
type EmailAttachment,
FileFolder,
} from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { In, IsNull, LessThanOrEqual, type Repository } from 'typeorm';
@@ -16,6 +15,7 @@ import { z } from 'zod';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { EMAIL_ATTACHMENT_FILE_FOLDERS } from 'src/engine/core-modules/tool/tools/email-tool/constants/email-attachment-file-folders.const';
import {
EmailToolException,
EmailToolExceptionCode,
@@ -185,7 +185,6 @@ export class EmailComposerService {
private async getAttachments(
files: Array<EmailAttachment>,
workspaceId: string,
fileFolder: FileFolder,
): Promise<MessageAttachment[]> {
if (files.length === 0) {
return [];
@@ -224,7 +223,7 @@ export class EmailComposerService {
const fileStream = await this.fileService.getFileStreamById({
fileId: fileMetadata.id,
workspaceId,
fileFolder,
allowedFileFolders: EMAIL_ATTACHMENT_FILE_FOLDERS,
});
if (fileStream === null) {
@@ -312,7 +311,6 @@ export class EmailComposerService {
async composeEmail(
parameters: ComposeEmailParams,
context: ToolExecutionContext,
options: { attachmentsFileFolder: FileFolder },
): Promise<EmailComposerResult> {
const { workspaceId } = context;
const { subject, body, files, inReplyTo } = parameters;
@@ -390,11 +388,7 @@ export class EmailComposerService {
);
}
const attachments = await this.getAttachments(
files || [],
workspaceId,
options.attachmentsFileFolder,
);
const attachments = await this.getAttachments(files || [], workspaceId);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
@@ -1,7 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
@@ -33,7 +31,6 @@ export class SendEmailTool implements Tool {
const result = await this.emailComposerService.composeEmail(
parameters,
context,
{ attachmentsFileFolder: FileFolder.Workflow },
);
if (!result.success) {
@@ -7,8 +7,6 @@ import {
} from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { FileFolder } from 'twenty-shared/types';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
@@ -70,7 +68,6 @@ export class SendEmailResolver {
inReplyTo: input.inReplyTo,
},
{ workspaceId: workspace.id },
{ attachmentsFileFolder: FileFolder.EmailAttachment },
);
if (!result.success) {
@@ -9,6 +9,7 @@ import {
type EmailStepLogMode,
} from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util';
import { resolveEmailBody } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-body.util';
import { resolveEmailFiles } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-files.util';
import { ToolBackedWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-backed/tool-backed.workflow-action';
export abstract class EmailWorkflowActionBase extends ToolBackedWorkflowAction<WorkflowSendEmailActionInput> {
@@ -18,13 +19,13 @@ export abstract class EmailWorkflowActionBase extends ToolBackedWorkflowAction<W
rawInput: WorkflowSendEmailActionInput,
context: Record<string, unknown>,
): Promise<WorkflowSendEmailActionInput> {
if (!isDefined(rawInput.body)) {
return rawInput;
}
const files = resolveEmailFiles(rawInput.files, context);
const renderedBody = await resolveEmailBody(rawInput.body, context);
const body = isDefined(rawInput.body)
? await resolveEmailBody(rawInput.body, context)
: rawInput.body;
return { ...rawInput, body: renderedBody };
return { ...rawInput, body, files };
}
protected buildStepLog({
@@ -1,3 +1,4 @@
import { type EmailAttachment } from 'twenty-shared/types';
import { type EmailRecipients } from 'twenty-shared/workflow';
export type WorkflowSendEmailActionInput = {
@@ -5,5 +6,6 @@ export type WorkflowSendEmailActionInput = {
recipients: EmailRecipients;
subject?: string;
body?: string;
files?: EmailAttachment[];
inReplyTo?: string;
};
@@ -0,0 +1,70 @@
import { resolveEmailFiles } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-files.util';
describe('resolveEmailFiles', () => {
it('conforms static uploads to id/name, dropping extra metadata', () => {
const upload = {
id: 'file-1',
name: 'logo.png',
size: 1024,
type: 'image/png',
createdAt: '2026-01-01T00:00:00.000Z',
};
expect(resolveEmailFiles([upload], {})).toEqual([
{ id: 'file-1', name: 'logo.png' },
]);
});
it('resolves a variable bound to a record Files field and flattens it', () => {
const context = {
step: {
first: {
files: [
{ fileId: 'file-1', label: 'Contract', extension: '.pdf' },
{ fileId: 'file-2', label: 'Proposal', extension: '.pdf' },
],
},
},
};
expect(resolveEmailFiles(['{{step.first.files}}'], context)).toEqual([
{ id: 'file-1', name: 'Contract.pdf' },
{ id: 'file-2', name: 'Proposal.pdf' },
]);
});
it('mixes a static upload with a resolved record Files variable', () => {
const context = {
step: { files: [{ fileId: 'file-2', label: 'Deck', extension: '.pdf' }] },
};
expect(
resolveEmailFiles(
[{ id: 'file-1', name: 'brochure.pdf' }, '{{step.files}}'],
context,
),
).toEqual([
{ id: 'file-1', name: 'brochure.pdf' },
{ id: 'file-2', name: 'Deck.pdf' },
]);
});
it('falls back to the file id when no usable name is present', () => {
expect(resolveEmailFiles([{ id: 'file-1' }], {})).toEqual([
{ id: 'file-1', name: 'file-1' },
]);
});
it('returns an empty array when there is nothing to attach', () => {
expect(resolveEmailFiles(undefined, {})).toEqual([]);
expect(resolveEmailFiles([], {})).toEqual([]);
});
it('skips entries that resolve to nothing or lack a file id', () => {
const context = { step: {} };
expect(
resolveEmailFiles(['{{step.missing}}', { name: 'no-id.pdf' }], context),
).toEqual([]);
});
});
@@ -0,0 +1,42 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type EmailAttachment } from 'twenty-shared/types';
import { isDefined, resolveInput } from 'twenty-shared/utils';
import { type WorkflowAttachment } from 'twenty-shared/workflow';
import { type FileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
export const resolveEmailFiles = (
files: unknown,
context: Record<string, unknown>,
): EmailAttachment[] => {
const resolvedFiles = resolveInput(files, context);
if (!Array.isArray(resolvedFiles)) {
return [];
}
return resolvedFiles
.flat()
.map((file): EmailAttachment | undefined => {
if (!isDefined(file) || typeof file !== 'object') {
return undefined;
}
const attachment = file as WorkflowAttachment | FileOutput;
const id = 'id' in attachment ? attachment.id : attachment.fileId;
if (!isNonEmptyString(id)) {
return undefined;
}
const name =
'id' in attachment
? attachment.name
: [attachment.label, attachment.extension]
.filter(isNonEmptyString)
.join('');
return { id, name: isNonEmptyString(name) ? name : id };
})
.filter(isDefined);
};
+5 -1
View File
@@ -51,7 +51,11 @@ export { workflowLogicFunctionActionSettingsSchema } from './schemas/logic-funct
export { workflowManualTriggerSchema } from './schemas/manual-trigger-schema';
export { objectRecordSchema } from './schemas/object-record-schema';
export { workflowSendEmailActionSchema } from './schemas/send-email-action-schema';
export { workflowSendEmailActionSettingsSchema } from './schemas/send-email-action-settings-schema';
export type { WorkflowEmailFiles } from './schemas/send-email-action-settings-schema';
export {
workflowEmailFilesSchema,
workflowSendEmailActionSettingsSchema,
} from './schemas/send-email-action-settings-schema';
export { stepFilterGroupSchema } from './schemas/step-filter-group-schema';
export { stepFilterSchema } from './schemas/step-filter-schema';
export { workflowUpdateRecordActionSchema } from './schemas/update-record-action-schema';
@@ -2,6 +2,24 @@ import { z } from 'zod';
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
import { workflowFileSchema } from './workflow-file-action-schema';
export const workflowEmailFilesSchema = z
.array(
z.union([
workflowFileSchema,
z
.string()
.regex(
/^{{[^{}]+}}$/,
'Expected a workflow variable reference like {{stepId.path}}',
)
.describe('A workflow variable reference resolving to files'),
]),
)
.optional()
.default([]);
export type WorkflowEmailFiles = z.infer<typeof workflowEmailFilesSchema>;
export const workflowSendEmailActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({
@@ -13,7 +31,7 @@ export const workflowSendEmailActionSettingsSchema =
}),
subject: z.string().optional(),
body: z.string().optional(),
files: z.array(workflowFileSchema).optional().default([]),
files: workflowEmailFilesSchema,
inReplyTo: z.string().trim().optional(),
}),
});