feat(files): use direct-to-storage upload for email and AI-chat attachments (#22610)

## Context

Follow-up to the direct-to-storage upload work (#22449 / #22531 / #22533
/ #22576). That migrated files-field, attachments and workflow uploads
off the buffered path. This PR does the same for the **last two
user-facing upload surfaces**: email attachments and AI-chat files.

## What this does

- Adds `EmailAttachment` and `AgentChat` to the server's
`DIRECT_UPLOAD_FILE_FOLDERS` allowlist. Both folders already resolve
through the workspace-custom-application path in
`resolveUploadLocation`, so no other server change is needed.
- Routes the two frontend hooks through the existing
`useDirectFileUpload` handshake (`createFileUpload` → `PUT` →
`completeFileUpload`):
- `useUploadEmailAttachment` → `FileFolder.EmailAttachment` (keeps its
existing `MAX_ATTACHMENT_SIZE` client check — email has a real send-size
limit).
  - `useAiChatFileUpload` → `FileFolder.AgentChat`.

Each hook keeps its public signature and return shape, so call sites are
unchanged. No schema change and no codegen needed — the
`CreateFileUpload`/`CompleteFileUpload` documents and the `FileFolder`
enum values already exist in `generated-metadata` from #22576.

## Why these are safe to migrate

Both server services (`file-ai-chat`, `file-email-attachment`) just
`writeFile` (store) and return a signed URL — no synchronous processing
of the bytes at upload time — so the store-and-reference direct-upload
flow fits exactly, same as files-field/workflow.

## Out of scope

`CorePicture` (avatars, member/workspace pictures, logos) stays on the
buffered path on purpose: small images that go through server-side image
handling and are served inline, where the 10 MB body limit is already
appropriate.

## Tests

Extends the `FileUploadService` unit spec with an `it.each` asserting
`createFileUpload` supports the `EmailAttachment` and `AgentChat`
folders.

## Verification

`typecheck` and `lint:diff-with-main` green on both `twenty-front` and
`twenty-server`. (The server jest suite couldn't run in my local sandbox
due to an unrelated config-import quirk present on a clean `main`
checkout too — CI runs it normally.)

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

---
_Generated by [Claude
Code](https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22610?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-07-07 10:26:04 +02:00
committed by GitHub
parent 9086b031e8
commit ee28ae363f
4 changed files with 38 additions and 37 deletions
@@ -1,23 +1,15 @@
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 { useDirectFileUpload } from '@/file/hooks/useDirectFileUpload';
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 { FileFolder } from '~/generated-metadata/graphql';
import { logError } from '~/utils/logError';
export const useUploadEmailAttachment = () => {
const [uploadEmailAttachmentFileMutation] = useMutation<
UploadEmailAttachmentFileMutation,
UploadEmailAttachmentFileMutationVariables
>(UPLOAD_EMAIL_ATTACHMENT_FILE);
const { uploadFile: directUploadFile } = useDirectFileUpload();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
@@ -36,16 +28,10 @@ export const useUploadEmailAttachment = () => {
return null;
}
const result = await uploadEmailAttachmentFileMutation({
variables: { file },
const uploadedFile = await directUploadFile(file, {
fileFolder: FileFolder.EmailAttachment,
});
const uploadedFile = result?.data?.uploadEmailAttachmentFile;
if (!isDefined(uploadedFile)) {
throw new Error('File upload failed');
}
const attachment: EmailAttachment = {
id: uploadedFile.id,
name: file.name,
@@ -1,19 +1,16 @@
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
import { useDirectFileUpload } from '@/file/hooks/useDirectFileUpload';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useApolloClient, useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { type AgentChatFileUIPart } from '@/ai/types/agent-chat-file-ui-part.type';
import { UploadAiChatFileDocument } from '~/generated-metadata/graphql';
import { FileFolder } from '~/generated-metadata/graphql';
export const useAiChatFileUpload = () => {
const apolloClient = useApolloClient();
const [uploadAiChatFile] = useMutation(UploadAiChatFileDocument, {
client: apolloClient,
});
const { uploadFile: directUploadFile } = useDirectFileUpload();
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] = useAtomState(
@@ -25,26 +22,18 @@ export const useAiChatFileUpload = () => {
const sendFile = async (file: File): Promise<AgentChatFileUIPart | null> => {
try {
const result = await uploadAiChatFile({
variables: {
file,
},
const uploadedFile = await directUploadFile(file, {
fileFolder: FileFolder.AgentChat,
});
const response = result?.data?.uploadAiChatFile;
if (!isDefined(response)) {
throw new Error(t`Couldn't upload the file.`);
}
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
return {
filename: file.name,
mediaType: file.type,
url: response.url,
fileId: response.id,
url: uploadedFile.url,
fileId: uploadedFile.id,
type: 'file',
};
} catch {
@@ -189,6 +189,30 @@ describe('FileUploadService', () => {
);
expect(result.contentType).toBe('application/octet-stream');
});
it.each([FileFolder.EmailAttachment, FileFolder.AgentChat])(
'should support direct upload for the %s folder',
async (fileFolder) => {
fileStorageService.getPresignedUploadUrl.mockResolvedValueOnce(
'https://bucket/presigned-put',
);
const result = await service.createFileUpload({
workspaceId: 'workspace-id',
filename: 'document.pdf',
size: 1024,
fileFolder,
});
expect(fileStorageService.createPendingFile).toHaveBeenCalledWith(
expect.objectContaining({
fileFolder,
resourcePath: 'mocked-file-id.pdf',
}),
);
expect(result.uploadUrl).toBe('https://bucket/presigned-put');
},
);
});
describe('completeFileUpload', () => {
@@ -41,6 +41,8 @@ import { readReadablePrefix } from 'src/utils/read-readable-prefix';
export const DIRECT_UPLOAD_FILE_FOLDERS = [
FileFolder.FilesField,
FileFolder.Workflow,
FileFolder.EmailAttachment,
FileFolder.AgentChat,
] as const;
@Injectable()