feat: email attachments and open-in-app click action (#19485)

## Changes

### Email Attachments
- Added `EmailAttachmentsField` component for uploading and managing
email attachments
- New `useUploadEmailAttachment` hook for handling file uploads with
size validation
- New `UPLOAD_EMAIL_ATTACHMENT_FILE` mutation for backend file
persistence
- Integrated attachments into email composer with file validation
- Added `EmailRecipientLimits` constant to enforce max recipients (100)
on frontend

### Open in App Click Action
- New `useOpenEmailInAppOrFallback` hook to open emails in the in-app
composer
- Email fields now default to "Open in app" action instead of "Open as
link"
- New `SettingsDataModelFieldOnClickActionForm` support for
`OPEN_IN_APP` action
- Email secondary table cell button now offers in-app composer as
alternative action
- `AttachmentChip` component moved from advanced-text-editor to file
module for reuse

### Refactoring & New Utilities
- Extracted `useComposeEmailForTargetRecord` hook for consistent email
composer opening
- New `useResolveDefaultEmailRecipient` hook to resolve recipient based
on record type
- New `getPrimaryEmailFromRecord` utility for safe email field access
- New `EmptyInboxPlaceholder` component with CTA button
- Simplified `ComposeEmailButton` using new hooks
- Enhanced `ComposeEmailCommand` to support bulk Person selections
- Updated `useSendEmail` to accept and forward attachments
- Recipient count validation with warning in composer footer

### Backend
- New `FileEmailAttachmentModule` with resolver and service
- New `file-email-attachment.command` for record selection menu items
- Updated `SendEmailInput` GraphQL type to include `files` field
- Email tool constants and exceptions updated

### Type Updates
- Added `SendEmailAttachmentInput` GraphQL type
- Added `FileFolder.EMAIL_ATTACHMENT` to file folder interface

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-04-10 10:01:50 +02:00
committed by GitHub
parent cbefd42c4c
commit 67e7f05a68
54 changed files with 1393 additions and 247 deletions
@@ -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
}
@@ -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
}
@@ -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!"
]
}
+9 -5
View File
@@ -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 (
<I18nProvider i18n={i18n}>
<ThemeProvider colorScheme="light">
<ClickOutsideListenerContext.Provider
value={{ excludedClickOutsideId: undefined }}
>
<Story />
</ClickOutsideListenerContext.Provider>
<FileUploadProvider>
<ClickOutsideListenerContext.Provider
value={{ excludedClickOutsideId: undefined }}
>
<Story />
</ClickOutsideListenerContext.Provider>
</FileUploadProvider>
</ThemeProvider>
</I18nProvider>
);
File diff suppressed because one or more lines are too long
@@ -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 (
<LightIconButton
Icon={IconPlus}
accent="tertiary"
size="small"
onClick={handleClick}
onClick={openComposer}
disabled={loading}
/>
);
};
@@ -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 (
<StyledContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledUploadArea
hasFiles={files.length > 0}
onClick={handleAddFileClick}
>
{files.length > 0 ? (
<StyledChipsContainer>
{files.map((file) => (
<AttachmentChip
key={file.id}
file={file}
onRemove={() => handleRemoveFile(file.id)}
/>
))}
</StyledChipsContainer>
) : (
<StyledUploadAreaLabel>
<IconUpload size={theme.icon.size.sm} />
<span>{t`Upload file`}</span>
</StyledUploadAreaLabel>
)}
</StyledUploadArea>
</StyledContainer>
);
};
@@ -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 = ({
<StyledComposerContainer>
<EmailComposerFields composerState={composerState} />
<StyledFooter>
{composerState.exceedsRecipientLimit && (
<StyledFooterWarning>
{t`Too many recipients (${composerState.recipientCount}/${composerState.maxRecipients}).`}
</StyledFooterWarning>
)}
<StyledFooterActions>
{onClose && (
<Button
@@ -1,6 +1,7 @@
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { EmailAttachmentsField } from '@/activities/emails/components/EmailAttachmentsField';
import { type EmailComposerState } from '@/activities/emails/types/EmailComposerState';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { FormMultiTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput';
@@ -112,6 +113,11 @@ export const EmailComposerFields = ({
maxWidth={600}
contentType="json"
/>
<EmailAttachmentsField
label={t`Attachments`}
files={composerState.files}
onChange={composerState.setFiles}
/>
</StyledFieldsContainer>
);
};
@@ -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 (
<StyledContainer>
<StyledComposeButtonRow>
<ComposeEmailButton />
</StyledComposeButtonRow>
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyInbox" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
<Trans>Empty Inbox</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
<Trans>
No email exchange has occurred with this record yet.
</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
<EmptyInboxPlaceholder />
</StyledContainer>
);
}
@@ -129,17 +99,15 @@ export const EmailsCard = () => {
<StyledContainer>
<Section>
<StyledHeaderRow>
<StyledH1TitleWrapper>
<H1Title
title={
<>
<Trans>Inbox</Trans>{' '}
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
/>
</StyledH1TitleWrapper>
<StyledH1Title
title={
<>
<Trans>Inbox</Trans>{' '}
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
/>
<ComposeEmailButton />
</StyledHeaderRow>
{!firstQueryLoading && (
@@ -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 (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyInbox" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
<Trans>Empty Inbox</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
<Trans>No email exchange has occurred with this record yet.</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={IconMail}
title={t`Send Email`}
variant="secondary"
onClick={openComposer}
disabled={loading}
/>
</AnimatedPlaceholderEmptyContainer>
);
};
@@ -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);
});
});
@@ -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,
};
};
@@ -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<EmailAttachment[]>([]);
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,
};
};
@@ -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;
@@ -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 };
};
@@ -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 };
};
@@ -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,
},
},
});
@@ -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<EmailAttachment | null> => {
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 };
};
@@ -0,0 +1,47 @@
import { getPrimaryEmailFromRecord } from '@/activities/emails/utils/getPrimaryEmailFromRecord';
const makeRecord = (emails: unknown): Record<string, unknown> => ({ 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();
});
});
@@ -0,0 +1,15 @@
import { isNonEmptyString } from '@sniptt/guards';
export const getPrimaryEmailFromRecord = (
record: Record<string, unknown>,
): 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;
};
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 = ({
<StyledContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFileInput
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
/>
<StyledUploadArea
hasFiles={files.length > 0}
onClick={handleAddFileClick}
@@ -130,7 +101,7 @@ export const WorkflowSendEmailAttachments = ({
{files.length > 0 ? (
<StyledChipsContainer>
{files.map((file: WorkflowAttachment) => (
<WorkflowAttachmentChip
<AttachmentChip
key={file.id}
file={file}
onRemove={() => handleRemoveFile(file.id)}
@@ -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 (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={!loading}
/>
<HeadlessEngineCommandWrapperEffect execute={handleExecute} ready={ready} />
);
};
@@ -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 (
<StyledChip data-chip deletable={!readonly}>
<StyledChip deletable={!readonly} onClick={handleChipClick}>
<AvatarOrIcon
Icon={IconMapping[getFileType(file.name)]}
IconBackgroundColor={iconColors[getFileType(file.name)]}
@@ -76,7 +85,7 @@ export const WorkflowAttachmentChip = ({
<StyledLabel title={file.name}>{file.name}</StyledLabel>
{!readonly && (
<StyledDelete onClick={onRemove}>
<StyledDelete onClick={handleRemoveClick}>
<IconX size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledDelete>
)}
@@ -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
}
}
`;
@@ -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<HTMLInputElement>(
'input:not([type="file"])',
);
expect(inputElement).not.toBeNull();
return inputElement!;
});
@@ -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;
}
};
@@ -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],
},
];
};
@@ -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 (
<SettingsOptionCardContentSelect
@@ -2,7 +2,7 @@ import { z } from 'zod';
export const settingsDataModelFieldOnClickActionSchema = z.object({
settings: z.object({
clickAction: z.enum(['COPY', 'OPEN_LINK']).optional(),
clickAction: z.enum(['COPY', 'OPEN_LINK', 'OPEN_IN_APP']).optional(),
}),
});
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command';
import { BackfillStandardSkillsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000002000-backfill-standard-skills.command';
import { AddSendEmailRecordSelectionCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1775500016000-add-send-email-record-selection-command-menu-items.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -17,6 +18,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceMigrationModule,
],
providers: [
AddSendEmailRecordSelectionCommandMenuItemsCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
BackfillStandardSkillsCommand,
],
@@ -0,0 +1,136 @@
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const SEND_EMAIL_RECORD_SELECTION_UNIVERSAL_IDENTIFIERS = [
STANDARD_COMMAND_MENU_ITEMS.composeEmailToPerson.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.composeEmailToCompany.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.composeEmailToOpportunity.universalIdentifier,
];
@RegisteredWorkspaceCommand('1.22.0', 1775500016000)
@Command({
name: 'upgrade:1-22:add-send-email-record-selection-command-menu-items',
description:
'Add the per-object Send Email command menu items (Person, Company, Opportunity) to existing workspaces',
})
export class AddSendEmailRecordSelectionCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
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}`,
);
}
}
@@ -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 {}
@@ -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<FileWithSignedUrlDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
return await this.fileEmailAttachmentService.uploadFile({
file: buffer,
filename,
workspaceId,
});
}
}
@@ -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<FileWithSignedUrlDTO> {
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<void> {
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}`,
);
}
}
}
}
@@ -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],
})
@@ -12,6 +12,7 @@ export const SUPPORTED_FILE_FOLDERS = [
FileFolder.FilesField,
FileFolder.Workflow,
FileFolder.AgentChat,
FileFolder.EmailAttachment,
FileFolder.AppTarball,
] as const;
@@ -54,6 +54,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.Workflow]: {
ignoreExpirationToken: false,
},
[FileFolder.EmailAttachment]: {
ignoreExpirationToken: false,
},
[FileFolder.AppTarball]: {
ignoreExpirationToken: false,
},
@@ -0,0 +1 @@
export { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
@@ -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) {
@@ -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<WorkflowAttachment>,
files: Array<EmailAttachment>,
workspaceId: string,
fileFolder: FileFolder,
): Promise<MessageAttachment[]> {
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<EmailComposerResult> {
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);
@@ -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);
}
@@ -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) {
@@ -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<EmailAttachment>;
inReplyTo?: string;
};
@@ -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;
@@ -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[];
}
@@ -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) {
@@ -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,
@@ -0,0 +1 @@
export const MAX_EMAIL_RECIPIENTS = 100;
@@ -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';
@@ -0,0 +1,4 @@
export type EmailAttachment = {
id: string;
name: string;
};
@@ -1,6 +1,7 @@
export enum FieldMetadataSettingsOnClickAction {
COPY = 'COPY',
OPEN_LINK = 'OPEN_LINK',
OPEN_IN_APP = 'OPEN_IN_APP',
}
export type FieldMetadataMultiItemSettings = {
@@ -13,6 +13,7 @@ export enum FileFolder {
FilesField = 'files-field',
Dependencies = 'dependencies',
Workflow = 'workflow',
EmailAttachment = 'email-attachment',
AppTarball = 'app-tarball',
GeneratedSdkClient = 'generated-sdk-client',
}
@@ -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,