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
@@ -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} />
);
};