Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31da4d80ae | |||
| 67033b8c96 | |||
| 4f3b4868ad | |||
| 74d46f835c | |||
| 68a6591336 | |||
| b6dafbcb3b | |||
| f72b7ef552 |
+132
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+6
@@ -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';
|
||||
};
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ export const CommandMenuMessageThreadIntermediaryMessages = ({
|
||||
participants={message.messageParticipants}
|
||||
body={message.text}
|
||||
sentAt={message.receivedAt}
|
||||
attachments={message.messageAttachments}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+290
@@ -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,
|
||||
}),
|
||||
});
|
||||
+23
@@ -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,
|
||||
|
||||
+31
@@ -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,
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+20
@@ -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;
|
||||
}
|
||||
+2
@@ -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[]>;
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const MESSAGE_ATTACHMENT_MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
+76
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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');
|
||||
}
|
||||
}
|
||||
+90
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+37
@@ -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');
|
||||
}
|
||||
}
|
||||
+31
@@ -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 {}
|
||||
+174
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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,
|
||||
};
|
||||
|
||||
+6
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"',
|
||||
|
||||
+6
@@ -41,6 +41,12 @@ export interface MicrosoftGraphBatchResponse {
|
||||
address?: string;
|
||||
};
|
||||
};
|
||||
attachments?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
}>;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
+13
-1
@@ -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]
|
||||
: [],
|
||||
|
||||
+36
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user