Compare commits

...
Author SHA1 Message Date
Claude 1ae759a089 fix: clean up email attachment files after send and move migration to 1-22
Delete temporary email attachment files from storage after a successful
send to prevent file storage leak. Also move the
AddSendEmailRecordSelectionCommandMenuItems upgrade command from 1-21
to 1-22 since 1-21 has already been released.

https://claude.ai/code/session_014DjujrbcZu4WrdhULUR8CH
2026-04-10 07:46:06 +00:00
Claude 9bf6d9afee fix: regenerate GraphQL metadata and fix prettier formatting
- Update FindOnePageLayoutDocument to include conditionalAvailabilityExpression
  field in PageLayoutWidgetFragment (stale after merge from main)
- Fix prettier formatting in FormDateFieldInput.stories.tsx

https://claude.ai/code/session_014DjujrbcZu4WrdhULUR8CH
2026-04-10 07:20:55 +00:00
Félix MalfaitandGitHub 9ee07b51d2 Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-10 09:10:28 +02:00
Claude 1d5e021b56 fix: use specific selector in FormDateFieldInput story to avoid matching FileUploadProvider's hidden input
The Disabled test was finding the hidden <input type="file"> from
FileUploadProvider (added globally in Storybook decorators) instead of
the date field input, causing a false toBeDisabled() failure.

https://claude.ai/code/session_014DjujrbcZu4WrdhULUR8CH
2026-04-09 21:26:08 +00:00
Félix MalfaitandGitHub 52bc2d17fc Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 22:42:02 +02:00
claude[bot] 3f650839dc Merge remote-tracking branch 'origin/main' into feat/email-attachments-and-open-in-app
# Conflicts:
#	packages/twenty-front/src/generated-metadata/graphql.ts
2026-04-09 19:52:17 +00:00
Félix MalfaitandGitHub 9accdb7f18 Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 21:29:26 +02:00
Félix MalfaitandGitHub b69ac4c1b1 Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 16:11:19 +02:00
Félix MalfaitandClaude Opus 4.6 2be478b677 fix: add workspaceId filter to file lookup in email attachments
The getAttachments method queried the file repository without a
workspaceId filter, allowing users to probe for file existence and
leak metadata (mimeType) across workspace boundaries. Add workspaceId
to the where clause to match every other file repository query in the
codebase.

Also regenerate metadata client SDK types after main merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:10:16 +02:00
Félix Malfait 0bb9e4ac53 Merge remote-tracking branch 'origin/main' into feat/email-attachments-and-open-in-app
# Conflicts:
#	packages/twenty-client-sdk/src/metadata/generated/types.ts
2026-04-09 16:07:48 +02:00
Félix MalfaitandClaude Opus 4.6 8abd65e90c fix: address PR review comments (CSS selectors, types, test cleanup)
- Replace deep child CSS selectors (> div > h2) with styled H1Title component
- Delete useOpenEmailInAppOrFallback test (too simple to warrant tests)
- Use generated GraphQL types in useUploadEmailAttachment instead of manual definitions
- Narrow getPrimaryEmailFromRecord param from ObjectRecord to Record<string, unknown>
- Remove unsafe casts in getPrimaryEmailFromRecord test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:43:56 +02:00
Félix MalfaitandGitHub ffae202399 Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 15:32:58 +02:00
Félix MalfaitandGitHub 24573f70cf Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 14:46:27 +02:00
Félix MalfaitandClaude Opus 4.6 44266f4c3c fix: add FileUploadProvider to global Storybook decorators
Components using useFileUpload() need the provider in the tree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 14:35:51 +02:00
Félix MalfaitandGitHub 7f9cb52994 Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 14:31:50 +02:00
Félix MalfaitandClaude Opus 4.6 521a83e5bf chore: regenerate GraphQL types and SDK metadata client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 13:47:24 +02:00
Félix MalfaitandClaude Opus 4.6 d0101ba849 fix: address PR review — proper typing, shared constants, idiomatic patterns
- Create EmailAttachment type in twenty-shared (replaces WorkflowAttachment for email)
- Move MAX_EMAIL_RECIPIENTS to twenty-shared/constants (single source of truth)
- Remove size/type/createdAt from SendEmailAttachmentInput — server reads
  mimeType from FileEntity instead of trusting frontend-provided values
- Replace useRef with useFileUpload() hook in both attachment field components
- Replace closest('[data-chip]') with stopPropagation on AttachmentChip
- Fix both EmailAttachmentsField and WorkflowSendEmailAttachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 13:35:53 +02:00
Félix MalfaitandGitHub 6c506c21c1 Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 12:58:25 +02:00
Félix MalfaitandGitHub 35c108a968 Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 12:47:20 +02:00
Félix MalfaitandClaude Opus 4.6 16316ff172 fix: prettier formatting in EmailsFieldDisplay
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 11:36:37 +02:00
Félix MalfaitandClaude Opus 4.6 fa59e96762 chore: remove noisy comments, keep only essential ones
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 11:21:57 +02:00
Félix MalfaitandClaude Opus 4.6 c15ae95c5f fix: fetch bulk person emails from server instead of record store
When "Send Email" is triggered from a People index with multiple rows
selected, the recipient list was built from the record store which only
contains fields loaded for the current view — emails.primaryEmail is
often missing if the email column is not visible.

In "select all" (exclusion) mode the problem was worse: selectedRecords
is empty so the composer always opened with a blank To: field.

Replace the record-store read with a dedicated useFindManyRecords query
using the context store's graphqlFilter, which correctly encodes both
inclusion and exclusion selections. The query fetches only id +
emails.primaryEmail and is capped at MAX_EMAIL_RECIPIENTS (100).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 11:14:57 +02:00
Félix MalfaitandClaude Opus 4.6 fdc26a1597 fix: address code review — skip unnecessary queries, add tests
Performance:
- Add `skip` option to useFirstConnectedAccount and
  useOpenEmailInAppOrFallback so the connected-account Apollo query is
  only fired when actually needed.
- useGetSecondaryRecordTableCellButton now skips the query for non-email
  field types (phones, links).
- EmailsFieldDisplay skips the query when the click action is COPY or
  OPEN_LINK.

Clarity:
- Add explicit OPEN_LINK fallthrough comment in EmailsFieldDisplay.

Tests (18 new):
- getPrimaryEmailFromRecord: 7 cases (happy path + all null/edge cases)
- useResolveDefaultEmailRecipient: 7 cases (person/company/opportunity
  resolution, unknown types, null recordId, loading, skip propagation)
- useOpenEmailInAppOrFallback: 4 cases (side-panel open, mailto
  fallback, loading fallback, skip propagation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 10:52:42 +02:00
Félix Malfait 4af84ed826 Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 10:14:56 +02:00
Félix Malfait ea19adb20f Merge branch 'main' into feat/email-attachments-and-open-in-app 2026-04-09 08:50:42 +02:00
Félix MalfaitandClaude Opus 4.6 fa8c2183d2 feat: re-architecture email attachments and add "Open in app" click action
Move email attachment uploads out of the workflow folder into a dedicated
EmailAttachment module with its own FileFolder, resolver, and service.
Add a 100-recipient cap (To+Cc+Bcc) enforced on both frontend and backend.
Extract shared AttachmentChip component for reuse across email and workflow.

Introduce a new "Open in app" click action for email fields that opens the
side-panel composer directly. This becomes the default for email fields.
When no connected account is available, it gracefully falls back to
mailto: instead of redirecting to settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 08:47:17 +02:00
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,