diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
index f7f44f7ea7..d14922c081 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
@@ -3441,6 +3441,7 @@ type Mutation {
updateNavigationMenuItem(input: UpdateOneNavigationMenuItemInput!): NavigationMenuItem!
deleteManyNavigationMenuItems(ids: [UUID!]!): [NavigationMenuItem!]!
deleteNavigationMenuItem(id: UUID!): NavigationMenuItem!
+ uploadEmailAttachmentFile(file: Upload!): FileWithSignedUrl!
uploadAIChatFile(file: Upload!): FileWithSignedUrl!
uploadWorkflowFile(file: Upload!): FileWithSignedUrl!
uploadWorkspaceLogo(file: Upload!): FileWithSignedUrl!
@@ -4592,6 +4593,12 @@ input SendEmailInput {
subject: String!
body: String!
inReplyTo: String
+ files: [SendEmailAttachmentInput!]
+}
+
+input SendEmailAttachmentInput {
+ id: String!
+ name: String!
}
input EmailAccountConnectionParameters {
@@ -4658,6 +4665,7 @@ enum FileFolder {
FilesField
Dependencies
Workflow
+ EmailAttachment
AppTarball
GeneratedSdkClient
}
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
index 447cfcba56..1d99496c36 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
@@ -2894,6 +2894,7 @@ export interface Mutation {
updateNavigationMenuItem: NavigationMenuItem
deleteManyNavigationMenuItems: NavigationMenuItem[]
deleteNavigationMenuItem: NavigationMenuItem
+ uploadEmailAttachmentFile: FileWithSignedUrl
uploadAIChatFile: FileWithSignedUrl
uploadWorkflowFile: FileWithSignedUrl
uploadWorkspaceLogo: FileWithSignedUrl
@@ -3112,7 +3113,7 @@ export type AiModelRole = 'FAST' | 'SMART'
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
-export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'AppTarball' | 'GeneratedSdkClient'
+export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient'
export interface Subscription {
onEventSubscription?: EventSubscription
@@ -6208,6 +6209,7 @@ export interface MutationGenqlSelection{
updateNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: UpdateOneNavigationMenuItemInput} })
deleteManyNavigationMenuItems?: (NavigationMenuItemGenqlSelection & { __args: {ids: Scalars['UUID'][]} })
deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
+ uploadEmailAttachmentFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadAIChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkflowFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
@@ -6727,7 +6729,9 @@ export interface DeleteSsoInput {identityProviderId: Scalars['UUID']}
export interface EditSsoInput {id: Scalars['UUID'],status: SSOIdentityProviderStatus}
-export interface SendEmailInput {connectedAccountId: Scalars['String'],to: Scalars['String'],cc?: (Scalars['String'] | null),bcc?: (Scalars['String'] | null),subject: Scalars['String'],body: Scalars['String'],inReplyTo?: (Scalars['String'] | null)}
+export interface SendEmailInput {connectedAccountId: Scalars['String'],to: Scalars['String'],cc?: (Scalars['String'] | null),bcc?: (Scalars['String'] | null),subject: Scalars['String'],body: Scalars['String'],inReplyTo?: (Scalars['String'] | null),files?: (SendEmailAttachmentInput[] | null)}
+
+export interface SendEmailAttachmentInput {id: Scalars['String'],name: Scalars['String']}
export interface EmailAccountConnectionParameters {IMAP?: (ConnectionParameters | null),SMTP?: (ConnectionParameters | null),CALDAV?: (ConnectionParameters | null)}
@@ -9665,6 +9669,7 @@ export const enumFileFolder = {
FilesField: 'FilesField' as const,
Dependencies: 'Dependencies' as const,
Workflow: 'Workflow' as const,
+ EmailAttachment: 'EmailAttachment' as const,
AppTarball: 'AppTarball' as const,
GeneratedSdkClient: 'GeneratedSdkClient' as const
}
diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts
index 84699fcb8e..295d46703c 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/types.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts
@@ -88,9 +88,9 @@ export default {
373,
380,
411,
- 491,
- 496,
- 497
+ 492,
+ 497,
+ 498
],
"types": {
"BillingProductDTO": {
@@ -7308,6 +7308,15 @@ export default {
]
}
],
+ "uploadEmailAttachmentFile": [
+ 142,
+ {
+ "file": [
+ 380,
+ "Upload!"
+ ]
+ }
+ ],
"uploadAIChatFile": [
142,
{
@@ -8980,7 +8989,7 @@ export default {
"String!"
],
"connectionParameters": [
- 488,
+ 489,
"EmailAccountConnectionParameters!"
],
"id": [
@@ -8992,7 +9001,7 @@ export default {
200,
{
"input": [
- 490,
+ 491,
"UpdateLabPublicFeatureFlagInput!"
]
}
@@ -9079,7 +9088,7 @@ export default {
6,
{
"role": [
- 491,
+ 492,
"AiModelRole!"
],
"modelId": [
@@ -9284,7 +9293,7 @@ export default {
68,
{
"input": [
- 492,
+ 493,
"CreateOneAppTokenInput!"
]
}
@@ -9320,7 +9329,7 @@ export default {
6,
{
"workspaceMigration": [
- 494,
+ 495,
"WorkspaceMigrationInput!"
]
}
@@ -9394,7 +9403,7 @@ export default {
"String!"
],
"fileFolder": [
- 497,
+ 498,
"FileFolder!"
],
"filePath": [
@@ -11590,19 +11599,33 @@ export default {
"inReplyTo": [
1
],
+ "files": [
+ 488
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "SendEmailAttachmentInput": {
+ "id": [
+ 1
+ ],
+ "name": [
+ 1
+ ],
"__typename": [
1
]
},
"EmailAccountConnectionParameters": {
"IMAP": [
- 489
+ 490
],
"SMTP": [
- 489
+ 490
],
"CALDAV": [
- 489
+ 490
],
"__typename": [
1
@@ -11642,7 +11665,7 @@ export default {
"AiModelRole": {},
"CreateOneAppTokenInput": {
"appToken": [
- 493
+ 494
],
"__typename": [
1
@@ -11658,7 +11681,7 @@ export default {
},
"WorkspaceMigrationInput": {
"actions": [
- 495
+ 496
],
"__typename": [
1
@@ -11666,7 +11689,7 @@ export default {
},
"WorkspaceMigrationDeleteActionInput": {
"type": [
- 496
+ 497
],
"metadataName": [
348
@@ -11694,7 +11717,7 @@ export default {
253,
{
"input": [
- 499,
+ 500,
"LogicFunctionLogsInput!"
]
}
diff --git a/packages/twenty-front/.storybook/preview.tsx b/packages/twenty-front/.storybook/preview.tsx
index 86aaa32257..b103ff720d 100644
--- a/packages/twenty-front/.storybook/preview.tsx
+++ b/packages/twenty-front/.storybook/preview.tsx
@@ -4,6 +4,8 @@ import { type Preview } from '@storybook/react-vite';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
+// oxlint-disable-next-line no-restricted-imports
+import { FileUploadProvider } from '../src/modules/file-upload/components/FileUploadProvider';
// oxlint-disable-next-line no-restricted-imports
import { RootDecorator } from '../src/testing/decorators/RootDecorator';
// oxlint-disable-next-line no-restricted-imports
@@ -63,11 +65,13 @@ const preview: Preview = {
return (
-
-
-
+
+
+
+
+
);
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 3a84d4a932..bf2fad7841 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -1918,6 +1918,7 @@ export enum FileFolder {
BuiltLogicFunction = 'BuiltLogicFunction',
CorePicture = 'CorePicture',
Dependencies = 'Dependencies',
+ EmailAttachment = 'EmailAttachment',
File = 'File',
FilesField = 'FilesField',
GeneratedSdkClient = 'GeneratedSdkClient',
@@ -2733,6 +2734,7 @@ export type Mutation = {
uploadAIChatFile: FileWithSignedUrl;
uploadAppTarball: ApplicationRegistration;
uploadApplicationFile: File;
+ uploadEmailAttachmentFile: FileWithSignedUrl;
uploadFilesFieldFile: FileWithSignedUrl;
uploadFilesFieldFileByUniversalIdentifier: FileWithSignedUrl;
uploadWorkflowFile: FileWithSignedUrl;
@@ -3748,6 +3750,11 @@ export type MutationUploadApplicationFileArgs = {
};
+export type MutationUploadEmailAttachmentFileArgs = {
+ file: Scalars['Upload'];
+};
+
+
export type MutationUploadFilesFieldFileArgs = {
fieldMetadataId: Scalars['String'];
file: Scalars['Upload'];
@@ -5075,11 +5082,17 @@ export type SendChatMessageResult = {
streamId?: Maybe;
};
+export type SendEmailAttachmentInput = {
+ id: Scalars['String'];
+ name: Scalars['String'];
+};
+
export type SendEmailInput = {
bcc?: InputMaybe;
body: Scalars['String'];
cc?: InputMaybe;
connectedAccountId: Scalars['String'];
+ files?: InputMaybe>;
inReplyTo?: InputMaybe;
subject: Scalars['String'];
to: Scalars['String'];
@@ -6819,6 +6832,13 @@ export type FindOnePageLayoutQueryVariables = Exact<{
export type FindOnePageLayoutQuery = { __typename?: 'Query', getPageLayout?: { __typename?: 'PageLayout', id: string, name: string, objectMetadataId?: string | null, type: PageLayoutType, defaultTabToFocusOnMobileAndSidePanelId?: string | null, createdAt: string, updatedAt: string, tabs?: Array<{ __typename?: 'PageLayoutTab', id: string, applicationId: string, title: string, icon?: string | null, position: number, layoutMode?: PageLayoutTabLayoutMode | null, pageLayoutId: string, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: string, title: string, type: WidgetType, objectMetadataId?: string | null, createdAt: string, updatedAt: string, deletedAt?: string | null, conditionalDisplay?: any | null, conditionalAvailabilityExpression?: string | null, pageLayoutTabId: string, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, position?: { __typename?: 'PageLayoutWidgetCanvasPosition', layoutMode: PageLayoutTabLayoutMode } | { __typename?: 'PageLayoutWidgetGridPosition', layoutMode: PageLayoutTabLayoutMode, row: number, column: number, rowSpan: number, columnSpan: number } | { __typename?: 'PageLayoutWidgetVerticalListPosition', layoutMode: PageLayoutTabLayoutMode, index: number } | null, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: string, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: string, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: string | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, splitMultiValueFields?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'CalendarConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'EmailThreadConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'EmailsConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldConfiguration', configurationType: WidgetConfigurationType, fieldDisplayMode: FieldDisplayMode, fieldMetadataId: string } | { __typename?: 'FieldRichTextConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldsConfiguration', configurationType: WidgetConfigurationType, viewId?: string | null, newFieldDefaultVisibility?: boolean | null, shouldAllowUserToSeeHiddenFields?: boolean | null } | { __typename?: 'FilesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FrontComponentConfiguration', configurationType: WidgetConfigurationType, frontComponentId: string } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: string, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: string | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, splitMultiValueFields?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'NotesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: string, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, manualSortOrder?: Array | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, hideEmptyCategory?: boolean | null, splitMultiValueFields?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'RecordTableConfiguration', configurationType: WidgetConfigurationType, viewId?: string | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextBody', blocknote?: string | null, markdown?: string | null } } | { __typename?: 'TasksConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'TimelineConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'ViewConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowRunConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowVersionConfiguration', configurationType: WidgetConfigurationType } }> | null }> | null } | null };
+export type UploadEmailAttachmentFileMutationVariables = Exact<{
+ file: Scalars['Upload'];
+}>;
+
+
+export type UploadEmailAttachmentFileMutation = { __typename?: 'Mutation', uploadEmailAttachmentFile: { __typename?: 'FileWithSignedUrl', id: string, path: string, size: number, createdAt: string, url: string } };
+
export type UploadFilesFieldFileMutationVariables = Exact<{
file: Scalars['Upload'];
fieldMetadataId: Scalars['String'];
@@ -8459,6 +8479,7 @@ export const ValidatePasswordResetTokenDocument = {"kind":"Document","definition
export const FindManyCommandMenuItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyCommandMenuItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commandMenuItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CommandMenuItemFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommandMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CommandMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workflowVersionId"}},{"kind":"Field","name":{"kind":"Name","value":"frontComponentId"}},{"kind":"Field","name":{"kind":"Name","value":"frontComponent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isHeadless"}}]}},{"kind":"Field","name":{"kind":"Name","value":"engineComponentKey"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"shortLabel"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"isPinned"}},{"kind":"Field","name":{"kind":"Name","value":"payload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PathCommandMenuItemPayload"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectMetadataCommandMenuItemPayload"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectMetadataItemId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"hotKeys"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalAvailabilityExpression"}},{"kind":"Field","name":{"kind":"Name","value":"availabilityType"}},{"kind":"Field","name":{"kind":"Name","value":"availabilityObjectMetadataId"}}]}}]} as unknown as DocumentNode;
export const DuplicateDashboardDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DuplicateDashboard"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"duplicateDashboard"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode;
export const FindOnePageLayoutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOnePageLayout"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPageLayout"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutWidgetFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidget"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalAvailabilityExpression"}},{"kind":"Field","name":{"kind":"Name","value":"gridPosition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"column"}},{"kind":"Field","name":{"kind":"Name","value":"columnSpan"}},{"kind":"Field","name":{"kind":"Name","value":"row"}},{"kind":"Field","name":{"kind":"Name","value":"rowSpan"}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetGridPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"row"}},{"kind":"Field","name":{"kind":"Name","value":"column"}},{"kind":"Field","name":{"kind":"Name","value":"rowSpan"}},{"kind":"Field","name":{"kind":"Name","value":"columnSpan"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetVerticalListPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"index"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetCanvasPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"configuration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BarChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"omitNullValues"}},{"kind":"Field","name":{"kind":"Name","value":"axisNameDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMin"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMax"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"groupMode"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"isCumulative"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LineChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"omitNullValues"}},{"kind":"Field","name":{"kind":"Name","value":"axisNameDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMin"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMax"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"isStacked"}},{"kind":"Field","name":{"kind":"Name","value":"isCumulative"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PieChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"groupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"groupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"dateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"orderBy"}},{"kind":"Field","name":{"kind":"Name","value":"manualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showCenterMetric"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"hideEmptyCategory"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AggregateChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"format"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"prefix"}},{"kind":"Field","name":{"kind":"Name","value":"suffix"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}},{"kind":"Field","name":{"kind":"Name","value":"ratioAggregateConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"optionValue"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"GaugeChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"IframeConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StandaloneRichTextConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"body"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"blocknote"}},{"kind":"Field","name":{"kind":"Name","value":"markdown"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CalendarConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EmailsConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EmailThreadConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"fieldDisplayMode"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldRichTextConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldsConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"newFieldDefaultVisibility"}},{"kind":"Field","name":{"kind":"Name","value":"shouldAllowUserToSeeHiddenFields"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FilesConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotesConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TasksConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RecordTableConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowRunConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowVersionConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FrontComponentConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"frontComponentId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutTabId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutTabFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutTab"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"widgets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutWidgetFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayout"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"defaultTabToFocusOnMobileAndSidePanelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tabs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutTabFragment"}}]}}]}}]} as unknown as DocumentNode;
+export const UploadEmailAttachmentFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadEmailAttachmentFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadEmailAttachmentFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode;
export const UploadFilesFieldFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadFilesFieldFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fieldMetadataId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadFilesFieldFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}},{"kind":"Argument","name":{"kind":"Name","value":"fieldMetadataId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fieldMetadataId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode;
export const UploadWorkflowFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkflowFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkflowFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode;
export const RenewApplicationTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RenewApplicationToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRefreshToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renewApplicationToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRefreshToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRefreshToken"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationAccessToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"applicationRefreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]} as unknown as DocumentNode;
diff --git a/packages/twenty-front/src/modules/activities/emails/components/ComposeEmailButton.tsx b/packages/twenty-front/src/modules/activities/emails/components/ComposeEmailButton.tsx
index 9b7467886c..10de2c5eed 100644
--- a/packages/twenty-front/src/modules/activities/emails/components/ComposeEmailButton.tsx
+++ b/packages/twenty-front/src/modules/activities/emails/components/ComposeEmailButton.tsx
@@ -1,91 +1,17 @@
-import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
-import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
-import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
-import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
-import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
-import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
-import { isDefined } from 'twenty-shared/utils';
+import { useComposeEmailForTargetRecord } from '@/activities/emails/hooks/useComposeEmailForTargetRecord';
import { IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
-import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const ComposeEmailButton = () => {
- const targetRecord = useTargetRecord();
- const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
- const navigateSettings = useNavigateSettings();
- const { connectedAccountId, loading: accountLoading } =
- useFirstConnectedAccount();
-
- const isPerson =
- targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Person;
- const isCompany =
- targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Company;
- const isOpportunity =
- targetRecord.targetObjectNameSingular ===
- CoreObjectNameSingular.Opportunity;
-
- const { record: personRecord } = useFindOneRecord({
- objectNameSingular: CoreObjectNameSingular.Person,
- objectRecordId: targetRecord.id,
- recordGqlFields: { id: true, emails: { primaryEmail: true } },
- skip: !isPerson,
- });
-
- const { records: companyPeople } = useFindManyRecords({
- objectNameSingular: CoreObjectNameSingular.Person,
- filter: { companyId: { eq: targetRecord.id } },
- recordGqlFields: { id: true, emails: { primaryEmail: true } },
- limit: 1,
- skip: !isCompany,
- });
-
- const { record: opportunityRecord } = useFindOneRecord({
- objectNameSingular: CoreObjectNameSingular.Opportunity,
- objectRecordId: targetRecord.id,
- recordGqlFields: {
- id: true,
- pointOfContact: { id: true, emails: { primaryEmail: true } },
- company: { id: true },
- },
- skip: !isOpportunity,
- });
-
- const resolveDefaultTo = (): string => {
- if (isPerson) {
- return personRecord?.emails?.primaryEmail ?? '';
- }
- if (isCompany) {
- return companyPeople[0]?.emails?.primaryEmail ?? '';
- }
- if (isOpportunity) {
- return opportunityRecord?.pointOfContact?.emails?.primaryEmail ?? '';
- }
- return '';
- };
-
- const handleClick = () => {
- if (!isDefined(connectedAccountId)) {
- navigateSettings(SettingsPath.NewAccount);
-
- return;
- }
-
- openComposeEmailInSidePanel({
- connectedAccountId,
- defaultTo: resolveDefaultTo(),
- });
- };
-
- if (accountLoading) {
- return null;
- }
+ const { openComposer, loading } = useComposeEmailForTargetRecord();
return (
);
};
diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailAttachmentsField.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailAttachmentsField.tsx
new file mode 100644
index 0000000000..432f9af172
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/components/EmailAttachmentsField.tsx
@@ -0,0 +1,120 @@
+import { styled } from '@linaria/react';
+import { useLingui } from '@lingui/react/macro';
+import { useContext } from 'react';
+import { type EmailAttachment } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+import { IconUpload } from 'twenty-ui/display';
+import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
+
+import { useUploadEmailAttachment } from '@/activities/emails/hooks/useUploadEmailAttachment';
+import { AttachmentChip } from '@/file/components/AttachmentChip';
+import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
+import { InputLabel } from '@/ui/input/components/InputLabel';
+
+type EmailAttachmentsFieldProps = {
+ files: EmailAttachment[];
+ onChange: (files: EmailAttachment[]) => void;
+ label?: string;
+};
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const StyledUploadArea = styled.div<{ hasFiles: boolean }>`
+ background-color: ${themeCssVariables.background.transparent.lighter};
+ border: 1px solid ${themeCssVariables.border.color.medium};
+ border-radius: ${themeCssVariables.border.radius.sm};
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: ${({ hasFiles }) => (hasFiles ? 'auto' : '24px')};
+ padding-bottom: ${themeCssVariables.spacing[1]};
+ padding-left: ${themeCssVariables.spacing[2]};
+ padding-right: ${themeCssVariables.spacing[2]};
+ padding-top: ${themeCssVariables.spacing[1]};
+
+ &:hover {
+ background-color: ${themeCssVariables.background.transparent.light};
+ border-color: ${themeCssVariables.border.color.strong};
+ }
+`;
+
+const StyledChipsContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: ${themeCssVariables.spacing[1]};
+`;
+
+const StyledUploadAreaLabel = styled.div`
+ color: ${themeCssVariables.font.color.tertiary};
+ display: flex;
+ font-size: ${themeCssVariables.font.size.sm};
+ font-weight: ${themeCssVariables.font.weight.medium};
+ gap: ${themeCssVariables.spacing[1]};
+ justify-content: center;
+`;
+
+export const EmailAttachmentsField = ({
+ files,
+ label,
+ onChange,
+}: EmailAttachmentsFieldProps) => {
+ const { theme } = useContext(ThemeContext);
+ const { uploadEmailAttachment } = useUploadEmailAttachment();
+ const { openFileUpload } = useFileUpload();
+ const { t } = useLingui();
+
+ const handleUploadFiles = async (filesToUpload: File[]) => {
+ const uploadedFiles = await Promise.all(
+ filesToUpload.map((file) => uploadEmailAttachment(file)),
+ );
+
+ const successfulUploads = uploadedFiles.filter(isDefined);
+
+ if (successfulUploads.length > 0) {
+ onChange([...files, ...successfulUploads]);
+ }
+ };
+
+ const handleAddFileClick = () => {
+ openFileUpload({
+ multiple: true,
+ onUpload: handleUploadFiles,
+ });
+ };
+
+ const handleRemoveFile = (fileId: string) => {
+ onChange(files.filter((file) => file.id !== fileId));
+ };
+
+ return (
+
+ {label ? {label} : null}
+
+ 0}
+ onClick={handleAddFileClick}
+ >
+ {files.length > 0 ? (
+
+ {files.map((file) => (
+ handleRemoveFile(file.id)}
+ />
+ ))}
+
+ ) : (
+
+
+ {t`Upload file`}
+
+ )}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailComposer.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailComposer.tsx
index 8d98f0c651..d69492f0bf 100644
--- a/packages/twenty-front/src/modules/activities/emails/components/EmailComposer.tsx
+++ b/packages/twenty-front/src/modules/activities/emails/components/EmailComposer.tsx
@@ -7,6 +7,12 @@ import { IconArrowBackUp } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
+const StyledFooterWarning = styled.span`
+ color: ${themeCssVariables.color.red};
+ flex: 1;
+ font-size: ${themeCssVariables.font.size.xs};
+`;
+
const StyledComposerContainer = styled.div`
background: ${themeCssVariables.background.primary};
display: flex;
@@ -56,6 +62,11 @@ export const EmailComposer = ({
+ {composerState.exceedsRecipientLimit && (
+
+ {t`Too many recipients (${composerState.recipientCount}/${composerState.maxRecipients}).`}
+
+ )}
{onClose && (
+
);
};
diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx
index 025ff9e7c3..da83e035ce 100644
--- a/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx
+++ b/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx
@@ -5,6 +5,7 @@ import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomRes
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { ComposeEmailButton } from '@/activities/emails/components/ComposeEmailButton';
import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
+import { EmptyInboxPlaceholder } from '@/activities/emails/components/EmptyInboxPlaceholder';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
@@ -14,15 +15,7 @@ import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { Trans } from '@lingui/react/macro';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
-import {
- AnimatedPlaceholder,
- AnimatedPlaceholderEmptyContainer,
- AnimatedPlaceholderEmptySubTitle,
- AnimatedPlaceholderEmptyTextContainer,
- AnimatedPlaceholderEmptyTitle,
- EMPTY_PLACEHOLDER_TRANSITION_PROPS,
- Section,
-} from 'twenty-ui/layout';
+import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
type TimelineThread,
@@ -43,24 +36,19 @@ const StyledHeaderRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
+ margin-bottom: ${themeCssVariables.spacing[4]};
`;
-const StyledH1TitleWrapper = styled.div`
- > h2 {
- display: flex;
- gap: ${themeCssVariables.spacing[2]};
- }
+const StyledH1Title = styled(H1Title)`
+ display: flex;
+ gap: ${themeCssVariables.spacing[2]};
+ margin-bottom: 0;
`;
const StyledEmailCount = styled.span`
color: ${themeCssVariables.font.color.light};
`;
-const StyledComposeButtonRow = styled.div`
- display: flex;
- justify-content: flex-end;
-`;
-
export const EmailsCard = () => {
const targetRecord = useTargetRecord();
@@ -102,25 +90,7 @@ export const EmailsCard = () => {
if (!firstQueryLoading && !timelineThreads?.length) {
return (
-
-
-
-
-
-
-
- Empty Inbox
-
-
-
- No email exchange has occurred with this record yet.
-
-
-
-
+
);
}
@@ -129,17 +99,15 @@ export const EmailsCard = () => {
-
-
- Inbox{' '}
- {totalNumberOfThreads}
- >
- }
- fontColor={H1TitleFontColor.Primary}
- />
-
+
+ Inbox{' '}
+ {totalNumberOfThreads}
+ >
+ }
+ fontColor={H1TitleFontColor.Primary}
+ />
{!firstQueryLoading && (
diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmptyInboxPlaceholder.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmptyInboxPlaceholder.tsx
new file mode 100644
index 0000000000..73c21407be
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/components/EmptyInboxPlaceholder.tsx
@@ -0,0 +1,41 @@
+import { useComposeEmailForTargetRecord } from '@/activities/emails/hooks/useComposeEmailForTargetRecord';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { IconMail } from 'twenty-ui/display';
+import { Button } from 'twenty-ui/input';
+import {
+ AnimatedPlaceholder,
+ AnimatedPlaceholderEmptyContainer,
+ AnimatedPlaceholderEmptySubTitle,
+ AnimatedPlaceholderEmptyTextContainer,
+ AnimatedPlaceholderEmptyTitle,
+ EMPTY_PLACEHOLDER_TRANSITION_PROPS,
+} from 'twenty-ui/layout';
+
+export const EmptyInboxPlaceholder = () => {
+ const { t } = useLingui();
+ const { openComposer, loading } = useComposeEmailForTargetRecord();
+
+ return (
+
+
+
+
+ Empty Inbox
+
+
+ No email exchange has occurred with this record yet.
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/__tests__/useResolveDefaultEmailRecipient.test.ts b/packages/twenty-front/src/modules/activities/emails/hooks/__tests__/useResolveDefaultEmailRecipient.test.ts
new file mode 100644
index 0000000000..c5f97e1acb
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/hooks/__tests__/useResolveDefaultEmailRecipient.test.ts
@@ -0,0 +1,171 @@
+import { renderHook } from '@testing-library/react';
+
+import { useResolveDefaultEmailRecipient } from '@/activities/emails/hooks/useResolveDefaultEmailRecipient';
+import { CoreObjectNameSingular } from 'twenty-shared/types';
+
+const mockUseFindOneRecord = jest.fn();
+const mockUseFindManyRecords = jest.fn();
+
+jest.mock('@/object-record/hooks/useFindOneRecord', () => ({
+ useFindOneRecord: (args: unknown) => mockUseFindOneRecord(args),
+}));
+
+jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
+ useFindManyRecords: (args: unknown) => mockUseFindManyRecords(args),
+}));
+
+describe('useResolveDefaultEmailRecipient', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseFindOneRecord.mockReturnValue({
+ record: null,
+ loading: false,
+ });
+ mockUseFindManyRecords.mockReturnValue({
+ records: [],
+ loading: false,
+ });
+ });
+
+ it('should return the person primary email for a Person record', () => {
+ mockUseFindOneRecord.mockImplementation(
+ (args: { objectNameSingular: string }) => {
+ if (args.objectNameSingular === CoreObjectNameSingular.Person) {
+ return {
+ record: { emails: { primaryEmail: 'person@example.com' } },
+ loading: false,
+ };
+ }
+
+ return { record: null, loading: false };
+ },
+ );
+
+ const { result } = renderHook(() =>
+ useResolveDefaultEmailRecipient({
+ objectNameSingular: CoreObjectNameSingular.Person,
+ recordId: 'person-id',
+ }),
+ );
+
+ expect(result.current.defaultTo).toBe('person@example.com');
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should return the first company employee email for a Company record', () => {
+ mockUseFindManyRecords.mockReturnValue({
+ records: [{ emails: { primaryEmail: 'employee@company.com' } }],
+ loading: false,
+ });
+
+ const { result } = renderHook(() =>
+ useResolveDefaultEmailRecipient({
+ objectNameSingular: CoreObjectNameSingular.Company,
+ recordId: 'company-id',
+ }),
+ );
+
+ expect(result.current.defaultTo).toBe('employee@company.com');
+ });
+
+ it('should return the opportunity point of contact email', () => {
+ mockUseFindOneRecord.mockImplementation(
+ (args: { objectNameSingular: string }) => {
+ if (args.objectNameSingular === CoreObjectNameSingular.Opportunity) {
+ return {
+ record: {
+ pointOfContact: {
+ emails: { primaryEmail: 'contact@opp.com' },
+ },
+ },
+ loading: false,
+ };
+ }
+
+ return { record: null, loading: false };
+ },
+ );
+
+ const { result } = renderHook(() =>
+ useResolveDefaultEmailRecipient({
+ objectNameSingular: CoreObjectNameSingular.Opportunity,
+ recordId: 'opp-id',
+ }),
+ );
+
+ expect(result.current.defaultTo).toBe('contact@opp.com');
+ });
+
+ it('should return empty string for unknown object types', () => {
+ const { result } = renderHook(() =>
+ useResolveDefaultEmailRecipient({
+ objectNameSingular: 'customObject',
+ recordId: 'some-id',
+ }),
+ );
+
+ expect(result.current.defaultTo).toBe('');
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should return empty string when recordId is null', () => {
+ const { result } = renderHook(() =>
+ useResolveDefaultEmailRecipient({
+ objectNameSingular: CoreObjectNameSingular.Person,
+ recordId: null,
+ }),
+ );
+
+ expect(result.current.defaultTo).toBe('');
+ });
+
+ it('should report loading when the relevant query is in flight', () => {
+ mockUseFindOneRecord.mockImplementation(
+ (args: { objectNameSingular: string }) => {
+ if (args.objectNameSingular === CoreObjectNameSingular.Person) {
+ return { record: null, loading: true };
+ }
+
+ return { record: null, loading: false };
+ },
+ );
+
+ const { result } = renderHook(() =>
+ useResolveDefaultEmailRecipient({
+ objectNameSingular: CoreObjectNameSingular.Person,
+ recordId: 'person-id',
+ }),
+ );
+
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('should pass skip=true to queries for non-matching object types', () => {
+ renderHook(() =>
+ useResolveDefaultEmailRecipient({
+ objectNameSingular: CoreObjectNameSingular.Person,
+ recordId: 'person-id',
+ }),
+ );
+
+ // Person query should NOT be skipped
+ const personCall = mockUseFindOneRecord.mock.calls.find(
+ (call: { objectNameSingular: string }[]) =>
+ call[0].objectNameSingular === CoreObjectNameSingular.Person,
+ );
+
+ expect(personCall?.[0].skip).toBe(false);
+
+ // Opportunity query SHOULD be skipped
+ const oppCall = mockUseFindOneRecord.mock.calls.find(
+ (call: { objectNameSingular: string }[]) =>
+ call[0].objectNameSingular === CoreObjectNameSingular.Opportunity,
+ );
+
+ expect(oppCall?.[0].skip).toBe(true);
+
+ // Company people query SHOULD be skipped
+ expect(mockUseFindManyRecords.mock.calls[0][0].skip).toBe(true);
+ });
+});
diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useComposeEmailForTargetRecord.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useComposeEmailForTargetRecord.ts
new file mode 100644
index 0000000000..31870e388b
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/hooks/useComposeEmailForTargetRecord.ts
@@ -0,0 +1,39 @@
+import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
+import { useResolveDefaultEmailRecipient } from '@/activities/emails/hooks/useResolveDefaultEmailRecipient';
+import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
+import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
+import { SettingsPath } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+import { useNavigateSettings } from '~/hooks/useNavigateSettings';
+
+export const useComposeEmailForTargetRecord = () => {
+ const targetRecord = useTargetRecord();
+ const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
+ const navigateSettings = useNavigateSettings();
+ const { connectedAccountId, loading: accountLoading } =
+ useFirstConnectedAccount();
+
+ const { defaultTo, loading: recipientLoading } =
+ useResolveDefaultEmailRecipient({
+ objectNameSingular: targetRecord.targetObjectNameSingular,
+ recordId: targetRecord.id,
+ });
+
+ const openComposer = () => {
+ if (!isDefined(connectedAccountId)) {
+ navigateSettings(SettingsPath.NewAccount);
+
+ return;
+ }
+
+ openComposeEmailInSidePanel({
+ connectedAccountId,
+ defaultTo,
+ });
+ };
+
+ return {
+ openComposer,
+ loading: accountLoading || recipientLoading,
+ };
+};
diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useEmailComposerState.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useEmailComposerState.ts
index d20c055f51..0df7aed464 100644
--- a/packages/twenty-front/src/modules/activities/emails/hooks/useEmailComposerState.ts
+++ b/packages/twenty-front/src/modules/activities/emails/hooks/useEmailComposerState.ts
@@ -1,4 +1,6 @@
-import { useCallback, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
+import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
+import { type EmailAttachment } from 'twenty-shared/types';
import { useSendEmail } from '@/activities/emails/hooks/useSendEmail';
@@ -10,6 +12,12 @@ type UseEmailComposerStateArgs = {
onSent?: () => void;
};
+const countRecipients = (csv: string): number =>
+ csv
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0).length;
+
export const useEmailComposerState = ({
connectedAccountId: initialConnectedAccountId,
defaultTo = '',
@@ -26,14 +34,25 @@ export const useEmailComposerState = ({
const [subject, setSubject] = useState(defaultSubject);
const [body, setBody] = useState('');
const [showCcBcc, setShowCcBcc] = useState(false);
+ const [files, setFiles] = useState([]);
const { sendEmail, loading } = useSendEmail();
+ const recipientCount = useMemo(
+ () => countRecipients(to) + countRecipients(cc) + countRecipients(bcc),
+ [to, cc, bcc],
+ );
+
+ const exceedsRecipientLimit = recipientCount > MAX_EMAIL_RECIPIENTS;
+
const canSend =
- to.trim().length > 0 && connectedAccountId.length > 0 && !loading;
+ to.trim().length > 0 &&
+ connectedAccountId.length > 0 &&
+ !loading &&
+ !exceedsRecipientLimit;
const handleSend = useCallback(async () => {
- if (!to.trim() || !connectedAccountId) {
+ if (!to.trim() || !connectedAccountId || exceedsRecipientLimit) {
return;
}
@@ -49,6 +68,7 @@ export const useEmailComposerState = ({
subject,
body,
inReplyTo: defaultInReplyTo,
+ files: files.length > 0 ? files : undefined,
});
if (success) {
@@ -62,8 +82,10 @@ export const useEmailComposerState = ({
subject,
body,
defaultInReplyTo,
+ files,
sendEmail,
onSent,
+ exceedsRecipientLimit,
]);
return {
@@ -81,10 +103,15 @@ export const useEmailComposerState = ({
setBody,
showCcBcc,
setShowCcBcc,
+ files,
+ setFiles,
handleSend,
loading,
canSend,
defaultTo,
defaultSubject,
+ recipientCount,
+ exceedsRecipientLimit,
+ maxRecipients: MAX_EMAIL_RECIPIENTS,
};
};
diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useFirstConnectedAccount.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useFirstConnectedAccount.ts
index 0a09f02c23..583e8a93a3 100644
--- a/packages/twenty-front/src/modules/activities/emails/hooks/useFirstConnectedAccount.ts
+++ b/packages/twenty-front/src/modules/activities/emails/hooks/useFirstConnectedAccount.ts
@@ -2,10 +2,18 @@ import { useQuery } from '@apollo/client/react';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
-export const useFirstConnectedAccount = () => {
+type UseFirstConnectedAccountOptions = {
+ skip?: boolean;
+};
+
+export const useFirstConnectedAccount = (
+ options?: UseFirstConnectedAccountOptions,
+) => {
const { data, loading } = useQuery<{
myConnectedAccounts: { id: string; handle: string }[];
- }>(GET_MY_CONNECTED_ACCOUNTS);
+ }>(GET_MY_CONNECTED_ACCOUNTS, {
+ skip: options?.skip,
+ });
const firstAccount = data?.myConnectedAccounts?.[0] ?? null;
diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useOpenEmailInAppOrFallback.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useOpenEmailInAppOrFallback.ts
new file mode 100644
index 0000000000..db5057870c
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/hooks/useOpenEmailInAppOrFallback.ts
@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
+import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
+import { isDefined } from 'twenty-shared/utils';
+
+type UseOpenEmailInAppOrFallbackOptions = {
+ skip?: boolean;
+};
+
+// Falls back to mailto: when no connected account exists, rather than
+// redirecting to settings.
+export const useOpenEmailInAppOrFallback = (
+ options?: UseOpenEmailInAppOrFallbackOptions,
+) => {
+ const { connectedAccountId, loading } = useFirstConnectedAccount({
+ skip: options?.skip,
+ });
+ const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
+
+ const openEmail = useCallback(
+ (email: string) => {
+ if (isDefined(connectedAccountId)) {
+ openComposeEmailInSidePanel({
+ connectedAccountId,
+ defaultTo: email,
+ });
+
+ return;
+ }
+
+ window.open(`mailto:${email}`, '_blank');
+ },
+ [connectedAccountId, openComposeEmailInSidePanel],
+ );
+
+ return { openEmail, loading };
+};
diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useResolveDefaultEmailRecipient.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useResolveDefaultEmailRecipient.ts
new file mode 100644
index 0000000000..bd636c437c
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/hooks/useResolveDefaultEmailRecipient.ts
@@ -0,0 +1,64 @@
+import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
+import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
+import { CoreObjectNameSingular } from 'twenty-shared/types';
+
+type UseResolveDefaultEmailRecipientParams = {
+ objectNameSingular: string | null | undefined;
+ recordId: string | null | undefined;
+};
+
+export const useResolveDefaultEmailRecipient = ({
+ objectNameSingular,
+ recordId,
+}: UseResolveDefaultEmailRecipientParams) => {
+ const isPerson = objectNameSingular === CoreObjectNameSingular.Person;
+ const isCompany = objectNameSingular === CoreObjectNameSingular.Company;
+ const isOpportunity =
+ objectNameSingular === CoreObjectNameSingular.Opportunity;
+
+ const skipPerson = !isPerson || !recordId;
+ const skipCompanyPeople = !isCompany || !recordId;
+ const skipOpportunity = !isOpportunity || !recordId;
+
+ const { record: personRecord, loading: personLoading } = useFindOneRecord({
+ objectNameSingular: CoreObjectNameSingular.Person,
+ objectRecordId: recordId ?? '',
+ recordGqlFields: { id: true, emails: { primaryEmail: true } },
+ skip: skipPerson,
+ });
+
+ const { records: companyPeople, loading: companyPeopleLoading } =
+ useFindManyRecords({
+ objectNameSingular: CoreObjectNameSingular.Person,
+ filter: { companyId: { eq: recordId ?? '' } },
+ recordGqlFields: { id: true, emails: { primaryEmail: true } },
+ limit: 1,
+ skip: skipCompanyPeople,
+ });
+
+ const { record: opportunityRecord, loading: opportunityLoading } =
+ useFindOneRecord({
+ objectNameSingular: CoreObjectNameSingular.Opportunity,
+ objectRecordId: recordId ?? '',
+ recordGqlFields: {
+ id: true,
+ pointOfContact: { id: true, emails: { primaryEmail: true } },
+ },
+ skip: skipOpportunity,
+ });
+
+ const defaultTo = isPerson
+ ? (personRecord?.emails?.primaryEmail ?? '')
+ : isCompany
+ ? (companyPeople[0]?.emails?.primaryEmail ?? '')
+ : isOpportunity
+ ? (opportunityRecord?.pointOfContact?.emails?.primaryEmail ?? '')
+ : '';
+
+ const loading =
+ (isPerson && personLoading) ||
+ (isCompany && companyPeopleLoading) ||
+ (isOpportunity && opportunityLoading);
+
+ return { defaultTo, loading };
+};
diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useSendEmail.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useSendEmail.ts
index 8654b11e35..7157cc66ff 100644
--- a/packages/twenty-front/src/modules/activities/emails/hooks/useSendEmail.ts
+++ b/packages/twenty-front/src/modules/activities/emails/hooks/useSendEmail.ts
@@ -1,5 +1,6 @@
import { useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
+import { type EmailAttachment } from 'twenty-shared/types';
import { SEND_EMAIL } from '@/activities/emails/graphql/mutations/sendEmail';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
@@ -21,6 +22,7 @@ type SendEmailParams = {
subject: string;
body: string;
inReplyTo?: string;
+ files?: EmailAttachment[];
};
export const useSendEmail = () => {
@@ -46,6 +48,7 @@ export const useSendEmail = () => {
subject: params.subject,
body: params.body,
inReplyTo: params.inReplyTo,
+ files: params.files,
},
},
});
diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useUploadEmailAttachment.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useUploadEmailAttachment.ts
new file mode 100644
index 0000000000..ce9b1efda7
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/hooks/useUploadEmailAttachment.ts
@@ -0,0 +1,75 @@
+import { useMutation } from '@apollo/client/react';
+import { useLingui } from '@lingui/react/macro';
+import { type EmailAttachment } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
+import { MAX_ATTACHMENT_SIZE } from '@/advanced-text-editor/utils/maxAttachmentSize';
+import { UPLOAD_EMAIL_ATTACHMENT_FILE } from '@/file/graphql/mutations/uploadEmailAttachmentFile';
+import { formatFileSize } from '@/file/utils/formatFileSize';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import {
+ type UploadEmailAttachmentFileMutation,
+ type UploadEmailAttachmentFileMutationVariables,
+} from '~/generated-metadata/graphql';
+import { logError } from '~/utils/logError';
+
+export const useUploadEmailAttachment = () => {
+ const [uploadEmailAttachmentFileMutation] = useMutation<
+ UploadEmailAttachmentFileMutation,
+ UploadEmailAttachmentFileMutationVariables
+ >(UPLOAD_EMAIL_ATTACHMENT_FILE);
+ const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
+ const { t } = useLingui();
+
+ const uploadEmailAttachment = async (
+ file: File,
+ ): Promise => {
+ try {
+ if (file.size > MAX_ATTACHMENT_SIZE) {
+ const fileName = file.name;
+ const maxUploadSize = formatFileSize(MAX_ATTACHMENT_SIZE);
+
+ enqueueErrorSnackBar({
+ message: t`File "${fileName}" exceeds ${maxUploadSize}`,
+ });
+
+ return null;
+ }
+
+ const result = await uploadEmailAttachmentFileMutation({
+ variables: { file },
+ });
+
+ const uploadedFile = result?.data?.uploadEmailAttachmentFile;
+
+ if (!isDefined(uploadedFile)) {
+ throw new Error('File upload failed');
+ }
+
+ const attachment: EmailAttachment = {
+ id: uploadedFile.id,
+ name: file.name,
+ };
+
+ const fileName = file.name;
+
+ enqueueSuccessSnackBar({
+ message: t`File "${fileName}" uploaded successfully`,
+ });
+
+ return attachment;
+ } catch (error) {
+ logError(`Failed to upload email attachment "${file.name}": ${error}`);
+
+ const fileNameForError = file.name;
+
+ enqueueErrorSnackBar({
+ message: t`Failed to upload "${fileNameForError}"`,
+ });
+
+ return null;
+ }
+ };
+
+ return { uploadEmailAttachment };
+};
diff --git a/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getPrimaryEmailFromRecord.test.ts b/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getPrimaryEmailFromRecord.test.ts
new file mode 100644
index 0000000000..ee9cac606a
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getPrimaryEmailFromRecord.test.ts
@@ -0,0 +1,47 @@
+import { getPrimaryEmailFromRecord } from '@/activities/emails/utils/getPrimaryEmailFromRecord';
+
+const makeRecord = (emails: unknown): Record => ({ emails });
+
+describe('getPrimaryEmailFromRecord', () => {
+ it('should return the primary email when present', () => {
+ const record = makeRecord({ primaryEmail: 'alice@example.com' });
+
+ expect(getPrimaryEmailFromRecord(record)).toBe('alice@example.com');
+ });
+
+ it('should return null when emails is null', () => {
+ const record = makeRecord(null);
+
+ expect(getPrimaryEmailFromRecord(record)).toBeNull();
+ });
+
+ it('should return null when emails is undefined', () => {
+ const record = makeRecord(undefined);
+
+ expect(getPrimaryEmailFromRecord(record)).toBeNull();
+ });
+
+ it('should return null when emails is not an object', () => {
+ const record = makeRecord('not-an-object');
+
+ expect(getPrimaryEmailFromRecord(record)).toBeNull();
+ });
+
+ it('should return null when primaryEmail is missing', () => {
+ const record = makeRecord({ additionalEmails: ['b@example.com'] });
+
+ expect(getPrimaryEmailFromRecord(record)).toBeNull();
+ });
+
+ it('should return null when primaryEmail is an empty string', () => {
+ const record = makeRecord({ primaryEmail: '' });
+
+ expect(getPrimaryEmailFromRecord(record)).toBeNull();
+ });
+
+ it('should return null when primaryEmail is not a string', () => {
+ const record = makeRecord({ primaryEmail: 42 });
+
+ expect(getPrimaryEmailFromRecord(record)).toBeNull();
+ });
+});
diff --git a/packages/twenty-front/src/modules/activities/emails/utils/getPrimaryEmailFromRecord.ts b/packages/twenty-front/src/modules/activities/emails/utils/getPrimaryEmailFromRecord.ts
new file mode 100644
index 0000000000..07b5d65373
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/emails/utils/getPrimaryEmailFromRecord.ts
@@ -0,0 +1,15 @@
+import { isNonEmptyString } from '@sniptt/guards';
+
+export const getPrimaryEmailFromRecord = (
+ record: Record,
+): string | null => {
+ const emails = record.emails;
+
+ if (emails === null || typeof emails !== 'object') {
+ return null;
+ }
+
+ const primaryEmail = (emails as { primaryEmail?: unknown }).primaryEmail;
+
+ return isNonEmptyString(primaryEmail) ? primaryEmail : null;
+};
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
index e1695847f6..5242a121e8 100644
--- a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx
+++ b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx
@@ -1,10 +1,11 @@
-import { WorkflowAttachmentChip } from '@/advanced-text-editor/components/WorkflowAttachmentChip';
import { useUploadWorkflowFile } from '@/advanced-text-editor/hooks/useUploadWorkflowFile';
+import { AttachmentChip } from '@/file/components/AttachmentChip';
+import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
-import { type ChangeEvent, useContext, useRef } from 'react';
+import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type WorkflowAttachment } from 'twenty-shared/workflow';
import { IconUpload } from 'twenty-ui/display';
@@ -21,10 +22,6 @@ const StyledContainer = styled.div`
flex-direction: column;
`;
-const StyledFileInput = styled.input`
- display: none;
-`;
-
const StyledUploadArea = styled.div<{ hasFiles: boolean }>`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
@@ -52,7 +49,6 @@ const StyledChipsContainer = styled.div`
`;
const StyledUploadAreaLabel = styled.div`
- color: ${themeCssVariables.font.color.secondary};
color: ${themeCssVariables.font.color.tertiary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
@@ -67,26 +63,11 @@ export const WorkflowSendEmailAttachments = ({
onChange,
}: WorkflowSendEmailAttachmentsProps) => {
const { theme } = useContext(ThemeContext);
- const fileInputRef = useRef(null);
const { uploadWorkflowFile } = useUploadWorkflowFile();
+ const { openFileUpload } = useFileUpload();
const { t } = useLingui();
- 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 handleUploadFiles = async (filesToUpload: File[]) => {
const uploadedFiles = await Promise.all(
filesToUpload.map((file) => uploadWorkflowFile(file)),
);
@@ -98,14 +79,11 @@ export const WorkflowSendEmailAttachments = ({
}
};
- const handleFileChange = (event: ChangeEvent) => {
- const selectedFiles = event.target.files;
- if (isDefined(selectedFiles)) {
- onUploadFiles(Array.from(selectedFiles));
- }
- if (fileInputRef.current !== null) {
- fileInputRef.current.value = '';
- }
+ const handleAddFileClick = () => {
+ openFileUpload({
+ multiple: true,
+ onUpload: handleUploadFiles,
+ });
};
const handleRemoveFile = (fileId: string) => {
@@ -116,13 +94,6 @@ export const WorkflowSendEmailAttachments = ({
{label ? {label} : null}
-
-
0}
onClick={handleAddFileClick}
@@ -130,7 +101,7 @@ export const WorkflowSendEmailAttachments = ({
{files.length > 0 ? (
{files.map((file: WorkflowAttachment) => (
- handleRemoveFile(file.id)}
diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/global/components/ComposeEmailCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/global/components/ComposeEmailCommand.tsx
index 68c9c9f3a6..50f5044ce0 100644
--- a/packages/twenty-front/src/modules/command-menu-item/engine-command/global/components/ComposeEmailCommand.tsx
+++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/global/components/ComposeEmailCommand.tsx
@@ -1,15 +1,61 @@
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
+import { useResolveDefaultEmailRecipient } from '@/activities/emails/hooks/useResolveDefaultEmailRecipient';
+import { getPrimaryEmailFromRecord } from '@/activities/emails/utils/getPrimaryEmailFromRecord';
+import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
+import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
+import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
-import { SettingsPath } from 'twenty-shared/types';
+import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const ComposeEmailCommand = () => {
- const { connectedAccountId, loading } = useFirstConnectedAccount();
+ const { connectedAccountId, loading: accountLoading } =
+ useFirstConnectedAccount();
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
const navigateSettings = useNavigateSettings();
+ const {
+ objectMetadataItem,
+ selectedRecords,
+ graphqlFilter,
+ targetedRecordsRule,
+ } = useHeadlessCommandContextApi();
+
+ const objectNameSingular = objectMetadataItem?.nameSingular ?? null;
+ const isPerson = objectNameSingular === CoreObjectNameSingular.Person;
+
+ const isBulkPerson =
+ isPerson &&
+ (selectedRecords.length > 1 || targetedRecordsRule.mode === 'exclusion');
+
+ const { records: bulkPersonRecords, loading: bulkLoading } =
+ useFindManyRecords({
+ objectNameSingular: CoreObjectNameSingular.Person,
+ filter: graphqlFilter ?? undefined,
+ recordGqlFields: { id: true, emails: { primaryEmail: true } },
+ limit: MAX_EMAIL_RECIPIENTS,
+ skip: !isBulkPerson,
+ });
+
+ const singleSelectedRecordId = !isBulkPerson
+ ? (selectedRecords[0]?.id ?? null)
+ : null;
+
+ const { defaultTo: singleDefaultTo, loading: recipientLoading } =
+ useResolveDefaultEmailRecipient({
+ objectNameSingular,
+ recordId: singleSelectedRecordId,
+ });
+
+ const defaultTo = isBulkPerson
+ ? bulkPersonRecords
+ .map(getPrimaryEmailFromRecord)
+ .filter(isDefined)
+ .join(', ')
+ : singleDefaultTo;
+
const handleExecute = () => {
if (!isDefined(connectedAccountId)) {
navigateSettings(SettingsPath.NewAccount);
@@ -19,13 +65,14 @@ export const ComposeEmailCommand = () => {
openComposeEmailInSidePanel({
connectedAccountId,
+ defaultTo,
});
};
+ const ready =
+ !accountLoading && (isBulkPerson ? !bulkLoading : !recipientLoading);
+
return (
-
+
);
};
diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx b/packages/twenty-front/src/modules/file/components/AttachmentChip.tsx
similarity index 83%
rename from packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx
rename to packages/twenty-front/src/modules/file/components/AttachmentChip.tsx
index 358d94505b..605275794c 100644
--- a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx
+++ b/packages/twenty-front/src/modules/file/components/AttachmentChip.tsx
@@ -1,15 +1,15 @@
-import { getFileType } from '@/activities/files/utils/getFileType';
-import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
-import { IconMapping } from '@/file/utils/fileIconMappings';
import { styled } from '@linaria/react';
import { useContext } from 'react';
-import { type WorkflowAttachment } from 'twenty-shared/workflow';
import { AvatarOrIcon } from 'twenty-ui/components';
import { IconX } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
-type WorkflowAttachmentChipProps = {
- file: WorkflowAttachment;
+import { getFileType } from '@/activities/files/utils/getFileType';
+import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
+import { IconMapping } from '@/file/utils/fileIconMappings';
+
+type AttachmentChipProps = {
+ file: { id: string; name: string };
onRemove: () => void;
readonly?: boolean;
};
@@ -59,16 +59,25 @@ const StyledDelete = styled.button`
}
`;
-export const WorkflowAttachmentChip = ({
+export const AttachmentChip = ({
file,
onRemove,
readonly = false,
-}: WorkflowAttachmentChipProps) => {
+}: AttachmentChipProps) => {
const { theme } = useContext(ThemeContext);
const iconColors = useFileCategoryColors();
+ const handleChipClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ };
+
+ const handleRemoveClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ onRemove();
+ };
+
return (
-
+
{file.name}
{!readonly && (
-
+
)}
diff --git a/packages/twenty-front/src/modules/file/graphql/mutations/uploadEmailAttachmentFile.ts b/packages/twenty-front/src/modules/file/graphql/mutations/uploadEmailAttachmentFile.ts
new file mode 100644
index 0000000000..f7e27a2617
--- /dev/null
+++ b/packages/twenty-front/src/modules/file/graphql/mutations/uploadEmailAttachmentFile.ts
@@ -0,0 +1,13 @@
+import { gql } from '@apollo/client';
+
+export const UPLOAD_EMAIL_ATTACHMENT_FILE = gql`
+ mutation UploadEmailAttachmentFile($file: Upload!) {
+ uploadEmailAttachmentFile(file: $file) {
+ id
+ path
+ size
+ createdAt
+ url
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx
index be40b2ecaf..a73d1481d2 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx
@@ -44,7 +44,9 @@ export const Disabled: Story = {
const canvas = within(canvasElement);
const input = await waitFor(() => {
- const inputElement = canvasElement.querySelector('input');
+ const inputElement = canvasElement.querySelector(
+ 'input:not([type="file"])',
+ );
expect(inputElement).not.toBeNull();
return inputElement!;
});
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/EmailsFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/EmailsFieldDisplay.tsx
index 72c18bfe77..207f06bcac 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/EmailsFieldDisplay.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/EmailsFieldDisplay.tsx
@@ -1,3 +1,4 @@
+import { useOpenEmailInAppOrFallback } from '@/activities/emails/hooks/useOpenEmailInAppOrFallback';
import { useEmailsFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useEmailsFieldDisplay';
import { EmailsDisplay } from '@/ui/field/display/components/EmailsDisplay';
import { useLingui } from '@lingui/react/macro';
@@ -10,7 +11,14 @@ export const EmailsFieldDisplay = () => {
const { copyToClipboard } = useCopyToClipboard();
const { t } = useLingui();
- const onClickAction = fieldDefinition.metadata.settings?.clickAction;
+ const onClickAction =
+ fieldDefinition.metadata.settings?.clickAction ??
+ FieldMetadataSettingsOnClickAction.OPEN_IN_APP;
+
+ const isOpenInApp =
+ onClickAction === FieldMetadataSettingsOnClickAction.OPEN_IN_APP;
+
+ const { openEmail } = useOpenEmailInAppOrFallback({ skip: !isOpenInApp });
const handleEmailClick = (
email: string,
@@ -19,6 +27,15 @@ export const EmailsFieldDisplay = () => {
if (onClickAction === FieldMetadataSettingsOnClickAction.COPY) {
event.preventDefault();
copyToClipboard(email, t`Email copied to clipboard`);
+
+ return;
+ }
+
+ if (isOpenInApp) {
+ event.preventDefault();
+ openEmail(email);
+
+ return;
}
};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton.ts
index aa9762a370..801d7b3f40 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton.ts
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton.ts
@@ -1,3 +1,4 @@
+import { useOpenEmailInAppOrFallback } from '@/activities/emails/hooks/useOpenEmailInAppOrFallback';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import {
type FieldEmailsValue,
@@ -12,13 +13,17 @@ import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
import { ensureAbsoluteUrl, isDefined } from 'twenty-shared/utils';
-import { IconArrowUpRight, IconCopy } from 'twenty-ui/display';
+import { IconArrowUpRight, IconCopy, IconMail } from 'twenty-ui/display';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
export const useGetSecondaryRecordTableCellButton = () => {
const { fieldDefinition, recordId } = useContext(FieldContext);
const { copyToClipboard } = useCopyToClipboard();
+ const isEmailField = isFieldEmails(fieldDefinition);
+
+ const { openEmail } = useOpenEmailInAppOrFallback({ skip: !isEmailField });
+
const fieldValue = useRecordFieldValue<
FieldPhonesValue | FieldEmailsValue | FieldLinksValue | undefined
>(recordId, fieldDefinition.metadata.fieldName, fieldDefinition);
@@ -26,23 +31,31 @@ export const useGetSecondaryRecordTableCellButton = () => {
if (
(!isFieldPhones(fieldDefinition) &&
!isFieldLinks(fieldDefinition) &&
- !isFieldEmails(fieldDefinition)) ||
+ !isEmailField) ||
!isDefined(fieldValue)
) {
return [];
}
+ const defaultClickAction = isEmailField
+ ? FieldMetadataSettingsOnClickAction.OPEN_IN_APP
+ : FieldMetadataSettingsOnClickAction.OPEN_LINK;
+
const mainActionOnClick =
- fieldDefinition.metadata.settings?.clickAction ??
- FieldMetadataSettingsOnClickAction.OPEN_LINK;
+ fieldDefinition.metadata.settings?.clickAction ?? defaultClickAction;
+
+ const openActionForFieldType = isEmailField
+ ? FieldMetadataSettingsOnClickAction.OPEN_IN_APP
+ : FieldMetadataSettingsOnClickAction.OPEN_LINK;
const secondaryActionOnClick =
- mainActionOnClick === FieldMetadataSettingsOnClickAction.OPEN_LINK
- ? FieldMetadataSettingsOnClickAction.COPY
- : FieldMetadataSettingsOnClickAction.OPEN_LINK;
+ mainActionOnClick === FieldMetadataSettingsOnClickAction.COPY
+ ? openActionForFieldType
+ : FieldMetadataSettingsOnClickAction.COPY;
let openLinkOnClick: () => void = () => {};
let copyOnClick: () => void = () => {};
+ let openInAppOnClick: () => void = () => {};
if (isFieldPhones(fieldDefinition)) {
const { primaryPhoneCallingCode = '', primaryPhoneNumber = '' } =
@@ -64,6 +77,9 @@ export const useGetSecondaryRecordTableCellButton = () => {
copyOnClick = () => {
copyToClipboard(email, t`Email copied to clipboard`);
};
+ openInAppOnClick = () => {
+ openEmail(email);
+ };
}
if (isFieldLinks(fieldDefinition)) {
@@ -76,16 +92,25 @@ export const useGetSecondaryRecordTableCellButton = () => {
};
}
+ const onClickByAction: Record<
+ FieldMetadataSettingsOnClickAction,
+ () => void
+ > = {
+ [FieldMetadataSettingsOnClickAction.OPEN_LINK]: openLinkOnClick,
+ [FieldMetadataSettingsOnClickAction.COPY]: copyOnClick,
+ [FieldMetadataSettingsOnClickAction.OPEN_IN_APP]: openInAppOnClick,
+ };
+
+ const iconByAction = {
+ [FieldMetadataSettingsOnClickAction.OPEN_LINK]: IconArrowUpRight,
+ [FieldMetadataSettingsOnClickAction.COPY]: IconCopy,
+ [FieldMetadataSettingsOnClickAction.OPEN_IN_APP]: IconMail,
+ };
+
return [
{
- onClick:
- secondaryActionOnClick === FieldMetadataSettingsOnClickAction.OPEN_LINK
- ? openLinkOnClick
- : copyOnClick,
- Icon:
- secondaryActionOnClick === FieldMetadataSettingsOnClickAction.OPEN_LINK
- ? IconArrowUpRight
- : IconCopy,
+ onClick: onClickByAction[secondaryActionOnClick],
+ Icon: iconByAction[secondaryActionOnClick],
},
];
};
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldOnClickActionForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldOnClickActionForm.tsx
index ec3fef13e2..c9a0654c72 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldOnClickActionForm.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldOnClickActionForm.tsx
@@ -9,7 +9,7 @@ import { useLingui } from '@lingui/react/macro';
import {
type FieldMetadataMultiItemSettings,
FieldMetadataSettingsOnClickAction,
- type FieldMetadataType,
+ FieldMetadataType,
} from 'twenty-shared/types';
import { IconClick } from 'twenty-ui/display';
@@ -35,7 +35,21 @@ export const SettingsDataModelFieldOnClickActionForm = ({
existingFieldMetadataId,
);
+ const isEmailField = fieldType === FieldMetadataType.EMAILS;
+
+ const defaultClickAction = isEmailField
+ ? FieldMetadataSettingsOnClickAction.OPEN_IN_APP
+ : FieldMetadataSettingsOnClickAction.OPEN_LINK;
+
const options = [
+ ...(isEmailField
+ ? [
+ {
+ label: t`Open in app`,
+ value: FieldMetadataSettingsOnClickAction.OPEN_IN_APP,
+ },
+ ]
+ : []),
{
label: t`Open as link`,
value: FieldMetadataSettingsOnClickAction.OPEN_LINK,
@@ -58,17 +72,13 @@ export const SettingsDataModelFieldOnClickActionForm = ({
control={control}
defaultValue={{
...existingSettings,
- clickAction:
- existingSettings.clickAction ??
- FieldMetadataSettingsOnClickAction.OPEN_LINK,
+ clickAction: existingSettings.clickAction ?? defaultClickAction,
}}
render={({ field: { value, onChange } }) => {
const currentSettings =
(value as FieldMetadataMultiItemSettings | undefined) ?? {};
- const clickAction =
- currentSettings.clickAction ??
- FieldMetadataSettingsOnClickAction.OPEN_LINK;
+ const clickAction = currentSettings.clickAction ?? defaultClickAction;
return (
{
+ const isDryRun = options.dryRun ?? false;
+
+ this.logger.log(
+ `${isDryRun ? '[DRY RUN] ' : ''}Checking Send Email record-selection commands for workspace ${workspaceId}`,
+ );
+
+ const { twentyStandardFlatApplication } =
+ await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
+ { workspaceId },
+ );
+
+ const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
+ await this.workspaceCacheService.getOrRecompute(workspaceId, [
+ 'flatCommandMenuItemMaps',
+ ]);
+
+ const missingUniversalIdentifiers =
+ SEND_EMAIL_RECORD_SELECTION_UNIVERSAL_IDENTIFIERS.filter(
+ (universalIdentifier) =>
+ !isDefined(
+ existingFlatCommandMenuItemMaps.byUniversalIdentifier[
+ universalIdentifier
+ ],
+ ),
+ );
+
+ if (missingUniversalIdentifiers.length === 0) {
+ this.logger.log(
+ `Send Email record-selection commands already exist for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ const { allFlatEntityMaps: standardAllFlatEntityMaps } =
+ computeTwentyStandardApplicationAllFlatEntityMaps({
+ shouldIncludeRecordPageLayouts: true,
+ now: new Date().toISOString(),
+ workspaceId,
+ twentyStandardApplicationId: twentyStandardFlatApplication.id,
+ });
+
+ const itemsToCreate = missingUniversalIdentifiers
+ .map(
+ (universalIdentifier) =>
+ standardAllFlatEntityMaps.flatCommandMenuItemMaps
+ .byUniversalIdentifier[universalIdentifier],
+ )
+ .filter(isDefined);
+
+ if (itemsToCreate.length === 0) {
+ this.logger.warn(
+ `Send Email record-selection commands not found in standard application for workspace ${workspaceId}`,
+ );
+
+ return;
+ }
+
+ if (isDryRun) {
+ this.logger.log(
+ `[DRY RUN] Would create ${itemsToCreate.length} Send Email record-selection commands for workspace ${workspaceId}`,
+ );
+
+ return;
+ }
+
+ const validateAndBuildResult =
+ await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
+ {
+ allFlatEntityOperationByMetadataName: {
+ commandMenuItem: {
+ flatEntityToCreate: itemsToCreate,
+ flatEntityToDelete: [],
+ flatEntityToUpdate: [],
+ },
+ },
+ workspaceId,
+ applicationUniversalIdentifier:
+ twentyStandardFlatApplication.universalIdentifier,
+ },
+ );
+
+ if (validateAndBuildResult.status === 'fail') {
+ this.logger.error(
+ `Failed to add Send Email record-selection commands:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
+ );
+
+ throw new Error(
+ `Failed to add Send Email record-selection commands for workspace ${workspaceId}`,
+ );
+ }
+
+ this.logger.log(
+ `Successfully added ${itemsToCreate.length} Send Email record-selection commands for workspace ${workspaceId}`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/file-email-attachment.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/file-email-attachment.module.ts
new file mode 100644
index 0000000000..76c7b9e936
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/file-email-attachment.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+
+import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
+import { FileEmailAttachmentResolver } from 'src/engine/core-modules/file/file-email-attachment/resolvers/file-email-attachment.resolver';
+import { FileEmailAttachmentService } from 'src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service';
+import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
+import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
+
+@Module({
+ imports: [FileUrlModule, ApplicationModule, PermissionsModule],
+ providers: [FileEmailAttachmentService, FileEmailAttachmentResolver],
+ exports: [FileEmailAttachmentService],
+})
+export class FileEmailAttachmentModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/resolvers/file-email-attachment.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/resolvers/file-email-attachment.resolver.ts
new file mode 100644
index 0000000000..2bfa7ba322
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/resolvers/file-email-attachment.resolver.ts
@@ -0,0 +1,46 @@
+import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
+import { Args, Mutation } from '@nestjs/graphql';
+
+import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
+import { PermissionFlagType } from 'twenty-shared/constants';
+
+import type { FileUpload } from 'graphql-upload/processRequest.mjs';
+
+import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
+import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
+import { FileEmailAttachmentService } from 'src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service';
+import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
+import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
+import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
+import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
+import { streamToBuffer } from 'src/utils/stream-to-buffer';
+
+@UseGuards(WorkspaceAuthGuard)
+@UsePipes(ResolverValidationPipe)
+@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
+@MetadataResolver()
+export class FileEmailAttachmentResolver {
+ constructor(
+ private readonly fileEmailAttachmentService: FileEmailAttachmentService,
+ ) {}
+
+ @Mutation(() => FileWithSignedUrlDTO)
+ @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
+ async uploadEmailAttachmentFile(
+ @AuthWorkspace()
+ { id: workspaceId }: WorkspaceEntity,
+ @Args({ name: 'file', type: () => GraphQLUpload })
+ { createReadStream, filename }: FileUpload,
+ ): Promise {
+ const stream = createReadStream();
+ const buffer = await streamToBuffer(stream);
+
+ return await this.fileEmailAttachmentService.uploadFile({
+ file: buffer,
+ filename,
+ workspaceId,
+ });
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts
new file mode 100644
index 0000000000..d6aaa4ba78
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts
@@ -0,0 +1,96 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+import { isNonEmptyString } from '@sniptt/guards';
+import { FileFolder } from 'twenty-shared/types';
+import { v4 } from 'uuid';
+
+import { ApplicationService } from 'src/engine/core-modules/application/application.service';
+import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
+import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
+import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
+import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
+import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
+
+@Injectable()
+export class FileEmailAttachmentService {
+ private readonly logger = new Logger(FileEmailAttachmentService.name);
+
+ constructor(
+ private readonly fileStorageService: FileStorageService,
+ private readonly applicationService: ApplicationService,
+ private readonly fileUrlService: FileUrlService,
+ ) {}
+
+ async uploadFile({
+ file,
+ filename,
+ workspaceId,
+ }: {
+ file: Buffer;
+ filename: string;
+ workspaceId: string;
+ }): Promise {
+ const { mimeType, ext } = await extractFileInfo({
+ file,
+ filename,
+ });
+
+ const sanitizedFile = sanitizeFile({ file, ext, mimeType });
+
+ const fileId = v4();
+ const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
+
+ const { workspaceCustomFlatApplication } =
+ await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
+ {
+ workspaceId,
+ },
+ );
+
+ const savedFile = await this.fileStorageService.writeFile({
+ sourceFile: sanitizedFile,
+ resourcePath: name,
+ mimeType,
+ fileFolder: FileFolder.EmailAttachment,
+ applicationUniversalIdentifier:
+ workspaceCustomFlatApplication.universalIdentifier,
+ workspaceId,
+ fileId,
+ settings: {
+ isTemporaryFile: true,
+ toDelete: false,
+ },
+ });
+
+ return {
+ ...savedFile,
+ url: this.fileUrlService.signFileByIdUrl({
+ fileId,
+ workspaceId,
+ fileFolder: FileFolder.EmailAttachment,
+ }),
+ };
+ }
+
+ async deleteFiles({
+ fileIds,
+ workspaceId,
+ }: {
+ fileIds: string[];
+ workspaceId: string;
+ }): Promise {
+ for (const fileId of fileIds) {
+ try {
+ await this.fileStorageService.deleteByFileId({
+ fileId,
+ workspaceId,
+ fileFolder: FileFolder.EmailAttachment,
+ });
+ } catch (error) {
+ this.logger.warn(
+ `Failed to delete email attachment file ${fileId}: ${error}`,
+ );
+ }
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/file/file.module.ts b/packages/twenty-server/src/engine/core-modules/file/file.module.ts
index 8469a036c4..e3f33b9213 100644
--- a/packages/twenty-server/src/engine/core-modules/file/file.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/file/file.module.ts
@@ -17,6 +17,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
import { FileController } from './controllers/file.controller';
import { FileEntity } from './entities/file.entity';
import { FileCorePictureModule } from './file-core-picture/file-core-picture.module';
+import { FileEmailAttachmentModule } from './file-email-attachment/file-email-attachment.module';
import { FileUrlModule } from './file-url/file-url.module';
import { FileWorkflowModule } from './file-workflow/file-workflow.module';
import { FilesFieldModule } from './files-field/files-field.module';
@@ -34,6 +35,7 @@ import { FileService } from './services/file.service';
FileCorePictureModule,
FileWorkflowModule,
FileAIChatModule,
+ FileEmailAttachmentModule,
SecureHttpClientModule,
],
providers: [
@@ -52,6 +54,7 @@ import { FileService } from './services/file.service';
FileCorePictureModule,
FileWorkflowModule,
FileAIChatModule,
+ FileEmailAttachmentModule,
],
controllers: [FileController],
})
diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts
index ef518dc97a..3657a25295 100644
--- a/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts
+++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts
@@ -12,6 +12,7 @@ export const SUPPORTED_FILE_FOLDERS = [
FileFolder.FilesField,
FileFolder.Workflow,
FileFolder.AgentChat,
+ FileFolder.EmailAttachment,
FileFolder.AppTarball,
] as const;
diff --git a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts
index 1369fc2b04..5fef2e6031 100644
--- a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts
+++ b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts
@@ -54,6 +54,9 @@ export const fileFolderConfigs: Record = {
[FileFolder.Workflow]: {
ignoreExpirationToken: false,
},
+ [FileFolder.EmailAttachment]: {
+ ignoreExpirationToken: false,
+ },
[FileFolder.AppTarball]: {
ignoreExpirationToken: false,
},
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/constants/email-tool.constants.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/constants/email-tool.constants.ts
new file mode 100644
index 0000000000..85831d58c9
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/constants/email-tool.constants.ts
@@ -0,0 +1 @@
+export { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts
index 037334d87a..e2585aafad 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/draft-email-tool.ts
@@ -1,5 +1,7 @@
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';
@@ -32,6 +34,7 @@ export class DraftEmailTool implements Tool {
const result = await this.emailComposerService.composeEmail(
parameters,
context,
+ { attachmentsFileFolder: FileFolder.Workflow },
);
if (!result.success) {
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts
index ab6eddc7f6..7646b2df8d 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts
@@ -4,9 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { render, toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
-import { FileFolder } from 'twenty-shared/types';
+import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
+import { type EmailAttachment, FileFolder } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
-import { WorkflowAttachment } from 'twenty-shared/workflow';
import { In, type Repository } from 'typeorm';
import { z } from 'zod';
@@ -17,7 +17,7 @@ import {
EmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
-import { EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
+import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -104,7 +104,7 @@ export class EmailComposerService {
);
}
- private normalizeRecipients(parameters: EmailToolInput): {
+ private normalizeRecipients(parameters: ComposeEmailParams): {
to: string[];
cc: string[];
bcc: string[];
@@ -157,9 +157,26 @@ export class EmailComposerService {
return invalidEmails;
}
+ private assertRecipientCountWithinLimit(recipients: {
+ to: string[];
+ cc: string[];
+ bcc: string[];
+ }): void {
+ const total =
+ recipients.to.length + recipients.cc.length + recipients.bcc.length;
+
+ if (total > MAX_EMAIL_RECIPIENTS) {
+ throw new EmailToolException(
+ `Too many recipients: ${total}. Maximum allowed is ${MAX_EMAIL_RECIPIENTS}.`,
+ EmailToolExceptionCode.TOO_MANY_RECIPIENTS,
+ );
+ }
+ }
+
private async getAttachments(
- files: Array,
+ files: Array,
workspaceId: string,
+ fileFolder: FileFolder,
): Promise {
if (files.length === 0) {
return [];
@@ -168,7 +185,7 @@ export class EmailComposerService {
const fileIds = files.map((file) => file.id);
const fileEntities = await this.fileRepository.find({
- where: { id: In(fileIds) },
+ where: { id: In(fileIds), workspaceId },
});
const fileEntityMap = new Map(
@@ -193,10 +210,12 @@ export class EmailComposerService {
const attachments: MessageAttachment[] = [];
for (const fileMetadata of files) {
+ const fileEntity = fileEntityMap.get(fileMetadata.id);
+
const { stream } = await this.fileService.getFileStreamById({
fileId: fileMetadata.id,
workspaceId,
- fileFolder: FileFolder.Workflow,
+ fileFolder,
});
const buffer = await streamToBuffer(stream);
@@ -204,7 +223,7 @@ export class EmailComposerService {
attachments.push({
filename: fileMetadata.name,
content: buffer,
- contentType: fileMetadata.type,
+ contentType: fileEntity?.mimeType ?? 'application/octet-stream',
});
}
@@ -256,8 +275,9 @@ export class EmailComposerService {
}
async composeEmail(
- parameters: EmailToolInput,
+ parameters: ComposeEmailParams,
context: ToolExecutionContext,
+ options: { attachmentsFileFolder: FileFolder },
): Promise {
const { workspaceId } = context;
const { subject, body, files, inReplyTo } = parameters;
@@ -267,6 +287,7 @@ export class EmailComposerService {
try {
recipients = this.normalizeRecipients(parameters);
+ this.assertRecipientCountWithinLimit(recipients);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Invalid recipients';
@@ -334,7 +355,11 @@ export class EmailComposerService {
refreshToken,
};
- const attachments = await this.getAttachments(files || [], workspaceId);
+ const attachments = await this.getAttachments(
+ files || [],
+ workspaceId,
+ options.attachmentsFileFolder,
+ );
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception.ts
index 324a92e0f8..e6bd48db1f 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception.ts
@@ -11,6 +11,7 @@ export enum EmailToolExceptionCode {
WORKSPACE_ID_NOT_FOUND = 'WORKSPACE_ID_NOT_FOUND',
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
INVALID_FILE_ID = 'INVALID_FILE_ID',
+ TOO_MANY_RECIPIENTS = 'TOO_MANY_RECIPIENTS',
}
const getEmailToolExceptionUserFriendlyMessage = (
@@ -29,6 +30,8 @@ const getEmailToolExceptionUserFriendlyMessage = (
return msg`File not found.`;
case EmailToolExceptionCode.INVALID_FILE_ID:
return msg`Invalid file ID.`;
+ case EmailToolExceptionCode.TOO_MANY_RECIPIENTS:
+ return msg`Too many recipients.`;
default:
assertUnreachable(code);
}
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts
index 2f66af645d..36eb06c38e 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/send-email-tool.ts
@@ -1,5 +1,7 @@
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';
@@ -31,6 +33,7 @@ export class SendEmailTool implements Tool {
const result = await this.emailComposerService.composeEmail(
parameters,
context,
+ { attachmentsFileFolder: FileFolder.Workflow },
);
if (!result.success) {
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type.ts
new file mode 100644
index 0000000000..efa21b1d4c
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type.ts
@@ -0,0 +1,14 @@
+import { type EmailAttachment } from 'twenty-shared/types';
+
+export type ComposeEmailParams = {
+ recipients: {
+ to: string;
+ cc?: string;
+ bcc?: string;
+ };
+ subject: string;
+ body: string;
+ connectedAccountId?: string;
+ files?: Array;
+ inReplyTo?: string;
+};
diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts
index 1d6b23211b..b0eaa4bf70 100644
--- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts
@@ -943,4 +943,49 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
hotKeys: null,
payload: { path: '/settings/updates' },
},
+ composeEmailToPerson: {
+ universalIdentifier: 'f01d4b8b-2b4e-4ae0-9c6f-0b9a9a3e5b21',
+ label: 'Send Email',
+ icon: 'IconMail',
+ isPinned: true,
+ position: 63,
+ shortLabel: 'Send Email',
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ conditionalAvailabilityExpression: 'numberOfSelectedRecords >= 1',
+ availabilityObjectMetadataUniversalIdentifier:
+ STANDARD_OBJECTS.person.universalIdentifier,
+ frontComponentUniversalIdentifier: null,
+ engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
+ hotKeys: null,
+ },
+ composeEmailToCompany: {
+ universalIdentifier: 'a76d3ab8-4c3a-4e5d-8a4a-1f5d6e7f8a90',
+ label: 'Send Email',
+ icon: 'IconMail',
+ isPinned: true,
+ position: 64,
+ shortLabel: 'Send Email',
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1',
+ availabilityObjectMetadataUniversalIdentifier:
+ STANDARD_OBJECTS.company.universalIdentifier,
+ frontComponentUniversalIdentifier: null,
+ engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
+ hotKeys: null,
+ },
+ composeEmailToOpportunity: {
+ universalIdentifier: 'b3e7c9f2-5d6e-4f7a-8b9c-0d1e2f3a4b5c',
+ label: 'Send Email',
+ icon: 'IconMail',
+ isPinned: true,
+ position: 65,
+ shortLabel: 'Send Email',
+ availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
+ conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1',
+ availabilityObjectMetadataUniversalIdentifier:
+ STANDARD_OBJECTS.opportunity.universalIdentifier,
+ frontComponentUniversalIdentifier: null,
+ engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
+ hotKeys: null,
+ },
} as const;
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/send-email.input.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/send-email.input.ts
index ffe09ddfe6..5f11b875b1 100644
--- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/send-email.input.ts
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/send-email.input.ts
@@ -1,5 +1,14 @@
import { Field, InputType } from '@nestjs/graphql';
+@InputType()
+export class SendEmailAttachmentInput {
+ @Field(() => String)
+ id: string;
+
+ @Field(() => String)
+ name: string;
+}
+
@InputType()
export class SendEmailInput {
@Field(() => String)
@@ -22,4 +31,7 @@ export class SendEmailInput {
@Field(() => String, { nullable: true })
inReplyTo?: string;
+
+ @Field(() => [SendEmailAttachmentInput], { nullable: true })
+ files?: SendEmailAttachmentInput[];
}
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/resolvers/send-email.resolver.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/resolvers/send-email.resolver.ts
index dc92f36ae4..9d0474bd6c 100644
--- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/resolvers/send-email.resolver.ts
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/resolvers/send-email.resolver.ts
@@ -7,8 +7,11 @@ import {
} from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
+import { FileFolder } from 'twenty-shared/types';
+
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
+import { FileEmailAttachmentService } from 'src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
@@ -31,6 +34,7 @@ export class SendEmailResolver {
constructor(
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly emailComposerService: EmailComposerService,
+ private readonly fileEmailAttachmentService: FileEmailAttachmentService,
private readonly sendEmailService: SendEmailService,
) {}
@@ -57,10 +61,11 @@ export class SendEmailResolver {
subject: input.subject,
body: input.body,
connectedAccountId: input.connectedAccountId,
- files: [],
+ files: input.files ?? [],
inReplyTo: input.inReplyTo,
},
{ workspaceId: workspace.id },
+ { attachmentsFileFolder: FileFolder.EmailAttachment },
);
if (!result.success) {
@@ -80,6 +85,15 @@ export class SendEmailResolver {
workspace.id,
);
+ const attachmentFileIds = (input.files ?? []).map((file) => file.id);
+
+ if (attachmentFileIds.length > 0) {
+ await this.fileEmailAttachmentService.deleteFiles({
+ fileIds: attachmentFileIds,
+ workspaceId: workspace.id,
+ });
+ }
+
return { success: true };
} catch (error) {
if (error instanceof ForbiddenException) {
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/send-email.module.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/send-email.module.ts
index da2b09dd45..2f62555149 100644
--- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/send-email.module.ts
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/send-email.module.ts
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
+import { FileEmailAttachmentModule } from 'src/engine/core-modules/file/file-email-attachment/file-email-attachment.module';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { SendEmailResolver } from 'src/modules/messaging/message-outbound-manager/resolvers/send-email.resolver';
@@ -7,6 +8,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
@Module({
imports: [
+ FileEmailAttachmentModule,
ToolModule,
MessagingSendManagerModule,
ConnectedAccountMetadataModule,
diff --git a/packages/twenty-shared/src/constants/MaxEmailRecipients.ts b/packages/twenty-shared/src/constants/MaxEmailRecipients.ts
new file mode 100644
index 0000000000..4234b90962
--- /dev/null
+++ b/packages/twenty-shared/src/constants/MaxEmailRecipients.ts
@@ -0,0 +1 @@
+export const MAX_EMAIL_RECIPIENTS = 100;
diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts
index ea586dd07d..9ea30e8e4f 100644
--- a/packages/twenty-shared/src/constants/index.ts
+++ b/packages/twenty-shared/src/constants/index.ts
@@ -31,6 +31,7 @@ export { FILES_FIELD_MAX_NUMBER_OF_VALUES } from './FilesFieldMaxNumberOfValues'
export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone';
export { IANA_TIME_ZONES } from './IanaTimeZones';
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
+export { MAX_EMAIL_RECIPIENTS } from './MaxEmailRecipients';
export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues';
export { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from './MultiItemFieldMinMaxValues';
export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords';
diff --git a/packages/twenty-shared/src/types/EmailAttachment.ts b/packages/twenty-shared/src/types/EmailAttachment.ts
new file mode 100644
index 0000000000..433aa960d0
--- /dev/null
+++ b/packages/twenty-shared/src/types/EmailAttachment.ts
@@ -0,0 +1,4 @@
+export type EmailAttachment = {
+ id: string;
+ name: string;
+};
diff --git a/packages/twenty-shared/src/types/FieldMetadataMultiItemSettings.ts b/packages/twenty-shared/src/types/FieldMetadataMultiItemSettings.ts
index 4ccf774141..6d4ef0ea8f 100644
--- a/packages/twenty-shared/src/types/FieldMetadataMultiItemSettings.ts
+++ b/packages/twenty-shared/src/types/FieldMetadataMultiItemSettings.ts
@@ -1,6 +1,7 @@
export enum FieldMetadataSettingsOnClickAction {
COPY = 'COPY',
OPEN_LINK = 'OPEN_LINK',
+ OPEN_IN_APP = 'OPEN_IN_APP',
}
export type FieldMetadataMultiItemSettings = {
diff --git a/packages/twenty-shared/src/types/FileFolder.ts b/packages/twenty-shared/src/types/FileFolder.ts
index 361e259028..a10c99d584 100644
--- a/packages/twenty-shared/src/types/FileFolder.ts
+++ b/packages/twenty-shared/src/types/FileFolder.ts
@@ -13,6 +13,7 @@ export enum FileFolder {
FilesField = 'files-field',
Dependencies = 'dependencies',
Workflow = 'workflow',
+ EmailAttachment = 'email-attachment',
AppTarball = 'app-tarball',
GeneratedSdkClient = 'generated-sdk-client',
}
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index 35b62e8627..89984b27e7 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -61,6 +61,7 @@ export type { ConfirmationModalCaller } from './ConfirmationModalCaller';
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
export { CoreObjectNameSingular } from './CoreObjectNameSingular';
export { CrudOperationType } from './CrudOperationType';
+export type { EmailAttachment } from './EmailAttachment';
export type {
SnackBarVariant,
EnqueueSnackbarParams,