Compare commits

...

7 Commits

Author SHA1 Message Date
neo773 31da4d80ae wip 2026-02-26 07:28:36 +05:30
neo773 67033b8c96 wip 2026-02-26 07:06:30 +05:30
neo773 4f3b4868ad wip 2026-02-26 06:48:35 +05:30
neo773 74d46f835c wip 2026-02-26 06:29:36 +05:30
neo773 68a6591336 wip 2026-02-26 05:46:00 +05:30
neo773 b6dafbcb3b wip 2026-02-26 04:47:38 +05:30
neo773 f72b7ef552 wip 2026-02-26 04:45:42 +05:30
30 changed files with 1100 additions and 3 deletions
@@ -0,0 +1,132 @@
import styled from '@emotion/styled';
import { saveAs } from 'file-saver';
import { ActivityList } from '@/activities/components/ActivityList';
import { ActivityRow } from '@/activities/components/ActivityRow';
import { type EmailThreadMessageAttachment } from '@/activities/emails/types/EmailThreadMessage';
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { FileIcon } from '@/file/components/FileIcon';
import { getFileCategoryFromExtension } from '@/object-record/record-field/ui/utils/getFileCategoryFromExtension';
import { useLingui } from '@lingui/react/macro';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension';
const StyledContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(0, 6)};
width: calc(100% - ${({ theme }) => theme.spacing(12)});
`;
const StyledTitleBar = styled.h3`
display: flex;
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(4)};
place-items: center;
width: 100%;
`;
const StyledTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledCount = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledLeftContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
overflow: auto;
flex: 1;
`;
const StyledLinkContainer = styled.div`
overflow: auto;
width: 100%;
`;
const StyledLink = styled.a`
align-items: center;
appearance: none;
background: none;
border: none;
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
display: flex;
font-family: inherit;
font-size: inherit;
padding: 0;
text-align: left;
text-decoration: none;
width: 100%;
:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
type EmailMessageAttachmentsProps = {
attachments: EmailThreadMessageAttachment[];
};
export const EmailMessageAttachments = ({
attachments,
}: EmailMessageAttachmentsProps) => {
const { t } = useLingui();
if (attachments.length === 0) {
return null;
}
const downloadAttachment = (attachmentId: string, fileName: string) => {
const url = `${REACT_APP_SERVER_BASE_URL}/message-attachments/${attachmentId}/download`;
const token = getTokenPair()?.accessOrWorkspaceAgnosticToken?.token ?? '';
fetch(url, {
headers: { authorization: `Bearer ${token}` },
})
.then((resp) => (resp.ok ? resp.blob() : Promise.reject(resp.statusText)))
.then((blob) => {
saveAs(blob, fileName);
});
};
return (
<StyledContainer>
<StyledTitleBar>
<StyledTitle>
{t`Attachments`} <StyledCount>({attachments.length})</StyledCount>
</StyledTitle>
</StyledTitleBar>
<ActivityList>
{attachments.map((attachment) => {
const { extension } = getFileNameAndExtension(attachment.name);
const fileCategory = getFileCategoryFromExtension(
extension.replace('.', ''),
);
return (
<ActivityRow
key={attachment.id}
onClick={() => downloadAttachment(attachment.id, attachment.name)}
>
<StyledLeftContent>
<FileIcon fileCategory={fileCategory} />
<StyledLinkContainer>
<OverflowingTextWithTooltip text={attachment.name} />
</StyledLinkContainer>
</StyledLeftContent>
</ActivityRow>
);
})}
</ActivityList>
</StyledContainer>
);
};
@@ -1,11 +1,13 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { EmailMessageAttachments } from '@/activities/emails/components/EmailMessageAttachments';
import { EmailThreadMessageBody } from '@/activities/emails/components/EmailThreadMessageBody';
import { EmailThreadMessageBodyPreview } from '@/activities/emails/components/EmailThreadMessageBodyPreview';
import { EmailThreadMessageReceivers } from '@/activities/emails/components/EmailThreadMessageReceivers';
import { EmailThreadMessageSender } from '@/activities/emails/components/EmailThreadMessageSender';
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
import { type EmailThreadMessageAttachment } from '@/activities/emails/types/EmailThreadMessage';
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { MessageParticipantRole } from 'twenty-shared/types';
@@ -36,6 +38,7 @@ type EmailThreadMessageProps = {
sentAt: string;
sender: EmailThreadMessageParticipant;
participants: EmailThreadMessageParticipant[];
attachments?: EmailThreadMessageAttachment[];
isExpanded?: boolean;
};
@@ -44,6 +47,7 @@ export const EmailThreadMessage = ({
sentAt,
sender,
participants,
attachments,
isExpanded = false,
}: EmailThreadMessageProps) => {
const [isOpen, setIsOpen] = useState(isExpanded);
@@ -79,6 +83,9 @@ export const EmailThreadMessage = ({
<EmailThreadMessageBodyPreview body={body} />
)}
</StyledThreadMessageBody>
{isOpen && attachments && attachments.length > 0 && (
<EmailMessageAttachments attachments={attachments} />
)}
</StyledThreadMessage>
);
};
@@ -42,5 +42,11 @@ export const fetchAllThreadMessagesOperationSignatureFactory: RecordGqlOperation
person: true,
workspaceMember: true,
},
messageAttachments: {
id: true,
name: true,
mimeType: true,
size: true,
},
},
});
@@ -1,6 +1,13 @@
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { type MessageThread } from '@/activities/emails/types/MessageThread';
export type EmailThreadMessageAttachment = {
id: string;
name: string;
mimeType: string | null;
size: number | null;
};
export type EmailThreadMessage = {
id: string;
text: string;
@@ -8,6 +15,7 @@ export type EmailThreadMessage = {
subject: string;
messageThreadId: string;
messageParticipants: EmailThreadMessageParticipant[];
messageAttachments: EmailThreadMessageAttachment[];
messageThread: MessageThread;
__typename: 'EmailThreadMessage';
};
@@ -30,6 +30,7 @@ export const CommandMenuMessageThreadIntermediaryMessages = ({
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
attachments={message.messageAttachments}
/>
))
) : (
@@ -145,6 +145,7 @@ export const CommandMenuMessageThreadPage = () => {
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
attachments={message.messageAttachments}
/>
))}
<CommandMenuMessageThreadIntermediaryMessages
@@ -156,6 +157,7 @@ export const CommandMenuMessageThreadPage = () => {
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
attachments={lastMessage.messageAttachments}
isExpanded
/>
<CustomResolverFetchMoreLoader
@@ -14,6 +14,7 @@ export enum CoreObjectNameSingular {
Favorite = 'favorite',
FavoriteFolder = 'favoriteFolder',
Message = 'message',
MessageAttachment = 'messageAttachment',
MessageChannel = 'messageChannel',
MessageParticipant = 'messageParticipant',
MessageFolder = 'messageFolder',
@@ -20,6 +20,7 @@ import { buildMessageChannelMessageAssociationMessageFolderStandardFlatFieldMeta
import { buildMessageChannelMessageAssociationStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-message-association-standard-flat-field-metadata.util';
import { buildMessageChannelStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-standard-flat-field-metadata.util';
import { buildMessageFolderStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-folder-standard-flat-field-metadata.util';
import { buildMessageAttachmentStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-attachment-standard-flat-field-metadata.util';
import { buildMessageParticipantStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-participant-standard-flat-field-metadata.util';
import { buildMessageStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-standard-flat-field-metadata.util';
import { buildMessageThreadStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-thread-standard-flat-field-metadata.util';
@@ -62,6 +63,7 @@ const STANDARD_FLAT_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME = {
messageChannelMessageAssociationMessageFolder:
buildMessageChannelMessageAssociationMessageFolderStandardFlatFieldMetadatas,
messageFolder: buildMessageFolderStandardFlatFieldMetadatas,
messageAttachment: buildMessageAttachmentStandardFlatFieldMetadatas,
messageParticipant: buildMessageParticipantStandardFlatFieldMetadatas,
messageThread: buildMessageThreadStandardFlatFieldMetadatas,
note: buildNoteStandardFlatFieldMetadatas,
@@ -0,0 +1,290 @@
import {
DateDisplayFormat,
FieldMetadataType,
RelationOnDeleteAction,
RelationType,
} from 'twenty-shared/types';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type';
import {
type CreateStandardFieldArgs,
createStandardFieldFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { SEARCH_FIELDS_FOR_MESSAGE_ATTACHMENT } from 'src/modules/messaging/common/standard-objects/message-attachment.workspace-entity';
export const buildMessageAttachmentStandardFlatFieldMetadatas = ({
now,
objectName,
workspaceId,
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
}: Omit<
CreateStandardFieldArgs<'messageAttachment', FieldMetadataType>,
'context'
>): Record<
AllStandardObjectFieldName<'messageAttachment'>,
FlatFieldMetadata
> => ({
id: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'id',
type: FieldMetadataType.UUID,
label: 'Id',
description: 'Id',
icon: 'Icon123',
isSystem: true,
isNullable: false,
isUIReadOnly: true,
defaultValue: 'uuid',
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
createdAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'createdAt',
type: FieldMetadataType.DATE_TIME,
label: 'Creation date',
description: 'Creation date',
icon: 'IconCalendar',
isSystem: true,
isNullable: false,
isUIReadOnly: true,
defaultValue: 'now',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
updatedAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
label: 'Last update',
description: 'Last time the record was changed',
icon: 'IconCalendarClock',
isSystem: true,
isNullable: false,
isUIReadOnly: true,
defaultValue: 'now',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
deletedAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
label: 'Deleted at',
description: 'Date when the record was deleted',
icon: 'IconCalendarMinus',
isSystem: true,
isNullable: true,
isUIReadOnly: true,
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
createdBy: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'createdBy',
type: FieldMetadataType.ACTOR,
label: 'Created by',
description: 'The creator of the record',
icon: 'IconCreativeCommonsSa',
isSystem: true,
isUIReadOnly: true,
isNullable: false,
defaultValue: {
source: "'MANUAL'",
name: "'System'",
workspaceMemberId: null,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
updatedBy: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'updatedBy',
type: FieldMetadataType.ACTOR,
label: 'Updated by',
description: 'The workspace member who last updated the record',
icon: 'IconUserCircle',
isSystem: true,
isUIReadOnly: true,
isNullable: false,
defaultValue: {
source: "'MANUAL'",
name: "'System'",
workspaceMemberId: null,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
position: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'position',
type: FieldMetadataType.POSITION,
label: 'Position',
description: 'Message Attachment record position',
icon: 'IconHierarchy2',
isSystem: true,
isNullable: false,
defaultValue: 0,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
searchVector: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
label: 'Search vector',
description: 'Field used for full-text search',
icon: 'IconUser',
isSystem: true,
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields(
SEARCH_FIELDS_FOR_MESSAGE_ATTACHMENT,
),
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
name: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'name',
type: FieldMetadataType.TEXT,
label: 'Name',
description: 'Attachment file name',
icon: 'IconFile',
isNullable: false,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
mimeType: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'mimeType',
type: FieldMetadataType.TEXT,
label: 'MIME Type',
description: 'Attachment MIME type',
icon: 'IconFileDescription',
isNullable: true,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
size: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'size',
type: FieldMetadataType.NUMBER,
label: 'Size',
description: 'Attachment size in bytes',
icon: 'IconDatabase',
isNullable: true,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
externalIdentifier: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'externalIdentifier',
type: FieldMetadataType.TEXT,
label: 'External Identifier',
description: 'Provider-specific attachment identifier',
icon: 'IconHash',
isNullable: true,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
message: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
context: {
type: FieldMetadataType.RELATION,
morphId: null,
fieldName: 'message',
label: 'Message',
description: 'Message',
icon: 'IconMessage',
isNullable: false,
isUIReadOnly: true,
targetObjectName: 'message',
targetFieldName: 'messageAttachments',
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.CASCADE,
joinColumnName: 'messageId',
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
});
@@ -341,6 +341,29 @@ export const buildMessageStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
messageAttachments: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
context: {
type: FieldMetadataType.RELATION,
morphId: null,
fieldName: 'messageAttachments',
label: 'Message Attachments',
description: 'Message Attachments',
icon: 'IconPaperclip',
isNullable: true,
isUIReadOnly: true,
targetObjectName: 'messageAttachment',
targetFieldName: 'message',
settings: {
relationType: RelationType.ONE_TO_MANY,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
messageChannelMessageAssociations: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
@@ -449,6 +449,37 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
twentyStandardApplicationId,
now,
}),
messageAttachment: ({
now,
workspaceId,
standardObjectMetadataRelatedEntityIds,
twentyStandardApplicationId,
dependencyFlatEntityMaps,
}: Omit<
CreateStandardObjectArgs<'messageAttachment'>,
'context' | 'objectName'
>) =>
createStandardObjectFlatMetadata({
objectName: 'messageAttachment',
dependencyFlatEntityMaps,
context: {
universalIdentifier:
STANDARD_OBJECTS.messageAttachment.universalIdentifier,
nameSingular: 'messageAttachment',
namePlural: 'messageAttachments',
labelSingular: 'Message Attachment',
labelPlural: 'Message Attachments',
description: 'Message Attachments',
icon: 'IconPaperclip',
isSystem: true,
isAuditLogged: false,
labelIdentifierFieldMetadataName: 'name',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
twentyStandardApplicationId,
now,
}),
messageParticipant: ({
now,
workspaceId,
@@ -22,6 +22,7 @@ const createMockMessage = (
messageThread: null,
messageChannelMessageAssociations: [],
messageParticipants: [],
messageAttachments: [],
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
@@ -0,0 +1,20 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
const NAME_FIELD_NAME = 'name';
export const SEARCH_FIELDS_FOR_MESSAGE_ATTACHMENT: FieldTypeAndNameMetadata[] =
[{ name: NAME_FIELD_NAME, type: FieldMetadataType.TEXT }];
export class MessageAttachmentWorkspaceEntity extends BaseWorkspaceEntity {
name: string;
mimeType: string | null;
size: number | null;
externalIdentifier: string | null;
message: EntityRelation<MessageWorkspaceEntity>;
messageId: string;
}
@@ -3,6 +3,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageAttachmentWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-attachment.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
@@ -24,4 +25,5 @@ export class MessageWorkspaceEntity extends BaseWorkspaceEntity {
messageChannelMessageAssociations: EntityRelation<
MessageChannelMessageAssociationWorkspaceEntity[]
>;
messageAttachments: EntityRelation<MessageAttachmentWorkspaceEntity[]>;
}
@@ -0,0 +1 @@
export const MESSAGE_ATTACHMENT_MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024;
@@ -0,0 +1,76 @@
import {
Controller,
Get,
HttpException,
HttpStatus,
NotFoundException,
Param,
PayloadTooLargeException,
Res,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { ThrottlerException } from 'src/engine/core-modules/throttler/throttler.exception';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { MessagingAttachmentDownloadService } from 'src/modules/messaging/message-attachment-manager/services/messaging-attachment-download.service';
@Controller('message-attachments')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
export class MessageAttachmentController {
constructor(
private readonly messagingAttachmentDownloadService: MessagingAttachmentDownloadService,
) {}
@Get(':id/download')
async download(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@Res() res: Response,
) {
try {
const { stream, filename, mimeType, size } =
await this.messagingAttachmentDownloadService.download(
id,
workspace.id,
);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(filename)}"`,
);
if (isDefined(size)) {
res.setHeader('Content-Length', String(size));
}
stream.on('error', () => {
if (!res.headersSent) {
res.status(500).send('Error streaming attachment');
}
});
stream.pipe(res);
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof PayloadTooLargeException
) {
throw error;
}
if (error instanceof ThrottlerException) {
throw new HttpException(error.message, HttpStatus.TOO_MANY_REQUESTS);
}
throw new NotFoundException('Attachment could not be downloaded');
}
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { google } from 'googleapis';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class GmailAttachmentDownloadService {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
) {}
async download(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'provider' | 'refreshToken'
>,
externalMessageId: string,
externalIdentifier: string,
): Promise<Buffer> {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
connectedAccount,
);
const gmail = google.gmail({ version: 'v1', auth: oAuth2Client });
const response = await gmail.users.messages.attachments.get({
userId: 'me',
messageId: externalMessageId,
id: externalIdentifier,
});
if (!response.data.data) {
throw new Error('Attachment data not found');
}
return Buffer.from(response.data.data, 'base64url');
}
}
@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { type FetchMessageObject } from 'imapflow';
import PostalMime, { type Attachment } from 'postal-mime';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { parseMessageId } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-message-id.util';
@Injectable()
export class ImapAttachmentDownloadService {
constructor(private readonly imapClientProvider: ImapClientProvider) {}
async download(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'id' | 'provider' | 'handle' | 'handleAliases' | 'connectionParameters'
>,
externalMessageId: string,
externalIdentifier: string,
): Promise<Buffer> {
const parsed = parseMessageId(externalMessageId);
if (!parsed) {
throw new Error(
`Invalid IMAP message external ID format: ${externalMessageId}`,
);
}
const client = await this.imapClientProvider.getClient(connectedAccount);
try {
const lock = await client.getMailboxLock(parsed.folder);
try {
const fetchResult = await client.fetchOne(
String(parsed.uid),
{ source: true },
{ uid: true },
);
const message = fetchResult as FetchMessageObject;
if (!message?.source) {
throw new Error('Message source not found');
}
const parsedMail = await PostalMime.parse(message.source);
const attachment = this.findAttachment(
parsedMail.attachments,
externalIdentifier,
);
if (!attachment?.content) {
throw new Error('Attachment not found in message');
}
if (typeof attachment.content === 'string') {
return Buffer.from(attachment.content, 'base64');
}
if (attachment.content instanceof Uint8Array) {
return Buffer.from(attachment.content);
}
return Buffer.from(new Uint8Array(attachment.content));
} finally {
lock.release();
}
} finally {
await this.imapClientProvider.closeClient(client);
}
}
private findAttachment(
attachments: Attachment[],
externalIdentifier: string,
): Attachment | undefined {
if (externalIdentifier.startsWith('index:')) {
const index = parseInt(externalIdentifier.slice(6), 10);
return attachments[index];
}
return attachments.find(
(attachment) => attachment.contentId === externalIdentifier,
);
}
}
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class MicrosoftAttachmentDownloadService {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
) {}
async download(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'provider' | 'accessToken'
>,
externalMessageId: string,
externalIdentifier: string,
): Promise<Buffer> {
const client =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const response = await client
.api(
`/me/messages/${externalMessageId}/attachments/${externalIdentifier}`,
)
.get();
if (!response.contentBytes) {
throw new Error('Attachment content not found');
}
return Buffer.from(response.contentBytes, 'base64');
}
}
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
import { MessageAttachmentController } from 'src/modules/messaging/message-attachment-manager/controllers/message-attachment.controller';
import { GmailAttachmentDownloadService } from 'src/modules/messaging/message-attachment-manager/drivers/gmail-attachment-download.service';
import { ImapAttachmentDownloadService } from 'src/modules/messaging/message-attachment-manager/drivers/imap-attachment-download.service';
import { MicrosoftAttachmentDownloadService } from 'src/modules/messaging/message-attachment-manager/drivers/microsoft-attachment-download.service';
import { MessagingAttachmentDownloadService } from 'src/modules/messaging/message-attachment-manager/services/messaging-attachment-download.service';
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
@Module({
imports: [
TokenModule,
ThrottlerModule,
WorkspaceCacheStorageModule,
OAuth2ClientManagerModule,
MessagingIMAPDriverModule,
],
controllers: [MessageAttachmentController],
providers: [
MessagingAttachmentDownloadService,
GmailAttachmentDownloadService,
MicrosoftAttachmentDownloadService,
ImapAttachmentDownloadService,
],
exports: [MessagingAttachmentDownloadService],
})
export class MessagingAttachmentManagerModule {}
@@ -0,0 +1,174 @@
import {
Injectable,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import { Readable } from 'stream';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageAttachmentWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-attachment.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MESSAGE_ATTACHMENT_MAX_DOWNLOAD_SIZE_BYTES } from 'src/modules/messaging/message-attachment-manager/constants/message-attachment-download.constant';
import { GmailAttachmentDownloadService } from 'src/modules/messaging/message-attachment-manager/drivers/gmail-attachment-download.service';
import { ImapAttachmentDownloadService } from 'src/modules/messaging/message-attachment-manager/drivers/imap-attachment-download.service';
import { MicrosoftAttachmentDownloadService } from 'src/modules/messaging/message-attachment-manager/drivers/microsoft-attachment-download.service';
const ATTACHMENT_DOWNLOAD_MAX_REQUESTS = 50;
const ATTACHMENT_DOWNLOAD_TIME_WINDOW_MS = 60_000;
type DownloadResult = {
stream: Readable;
filename: string;
mimeType: string;
size: number | null;
};
@Injectable()
export class MessagingAttachmentDownloadService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly gmailAttachmentDownloadService: GmailAttachmentDownloadService,
private readonly microsoftAttachmentDownloadService: MicrosoftAttachmentDownloadService,
private readonly imapAttachmentDownloadService: ImapAttachmentDownloadService,
private readonly throttlerService: ThrottlerService,
) {}
async download(
messageAttachmentId: string,
workspaceId: string,
): Promise<DownloadResult> {
await this.throttlerService.tokenBucketThrottleOrThrow(
`attachment-download:throttler:${workspaceId}`,
1,
ATTACHMENT_DOWNLOAD_MAX_REQUESTS,
ATTACHMENT_DOWNLOAD_TIME_WINDOW_MS,
);
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageAttachmentRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageAttachmentWorkspaceEntity>(
workspaceId,
'messageAttachment',
);
const messageAttachment = await messageAttachmentRepository.findOne({
where: { id: messageAttachmentId },
});
if (!isDefined(messageAttachment)) {
throw new NotFoundException('Message attachment not found');
}
if (
isDefined(messageAttachment.size) &&
messageAttachment.size > MESSAGE_ATTACHMENT_MAX_DOWNLOAD_SIZE_BYTES
) {
throw new PayloadTooLargeException(
`Attachment size (${messageAttachment.size} bytes) exceeds maximum allowed size (${MESSAGE_ATTACHMENT_MAX_DOWNLOAD_SIZE_BYTES} bytes)`,
);
}
const messageChannelMessageAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
const association =
await messageChannelMessageAssociationRepository.findOne({
where: { messageId: messageAttachment.messageId },
});
if (!isDefined(association)) {
throw new NotFoundException(
'Message channel message association not found',
);
}
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const messageChannel = await messageChannelRepository.findOne({
where: { id: association.messageChannelId },
});
if (!isDefined(messageChannel)) {
throw new NotFoundException('Message channel not found');
}
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount = await connectedAccountRepository.findOne({
where: { id: messageChannel.connectedAccountId },
});
if (!isDefined(connectedAccount)) {
throw new NotFoundException('Connected account not found');
}
const content = await this.downloadFromProvider(
connectedAccount,
association.messageExternalId ?? '',
messageAttachment.externalIdentifier ?? '',
);
const stream = Readable.from(content);
return {
stream,
filename: messageAttachment.name,
mimeType: messageAttachment.mimeType || 'application/octet-stream',
size: messageAttachment.size,
};
},
authContext,
);
}
private async downloadFromProvider(
connectedAccount: ConnectedAccountWorkspaceEntity,
externalMessageId: string,
externalIdentifier: string,
): Promise<Buffer> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailAttachmentDownloadService.download(
connectedAccount,
externalMessageId,
externalIdentifier,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftAttachmentDownloadService.download(
connectedAccount,
externalMessageId,
externalIdentifier,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapAttachmentDownloadService.download(
connectedAccount,
externalMessageId,
externalIdentifier,
);
default:
throw new Error(`Unsupported provider: ${connectedAccount.provider}`);
}
}
}
@@ -83,7 +83,12 @@ export const parseAndFormatGmailMessage = (
direction: computeMessageDirection(from || '', connectedAccount),
participants,
text: sanitizeString(textWithoutReplyQuotations),
attachments,
attachments: attachments.map((attachment) => ({
filename: attachment.filename,
mimeType: attachment.mimeType,
size: attachment.size,
externalIdentifier: attachment.id,
})),
messageFolderExternalIds: labelIds,
labelIds,
};
@@ -245,6 +245,12 @@ export class ImapGetMessagesService {
private extractAttachments(parsed: ParsedMail) {
return (parsed.attachments || []).map((attachment) => ({
filename: attachment.filename || 'unnamed-attachment',
mimeType: attachment.mimeType,
size:
typeof attachment.content === 'string'
? attachment.content.length
: attachment.content?.byteLength,
externalIdentifier: attachment.contentId,
}));
}
}
@@ -37,7 +37,7 @@ export class MicrosoftFetchByBatchService {
const batchRequests = batchMessageIds.map((messageId, index) => ({
id: (index + 1).toString(),
method: 'GET',
url: `/me/messages/${messageId}`,
url: `/me/messages/${messageId}?$expand=attachments($select=id,name,contentType,size)`,
headers: {
'Content-Type': 'application/json',
Prefer: 'outlook.body-content-type="text"',
@@ -41,6 +41,12 @@ export interface MicrosoftGraphBatchResponse {
address?: string;
};
};
attachments?: Array<{
id?: string;
name?: string;
contentType?: string;
size?: number;
}>;
};
}[];
}
@@ -150,7 +150,19 @@ export class MicrosoftGetMessagesService {
)
: MessageDirection.INCOMING,
participants,
attachments: [],
attachments: (response.attachments ?? []).map(
(attachment: {
id?: string;
name?: string;
contentType?: string;
size?: number;
}) => ({
filename: attachment.name || 'unnamed-attachment',
mimeType: attachment.contentType,
size: attachment.size,
externalIdentifier: attachment.id,
}),
),
messageFolderExternalIds: response.parentFolderId
? [response.parentFolderId]
: [],
@@ -7,6 +7,7 @@ import { v4 } from 'uuid';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type MessageAttachmentWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-attachment.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
@@ -224,6 +225,41 @@ export class MessagingMessageService {
transactionManager,
);
const messageAttachmentRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageAttachmentWorkspaceEntity>(
workspaceId,
'messageAttachment',
);
const messageAttachmentsToCreate: Partial<MessageAttachmentWorkspaceEntity>[] =
[];
for (const message of messages) {
const accumulator = messageAccumulatorMap.get(message.externalId);
if (!isDefined(accumulator?.messageToCreate)) {
continue;
}
for (const attachment of message.attachments) {
messageAttachmentsToCreate.push({
id: v4(),
name: attachment.filename,
mimeType: attachment.mimeType ?? null,
size: attachment.size ?? null,
externalIdentifier: attachment.externalIdentifier ?? null,
messageId: accumulator.messageToCreate.id,
});
}
}
if (messageAttachmentsToCreate.length > 0) {
await messageAttachmentRepository.insert(
messageAttachmentsToCreate,
transactionManager,
);
}
const messageExternalIdsAndIdsMap = new Map<string, string>();
const messageExternalIdToMessageChannelMessageAssociationIdMap =
new Map<string, string>();
@@ -9,6 +9,7 @@ export type Message = Omit<
| 'deletedAt'
| 'messageChannelMessageAssociations'
| 'messageParticipants'
| 'messageAttachments'
| 'messageThread'
| 'messageThreadId'
| 'messageFolders'
@@ -16,6 +17,9 @@ export type Message = Omit<
> & {
attachments: {
filename: string;
mimeType?: string;
size?: number;
externalIdentifier?: string;
}[];
externalId: string;
messageThreadExternalId: string;
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { MessagingBlocklistManagerModule } from 'src/modules/messaging/blocklist-manager/messaging-blocklist-manager.module';
import { MessagingAttachmentManagerModule } from 'src/modules/messaging/message-attachment-manager/messaging-attachment-manager.module';
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
@@ -9,6 +10,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
@Module({
imports: [
MessagingImportManagerModule,
MessagingAttachmentManagerModule,
MessagingMessageCleanerModule,
MessageParticipantManagerModule,
MessagingBlocklistManagerModule,
@@ -1153,6 +1153,53 @@ export const STANDARD_OBJECTS = {
},
},
},
messageAttachment: {
universalIdentifier: 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e',
fields: {
id: { universalIdentifier: 'c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f' },
createdAt: {
universalIdentifier: 'd1e2f3a4-b5c6-4d7e-8f9a-1b2c3d4e5f6a',
},
updatedAt: {
universalIdentifier: 'e1f2a3b4-c5d6-4e7f-8a9b-2c3d4e5f6a7b',
},
deletedAt: {
universalIdentifier: 'f1a2b3c4-d5e6-4f7a-8b9c-3d4e5f6a7b8c',
},
name: {
universalIdentifier: 'a2b3c4d5-e6f7-4a8b-9c0d-4e5f6a7b8c9d',
},
mimeType: {
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-5f6a7b8c9d0e',
},
size: {
universalIdentifier: 'c2d3e4f5-a6b7-4c8d-9e0f-6a7b8c9d0e1f',
},
externalIdentifier: {
universalIdentifier: 'd2e3f4a5-b6c7-4d8e-9f0a-7b8c9d0e1f2a',
},
message: {
universalIdentifier: 'e2f3a4b5-c6d7-4e8f-9a0b-8c9d0e1f2a3b',
},
createdBy: {
universalIdentifier: 'f2a3b4c5-d6e7-4f8a-9b0c-9d0e1f2a3b4c',
},
updatedBy: {
universalIdentifier: 'a3b4c5d6-e7f8-4a9b-8c0d-0e1f2a3b4c5d',
},
position: {
universalIdentifier: 'b3c4d5e6-f7a8-4b9c-8d0e-1f2a3b4c5d6e',
},
searchVector: {
universalIdentifier: 'c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f',
},
},
indexes: {
messageIdIndex: {
universalIdentifier: 'd3e4f5a6-b7c8-4d9e-8f0a-3b4c5d6e7f8a',
},
},
},
messageThread: {
universalIdentifier: '20202020-849a-4c3e-84f5-a25a7d802271',
fields: {
@@ -1245,6 +1292,9 @@ export const STANDARD_OBJECTS = {
searchVector: {
universalIdentifier: '529b6008-4a12-4d48-bbc3-26a3f199bafd',
},
messageAttachments: {
universalIdentifier: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
},
},
indexes: {
messageThreadIdIndex: {