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:
+53
-6
@@ -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} />
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user