feat: message folders control (#14144)

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
neo773
2025-08-30 21:09:10 +05:30
committed by GitHub
parent a0884cede9
commit 8ab71fef1f
49 changed files with 1786 additions and 301 deletions
@@ -972,6 +972,7 @@ export enum FeatureFlagKey {
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
@@ -936,6 +936,7 @@ export enum FeatureFlagKey {
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
@@ -1,4 +1,5 @@
import { type ImapSmtpCaldavAccount } from '@/accounts/types/ImapSmtpCaldavAccount';
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { type ConnectedAccountProvider } from 'twenty-shared/types';
import { type MessageChannelVisibility } from '~/generated/graphql';
@@ -35,6 +36,7 @@ export type MessageChannel = {
excludeNonProfessionalEmails: boolean;
excludeGroupEmails: boolean;
isSyncEnabled: boolean;
messageFolders: MessageFolder[];
visibility: MessageChannelVisibility;
syncStatus: MessageChannelSyncStatus;
syncStage: MessageChannelSyncStage;
@@ -0,0 +1,9 @@
export type MessageFolder = {
id: string;
name: string;
syncCursor: string;
isSentFolder: boolean;
isSynced: boolean;
messageChannelId: string;
__typename: 'MessageFolder';
};
@@ -47,6 +47,15 @@ export const triggerAttachRelationOptimisticEffect = ({
}
if (fieldValueIsObjectRecordConnectionWithRefs) {
const recordAlreadyExists = targetRecordFieldValue.edges.some(
(edge: RecordGqlRefEdge) =>
edge.node.__ref === sourceRecordReference.__ref,
);
if (recordAlreadyExists) {
return targetRecordFieldValue;
}
const nextEdges: RecordGqlRefEdge[] = [
...targetRecordFieldValue.edges,
{
@@ -15,6 +15,7 @@ export enum CoreObjectNameSingular {
Message = 'message',
MessageChannel = 'messageChannel',
MessageParticipant = 'messageParticipant',
MessageFolder = 'messageFolder',
MessageThread = 'messageThread',
Note = 'note',
NoteTarget = 'noteTarget',
@@ -7,12 +7,17 @@ import {
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard';
import { SettingsAccountsMessageFoldersCard } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard';
import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { type MessageChannelVisibility } from '~/generated-metadata/graphql';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { Card, Section } from 'twenty-ui/layout';
import { H2Title, IconBriefcase, IconUsers } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import {
FeatureFlagKey,
type MessageChannelVisibility,
} from '~/generated-metadata/graphql';
type SettingsAccountsMessageChannelDetailsProps = {
messageChannel: Pick<
@@ -23,6 +28,7 @@ type SettingsAccountsMessageChannelDetailsProps = {
| 'excludeNonProfessionalEmails'
| 'excludeGroupEmails'
| 'isSyncEnabled'
| 'messageFolders'
>;
};
@@ -39,6 +45,10 @@ export const SettingsAccountsMessageChannelDetails = ({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
});
const isFolderControlEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_MESSAGE_FOLDER_CONTROL_ENABLED,
);
const handleVisibilityChange = (value: MessageChannelVisibility) => {
updateOneRecord({
idToUpdate: messageChannel.id,
@@ -79,6 +89,18 @@ export const SettingsAccountsMessageChannelDetails = ({
return (
<StyledDetailsContainer>
{isFolderControlEnabled && messageChannel.messageFolders && (
<Section>
<H2Title
title={t`Folder Management`}
description={t`Control which folders are synced`}
/>
<SettingsAccountsMessageFoldersCard
messageChannelId={messageChannel.id}
messageFolders={messageChannel.messageFolders}
/>
</Section>
)}
<Section>
<H2Title
title={t`Visibility`}
@@ -4,7 +4,9 @@ import { useRecoilValue } from 'recoil';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
@@ -25,6 +27,10 @@ export const SettingsAccountsMessageChannelsContainer = () => {
);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const messageChannelObjectMetadataItem = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
});
const { records: accounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
@@ -48,15 +54,16 @@ export const SettingsAccountsMessageChannelsContainer = () => {
eq: true,
},
},
recordGqlFields: generateDepthOneRecordGqlFields(
messageChannelObjectMetadataItem,
),
skip: !accounts.length,
});
const tabs = [
...messageChannels.map((messageChannel) => ({
id: messageChannel.id,
title: messageChannel.handle,
})),
];
const tabs = messageChannels.map((messageChannel) => ({
id: messageChannel.id,
title: messageChannel.handle,
}));
if (!messageChannels.length) {
return <SettingsNewAccountSection />;
@@ -2,11 +2,11 @@ import { type Meta, type StoryObj } from '@storybook/react';
import { MessageChannelContactAutoCreationPolicy } from '@/accounts/types/MessageChannel';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { ComponentDecorator } from 'twenty-ui/testing';
import { MessageChannelVisibility } from '~/generated/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof SettingsAccountsMessageChannelDetails> = {
title:
@@ -26,6 +26,7 @@ const meta: Meta<typeof SettingsAccountsMessageChannelDetails> = {
excludeGroupEmails: false,
isSyncEnabled: true,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
messageFolders: [],
},
},
argTypes: {
@@ -0,0 +1,38 @@
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard';
import { SettingsMessageFoldersTableHeader } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTableHeader';
import { SettingsMessageFoldersTableRow } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTableRow';
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { Section } from 'twenty-ui/layout';
type SettingsAccountsMessageFoldersCardProps = {
messageChannelId: string;
messageFolders: MessageFolder[];
};
const StyledTableRows = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAccountsMessageFoldersCard = ({
messageFolders,
}: SettingsAccountsMessageFoldersCardProps) => {
if (!messageFolders || messageFolders.length === 0) {
return <SettingsMessageFoldersEmptyStateCard />;
}
return (
<Section>
<Table>
<SettingsMessageFoldersTableHeader />
<StyledTableRows>
{messageFolders.map((folder) => (
<SettingsMessageFoldersTableRow key={folder.id} folder={folder} />
))}
</StyledTableRows>
</Table>
</Section>
);
};
@@ -0,0 +1,28 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconFolder } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
const StyledEmptyState = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(8)};
text-align: center;
`;
export const SettingsMessageFoldersEmptyStateCard = () => {
const theme = useTheme();
return (
<Section>
<StyledEmptyState>
<IconFolder size={theme.icon.size.md} />
<div>{t`No folders found for this account`}</div>
</StyledEmptyState>
</Section>
);
};
@@ -0,0 +1,19 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { Trans } from '@lingui/react/macro';
export const SettingsMessageFoldersTableHeader = () => {
return (
<Table>
<TableRow gridAutoColumns="1fr 120px">
<TableHeader>
<Trans>Folder</Trans>
</TableHeader>
<TableHeader align="center">
<Trans>Sync</Trans>
</TableHeader>
</TableRow>
</Table>
);
};
@@ -0,0 +1,75 @@
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconFolder, IconSend } from 'twenty-ui/display';
import { Toggle } from 'twenty-ui/input';
const StyledFolderNameCell = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledTableRow = styled(TableRow)`
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
type SettingsMessageFoldersTableRowProps = {
folder: MessageFolder;
};
export const SettingsMessageFoldersTableRow = ({
folder,
}: SettingsMessageFoldersTableRowProps) => {
const theme = useTheme();
const { updateOneRecord } = useUpdateOneRecord<MessageFolder>({
objectNameSingular: CoreObjectNameSingular.MessageFolder,
});
const handleSyncToggle = (value: boolean) => {
updateOneRecord({
idToUpdate: folder.id,
updateOneRecordInput: {
isSynced: value,
},
});
};
const formatName = (name: string) => {
return name
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
return (
<StyledTableRow gridAutoColumns="1fr 120px 70px">
<TableCell>
<StyledFolderNameCell>
{folder.isSentFolder ? (
<IconSend size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<IconFolder
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{formatName(folder.name)}
</StyledFolderNameCell>
</TableCell>
<TableCell align="center">
<Toggle
value={folder.isSynced}
onChange={handleSyncToggle}
toggleSize="small"
/>
</TableCell>
</StyledTableRow>
);
};
@@ -4,6 +4,7 @@ import { Command, CommandRunner } from 'nest-commander';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command';
import { CleanupOrphanedFilesCronCommand } from 'src/engine/core-modules/file/crons/commands/cleanup-orphaned-files.cron.command';
import { CronTriggerCronCommand } from 'src/engine/metadata-modules/trigger/crons/commands/cron-trigger.cron.command';
import { CalendarEventListFetchCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command';
import { CalendarEventsImportCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command';
import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-ongoing-stale.cron.command';
@@ -14,7 +15,6 @@ import { WorkflowCleanWorkflowRunsCommand } from 'src/modules/workflow/workflow-
import { WorkflowHandleStaledRunsCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-handle-staled-runs.cron.command';
import { WorkflowRunEnqueueCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-run-enqueue.cron.command';
import { WorkflowCronTriggerCronCommand } from 'src/modules/workflow/workflow-trigger/automated-trigger/crons/commands/workflow-cron-trigger.cron.command';
import { CronTriggerCronCommand } from 'src/engine/metadata-modules/trigger/crons/commands/cron-trigger.cron.command';
@Command({
name: 'cron:register:all',
@@ -27,6 +27,7 @@ export class CronRegisterAllCommand extends CommandRunner {
private readonly messagingMessagesImportCronCommand: MessagingMessagesImportCronCommand,
private readonly messagingMessageListFetchCronCommand: MessagingMessageListFetchCronCommand,
private readonly messagingOngoingStaleCronCommand: MessagingOngoingStaleCronCommand,
private readonly calendarEventListFetchCronCommand: CalendarEventListFetchCronCommand,
private readonly calendarEventsImportCronCommand: CalendarEventsImportCronCommand,
private readonly calendarOngoingStaleCronCommand: CalendarOngoingStaleCronCommand,
@@ -14,6 +14,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { TriggerModule } from 'src/engine/metadata-modules/trigger/trigger.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
@@ -21,7 +22,6 @@ import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { WorkflowRunQueueModule } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workflow-run-queue.module';
import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module';
import { TriggerModule } from 'src/engine/metadata-modules/trigger/trigger.module';
import { DataSeedWorkspaceCommand } from './data-seed-dev-workspace.command';
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddPositionsToWorkflowVersionsAndWorkflowRuns } from 'src/database/commands/upgrade-version-command/1-5/1-5-add-positions-to-workflow-versions-and-workflow-runs.command';
import { RemoveFavoriteViewRelation } from 'src/database/commands/upgrade-version-command/1-5/1-5-remove-favorite-view-relation.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -8,7 +9,6 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { AddPositionsToWorkflowVersionsAndWorkflowRuns } from 'src/database/commands/upgrade-version-command/1-5/1-5-add-positions-to-workflow-versions-and-workflow-runs.command';
@Module({
imports: [
@@ -31,13 +31,13 @@ import { RemoveWorkflowRunsWithoutState } from 'src/database/commands/upgrade-ve
import { AddNextStepIdsToWorkflowRunsTrigger } from 'src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command';
import { AssignRolesToExistingApiKeysCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-assign-roles-to-existing-api-keys.command';
import { UpdateTimestampColumnTypeInWorkspaceSchemaCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-update-timestamp-column-type-in-workspace-schema.command';
import { AddPositionsToWorkflowVersionsAndWorkflowRuns } from 'src/database/commands/upgrade-version-command/1-5/1-5-add-positions-to-workflow-versions-and-workflow-runs.command';
import { RemoveFavoriteViewRelation } from 'src/database/commands/upgrade-version-command/1-5/1-5-remove-favorite-view-relation.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
import { compareVersionMajorAndMinor } from 'src/utils/version/compare-version-minor-and-major';
import { AddPositionsToWorkflowVersionsAndWorkflowRuns } from 'src/database/commands/upgrade-version-command/1-5/1-5-add-positions-to-workflow-versions-and-workflow-runs.command';
const execPromise = promisify(exec);
@@ -17,7 +17,6 @@ import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.s
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { CreateMessageFolderService } from 'src/engine/core-modules/auth/services/create-message-folder.service';
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
@@ -59,6 +58,7 @@ import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
import { TwoFactorAuthenticationMethod } from '../two-factor-authentication/entities/two-factor-authentication-method.entity';
import { TwoFactorAuthenticationModule } from '../two-factor-authentication/two-factor-authentication.module';
@@ -96,6 +96,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
OnboardingModule,
WorkspaceDataSourceModule,
ConnectedAccountModule,
MessagingFolderSyncManagerModule,
WorkspaceSSOModule,
FeatureFlagModule,
WorkspaceInvitationModule,
@@ -136,17 +137,11 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ResetMessageFolderService,
CreateMessageChannelService,
CreateCalendarChannelService,
CreateMessageFolderService,
CreateConnectedAccountService,
UpdateConnectedAccountOnReconnectService,
TransientTokenService,
AuthSsoService,
],
exports: [
AccessTokenService,
LoginTokenService,
RefreshTokenService,
CreateMessageFolderService,
],
exports: [AccessTokenService, LoginTokenService, RefreshTokenService],
})
export class AuthModule {}
@@ -1,53 +0,0 @@
import { Injectable } from '@nestjs/common';
import { v4 } from 'uuid';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { MessageFolderName } from 'src/modules/messaging/message-import-manager/drivers/microsoft/types/folders';
export type CreateMessageFoldersInput = {
workspaceId: string;
messageChannelId: string;
manager: WorkspaceEntityManager;
};
@Injectable()
export class CreateMessageFolderService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async createMessageFolders(input: CreateMessageFoldersInput): Promise<void> {
const { workspaceId, messageChannelId, manager } = input;
const messageFolderRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageFolderWorkspaceEntity>(
workspaceId,
'messageFolder',
);
await messageFolderRepository.save(
{
id: v4(),
messageChannelId,
name: MessageFolderName.INBOX,
syncCursor: '',
},
{},
manager,
);
await messageFolderRepository.save(
{
id: v4(),
messageChannelId,
name: MessageFolderName.SENT_ITEMS,
syncCursor: '',
},
{},
manager,
);
}
}
@@ -6,7 +6,6 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { CreateMessageFolderService } from 'src/engine/core-modules/auth/services/create-message-folder.service';
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service';
@@ -141,12 +140,6 @@ describe('MicrosoftAPIsService', () => {
.mockResolvedValue('message-channel-id'),
},
},
{
provide: CreateMessageFolderService,
useValue: {
createMessageFolders: jest.fn(),
},
},
{
provide: CreateCalendarChannelService,
useValue: {
@@ -6,7 +6,6 @@ import { v4 } from 'uuid';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { CreateMessageFolderService } from 'src/engine/core-modules/auth/services/create-message-folder.service';
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service';
import { ResetMessageFolderService } from 'src/engine/core-modules/auth/services/reset-message-folder.service';
@@ -52,7 +51,6 @@ export class MicrosoftAPIsService {
private readonly resetCalendarChannelService: ResetCalendarChannelService,
private readonly createMessageChannelService: CreateMessageChannelService,
private readonly createCalendarChannelService: CreateCalendarChannelService,
private readonly createMessageFolderService: CreateMessageFolderService,
private readonly createConnectedAccountService: CreateConnectedAccountService,
private readonly updateConnectedAccountOnReconnectService: UpdateConnectedAccountOnReconnectService,
private readonly twentyConfigService: TwentyConfigService,
@@ -122,18 +120,11 @@ export class MicrosoftAPIsService {
manager,
});
const newMessageChannelId =
await this.createMessageChannelService.createMessageChannel({
workspaceId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
messageVisibility,
manager,
});
await this.createMessageFolderService.createMessageFolders({
await this.createMessageChannelService.createMessageChannel({
workspaceId,
messageChannelId: newMessageChannelId,
connectedAccountId: newOrExistingConnectedAccountId,
handle,
messageVisibility,
manager,
});
@@ -30,6 +30,15 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
imagePath: 'https://twenty.com/images/lab/is-workflow-branch-enabled.png',
},
},
{
key: FeatureFlagKey.IS_MESSAGE_FOLDER_CONTROL_ENABLED,
metadata: {
label: 'Message Folder Control',
description: 'Control which folders are synced',
imagePath:
'https://twenty.com/images/lab/is-message-folder-control-enabled.png',
},
},
...(process.env.CLOUDFLARE_API_KEY
? [
// {
@@ -14,4 +14,5 @@ export enum FeatureFlagKey {
IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED',
IS_API_KEY_ROLES_ENABLED = 'IS_API_KEY_ROLES_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED',
}
@@ -137,6 +137,7 @@ describe('WorkspaceEntityManager', () => {
IS_WORKSPACE_MIGRATION_V2_ENABLED: false,
IS_API_KEY_ROLES_ENABLED: false,
IS_PAGE_LAYOUT_ENABLED: false,
IS_MESSAGE_FOLDER_CONTROL_ENABLED: false,
},
eventEmitterService: {
emitMutationEvent: jest.fn(),
@@ -253,6 +253,9 @@ export const MESSAGE_FOLDER_STANDARD_FIELD_IDS = {
name: '20202020-7cf8-40bc-a681-b80b771449b7',
messageChannel: '20202020-b658-408f-bd46-3bd2d15d7e52',
syncCursor: '20202020-98cd-49ed-8dfc-cb5796400e64',
isSentFolder: '20202020-2af5-4a25-b2de-3c9386da941b',
isSynced: '20202020-764f-4e09-8f95-cd46b6bfe3c4',
externalId: '20202020-f3a8-4d2b-9c7e-1b5f9a8e4c6d',
} as const;
export const MESSAGE_PARTICIPANT_STANDARD_FIELD_IDS = {
@@ -3,12 +3,10 @@ import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { CreateMessageFolderService } from 'src/engine/core-modules/auth/services/create-message-folder.service';
import { type EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
@@ -40,7 +38,6 @@ export class ImapSmtpCalDavAPIService {
private readonly messageQueueService: MessageQueueService,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly calendarQueueService: MessageQueueService,
private readonly createMessageFolderService: CreateMessageFolderService,
) {}
async setupCompleteAccount(input: {
@@ -89,28 +86,25 @@ export class ImapSmtpCalDavAPIService {
let createdMessageChannel: MessageChannelWorkspaceEntity | null = null;
let createdCalendarChannel: CalendarChannelWorkspaceEntity | null = null;
await workspaceDataSource.transaction(
async (manager: WorkspaceEntityManager) => {
await this.upsertConnectedAccount(
input,
accountId,
connectedAccountRepository,
);
await workspaceDataSource.transaction(async () => {
await this.upsertConnectedAccount(
input,
accountId,
connectedAccountRepository,
);
createdMessageChannel = await this.setupMessageChannels(
input,
accountId,
messageChannelRepository,
manager,
);
createdMessageChannel = await this.setupMessageChannels(
input,
accountId,
messageChannelRepository,
);
createdCalendarChannel = await this.setupCalendarChannels(
input,
accountId,
calendarChannelRepository,
);
},
);
createdCalendarChannel = await this.setupCalendarChannels(
input,
accountId,
calendarChannelRepository,
);
});
await this.enqueueSyncJobs(
input,
@@ -149,7 +143,6 @@ export class ImapSmtpCalDavAPIService {
},
accountId: string,
messageChannelRepository: WorkspaceRepository<MessageChannelWorkspaceEntity>,
manager: WorkspaceEntityManager,
): Promise<MessageChannelWorkspaceEntity | null> {
const existingChannels = await messageChannelRepository.find({
where: { connectedAccountId: accountId },
@@ -182,12 +175,6 @@ export class ImapSmtpCalDavAPIService {
{},
);
await this.createMessageFolderService.createMessageFolders({
workspaceId: input.workspaceId,
messageChannelId: newMessageChannel.id,
manager,
});
return shouldEnableSync ? newMessageChannel : null;
}
@@ -1,14 +1,15 @@
import { msg } from '@lingui/core/macro';
import { Relation } from 'typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { Relation } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
@@ -58,6 +59,37 @@ export class MessageFolderWorkspaceEntity extends BaseWorkspaceEntity {
})
syncCursor: string;
@WorkspaceField({
standardId: MESSAGE_FOLDER_STANDARD_FIELD_IDS.isSentFolder,
type: FieldMetadataType.BOOLEAN,
label: msg`Is Sent Folder`,
description: msg`Is Sent Folder`,
icon: 'IconCheck',
defaultValue: false,
})
isSentFolder: boolean;
@WorkspaceField({
standardId: MESSAGE_FOLDER_STANDARD_FIELD_IDS.isSynced,
type: FieldMetadataType.BOOLEAN,
label: msg`Is Synced`,
description: msg`Is Synced`,
icon: 'IconCheck',
defaultValue: false,
})
isSynced: boolean;
@WorkspaceField({
standardId: MESSAGE_FOLDER_STANDARD_FIELD_IDS.externalId,
type: FieldMetadataType.TEXT,
label: msg`External ID`,
description: msg`External ID`,
icon: 'IconHash',
defaultValue: null,
})
@WorkspaceIsNullable()
externalId: string | null;
@WorkspaceJoinColumn('messageChannel')
messageChannelId: string;
}
@@ -0,0 +1,110 @@
import { Injectable, Logger } from '@nestjs/common';
import { gmail_v1 } from 'googleapis';
import { isDefined } from 'twenty-shared/utils';
import {
MessageFolder,
MessageFolderDriver,
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MESSAGING_GMAIL_EXCLUDED_CATEGORIES } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-excluded-categories';
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { GmailHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-handle-error.service';
import { computeGmailCategoryLabelId } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-category-label-id.util';
@Injectable()
export class GmailGetAllFoldersService implements MessageFolderDriver {
private readonly logger = new Logger(GmailGetAllFoldersService.name);
constructor(
private readonly gmailClientProvider: GmailClientProvider,
private readonly gmailHandleErrorService: GmailHandleErrorService,
) {}
private isExcludedCategoryFolder(labelId: string): boolean {
const excludedCategoryIds = MESSAGING_GMAIL_EXCLUDED_CATEGORIES.map(
(category) => computeGmailCategoryLabelId(category),
);
return excludedCategoryIds.includes(labelId);
}
private isIncludedFolder(label: gmail_v1.Schema$Label): boolean {
if (!isDefined(label.id)) {
return false;
}
const isTargetSystemFolder =
label.type === 'system' && (label.id === 'INBOX' || label.id === 'SENT');
const isUserFolder = label.type === 'user';
return isTargetSystemFolder || isUserFolder;
}
async getAllMessageFolders(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'provider' | 'refreshToken' | 'id' | 'handle'
>,
): Promise<MessageFolder[]> {
try {
const gmailClient =
await this.gmailClientProvider.getGmailClient(connectedAccount);
const response = await gmailClient.users.labels
.list({ userId: 'me' })
.catch((error) => {
this.logger.error(
`Connected account ${connectedAccount.id}: Error fetching labels: ${error.message}`,
);
this.gmailHandleErrorService.handleGmailMessageListFetchError(error);
return { data: { labels: [] } };
});
const labels = response.data.labels || [];
const folders: MessageFolder[] = [];
for (const label of labels) {
if (!label.name || !label.id) {
continue;
}
if (this.isExcludedCategoryFolder(label.id)) {
continue;
}
if (!this.isIncludedFolder(label)) {
continue;
}
const isSentFolder = label.id === 'SENT';
const isSyncedByDefault = label.id === 'INBOX' || label.id === 'SENT';
folders.push({
externalId: label.id,
name: label.name,
isSynced: isSyncedByDefault,
isSentFolder,
});
}
this.logger.log(
`Found ${folders.length} folders for Gmail account ${connectedAccount.handle}`,
);
return folders;
} catch (error) {
this.logger.error(
`Failed to get Gmail folders for account ${connectedAccount.handle}:`,
error,
);
throw error;
}
}
}
@@ -0,0 +1,171 @@
import { Injectable, Logger } from '@nestjs/common';
import { ImapFlow, type ListResponse } from 'imapflow';
import { isDefined } from 'twenty-shared/utils';
import {
MessageFolder,
MessageFolderDriver,
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
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 { ImapFindSentFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service';
import { MessageFolderName } from 'src/modules/messaging/message-import-manager/drivers/imap/types/folders';
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-manager/drivers/utils/get-standard-folder-by-regex';
@Injectable()
export class ImapGetAllFoldersService implements MessageFolderDriver {
private readonly logger = new Logger(ImapGetAllFoldersService.name);
constructor(
private readonly imapClientProvider: ImapClientProvider,
private readonly imapFindSentFolderService: ImapFindSentFolderService,
) {}
public async getAllMessageFolders(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'id' | 'provider' | 'connectionParameters' | 'handle'
>,
): Promise<MessageFolder[]> {
try {
const client = await this.imapClientProvider.getClient(connectedAccount);
const mailboxList = await client.list();
const folders = await this.filterAndMapFolders(client, mailboxList);
await this.imapClientProvider.closeClient(client);
return folders;
} catch (error) {
this.logger.error(
`Failed to get IMAP folders for account ${connectedAccount.handle}:`,
error,
);
throw error;
}
}
private async filterAndMapFolders(
client: ImapFlow,
mailboxList: ListResponse[],
): Promise<MessageFolder[]> {
const folders: MessageFolder[] = [];
const sentFolderPath =
await this.imapFindSentFolderService.findSentFolder(client);
if (isDefined(sentFolderPath)) {
const sentMailbox = mailboxList.find((m) => m.path === sentFolderPath);
const uidValidity = sentMailbox
? await this.getUidValidity(client, sentMailbox)
: null;
folders.push({
externalId: uidValidity
? `${sentFolderPath}:${uidValidity.toString()}`
: sentFolderPath,
name: sentFolderPath,
isSynced: true,
isSentFolder: true,
});
}
const validMailboxes = mailboxList.filter((mailbox) =>
this.isValidMailbox(mailbox, folders),
);
for (const mailbox of validMailboxes) {
const isInbox = await this.isInboxFolder(mailbox);
const uidValidity = await this.getUidValidity(client, mailbox);
folders.push({
externalId: uidValidity
? `${mailbox.path}:${uidValidity}`
: mailbox.path,
name: mailbox.path,
isSynced: isInbox,
isSentFolder: false,
});
}
return folders;
}
private isValidMailbox(
mailbox: ListResponse,
existingFolders: MessageFolder[],
): boolean {
if (this.shouldExcludeFolder(mailbox)) {
return false;
}
const isDuplicate = existingFolders.some(
(folder) => folder.name === mailbox.path,
);
return !isDuplicate;
}
private async isInboxFolder(mailbox: ListResponse): Promise<boolean> {
if (
mailbox.path.toLowerCase() === MessageFolderName.INBOX ||
mailbox.specialUse === '\\Inbox'
) {
return true;
}
return false;
}
private shouldExcludeFolder(mailbox: ListResponse): boolean {
if (mailbox.flags?.has('\\Noselect')) {
return true;
}
if (
mailbox.specialUse === '\\Drafts' ||
mailbox.specialUse === '\\Trash' ||
mailbox.specialUse === '\\Junk'
) {
return true;
}
const standardFolder = getStandardFolderByRegex(mailbox.path);
if (!standardFolder) {
return false;
}
return (
standardFolder !== StandardFolder.SENT &&
standardFolder !== StandardFolder.INBOX
);
}
private async getUidValidity(
client: ImapFlow,
mailbox: ListResponse,
): Promise<bigint | null> {
if (mailbox.status?.uidValidity) {
return mailbox.status.uidValidity;
}
try {
const status = await client.status(mailbox.path, {
uidValidity: true,
});
return status.uidValidity ?? null;
} catch (error) {
this.logger.warn(
`Failed to get uidValidity for folder ${mailbox.path}:`,
error,
);
return null;
}
}
}
@@ -0,0 +1,107 @@
import { Injectable, Logger } from '@nestjs/common';
import {
MessageFolder,
MessageFolderDriver,
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { MicrosoftHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-handle-error.service';
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-manager/drivers/utils/get-standard-folder-by-regex';
type MicrosoftGraphFolder = {
id: string;
displayName: string;
};
@Injectable()
export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
private readonly logger = new Logger(MicrosoftGetAllFoldersService.name);
constructor(
private readonly microsoftClientProvider: MicrosoftClientProvider,
private readonly microsoftHandleErrorService: MicrosoftHandleErrorService,
) {}
async getAllMessageFolders(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'refreshToken' | 'id' | 'handle'
>,
): Promise<MessageFolder[]> {
try {
const microsoftClient =
await this.microsoftClientProvider.getMicrosoftClient(connectedAccount);
const response = await microsoftClient
.api('/me/mailFolders')
.get()
.catch((error) => {
this.logger.error(
`Connected account ${connectedAccount.id}: Error fetching folders: ${error.message}`,
);
this.microsoftHandleErrorService.handleMicrosoftGetMessageListError(
error,
);
return { value: [] };
});
const folders = (response.value as MicrosoftGraphFolder[]) || [];
const folderInfos: MessageFolder[] = [];
for (const folder of folders) {
if (!folder.displayName) {
continue;
}
const standardFolder = getStandardFolderByRegex(folder.displayName);
if (this.shouldExcludeFolder(standardFolder)) {
continue;
}
const isInbox = this.isInboxFolder(standardFolder);
const isSentFolder = this.isSentFolder(standardFolder);
folderInfos.push({
externalId: folder.id,
name: folder.displayName,
isSynced: isInbox || isSentFolder,
isSentFolder,
});
}
this.logger.log(
`Found ${folderInfos.length} folders for Microsoft account ${connectedAccount.handle}`,
);
return folderInfos;
} catch (error) {
this.logger.error(
`Failed to get Microsoft folders for account ${connectedAccount.handle}:`,
error,
);
throw error;
}
}
private isInboxFolder(standardFolder: StandardFolder | null): boolean {
return standardFolder === StandardFolder.INBOX;
}
private isSentFolder(standardFolder: StandardFolder | null): boolean {
return standardFolder === StandardFolder.SENT;
}
private shouldExcludeFolder(standardFolder: StandardFolder | null): boolean {
return (
standardFolder !== null &&
standardFolder !== StandardFolder.SENT &&
standardFolder !== StandardFolder.INBOX
);
}
}
@@ -0,0 +1,16 @@
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
export type MessageFolder = Pick<
MessageFolderWorkspaceEntity,
'name' | 'isSynced' | 'isSentFolder' | 'externalId'
>;
export interface MessageFolderDriver {
getAllMessageFolders(
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'provider' | 'refreshToken' | 'id' | 'handle' | 'connectionParameters'
>,
): Promise<MessageFolder[]>;
}
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
import { GmailGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/gmail/gmail-get-all-folders.service';
import { ImapGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service';
import { MicrosoftGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service';
import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module';
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module';
@Module({
imports: [
FeatureFlagModule,
WorkspaceDataSourceModule,
DataSourceModule,
TypeOrmModule.forFeature([Workspace]),
MessagingGmailDriverModule,
MessagingMicrosoftDriverModule,
MessagingIMAPDriverModule,
],
providers: [
SyncMessageFoldersService,
GmailGetAllFoldersService,
ImapGetAllFoldersService,
MicrosoftGetAllFoldersService,
],
exports: [SyncMessageFoldersService],
})
export class MessagingFolderSyncManagerModule {}
@@ -0,0 +1,181 @@
import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { GmailGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/gmail/gmail-get-all-folders.service';
import { ImapGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service';
import { MicrosoftGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service';
import { MessageFolderName } from 'src/modules/messaging/message-import-manager/drivers/microsoft/types/folders';
type SyncMessageFoldersInput = {
workspaceId: string;
messageChannelId: string;
connectedAccount: MessageChannelWorkspaceEntity['connectedAccount'];
manager: WorkspaceEntityManager;
};
@Injectable()
export class SyncMessageFoldersService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly featureFlagService: FeatureFlagService,
private readonly gmailGetAllFoldersService: GmailGetAllFoldersService,
private readonly microsoftGetAllFoldersService: MicrosoftGetAllFoldersService,
private readonly imapGetAllFoldersService: ImapGetAllFoldersService,
) {}
async syncMessageFolders(input: SyncMessageFoldersInput): Promise<void> {
const { workspaceId, messageChannelId, connectedAccount, manager } = input;
const folders = await this.discoverAllFolders(connectedAccount);
await this.upsertDiscoveredFolders({
workspaceId,
messageChannelId,
folders,
manager,
});
}
private async upsertDiscoveredFolders({
workspaceId,
messageChannelId,
folders,
manager,
}: {
workspaceId: string;
messageChannelId: string;
folders: MessageFolder[];
manager: WorkspaceEntityManager;
}): Promise<void> {
const messageFolderRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageFolderWorkspaceEntity>(
workspaceId,
'messageFolder',
);
const existingFolderMap = await this.buildExistingFolderMap({
messageChannelId,
messageFolderRepository,
});
for (const folder of folders) {
const existingFolder = this.findExistingFolderInMap(
existingFolderMap,
folder,
);
if (existingFolder) {
await messageFolderRepository.update(
existingFolder.id,
{
name: folder.name,
isSynced: folder.isSynced,
isSentFolder: folder.isSentFolder,
externalId: folder.externalId,
},
manager,
);
} else {
await messageFolderRepository.save(
{
id: v4(),
messageChannelId,
name: folder.name,
syncCursor: '',
isSynced: folder.isSynced,
isSentFolder: folder.isSentFolder,
externalId: folder.externalId,
},
{},
manager,
);
}
}
}
async discoverAllFolders(
connectedAccount: MessageChannelWorkspaceEntity['connectedAccount'],
): Promise<MessageFolder[]> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return await this.gmailGetAllFoldersService.getAllMessageFolders(
connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return await this.microsoftGetAllFoldersService.getAllMessageFolders(
connectedAccount,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return await this.imapGetAllFoldersService.getAllMessageFolders(
connectedAccount,
);
default:
throw new Error(
`Provider ${connectedAccount.provider} is not supported`,
);
}
}
private async buildExistingFolderMap({
messageChannelId,
messageFolderRepository,
}: {
messageChannelId: string;
messageFolderRepository: WorkspaceRepository<MessageFolderWorkspaceEntity>;
}): Promise<Map<string, MessageFolderWorkspaceEntity>> {
const existingFolders = await messageFolderRepository.find({
where: { messageChannelId },
});
const existingFolderMap = new Map<string, MessageFolderWorkspaceEntity>();
for (const existingFolder of existingFolders) {
if (isDefined(existingFolder.externalId)) {
existingFolderMap.set(existingFolder.externalId, existingFolder);
}
existingFolderMap.set(existingFolder.name, existingFolder);
}
return existingFolderMap;
}
private findExistingFolderInMap(
existingFolderMap: Map<string, MessageFolderWorkspaceEntity>,
folder: MessageFolder,
): MessageFolderWorkspaceEntity | undefined {
if (isDefined(folder.externalId)) {
const existingFolder = existingFolderMap.get(folder.externalId);
if (existingFolder) {
return existingFolder;
}
}
const legacyFolderName = this.getLegacyFolderName(folder);
return existingFolderMap.get(legacyFolderName);
}
private getLegacyFolderName(folder: MessageFolder): string {
if (folder.isSynced && !folder.isSentFolder) {
return MessageFolderName.INBOX;
}
if (folder.isSynced && folder.isSentFolder) {
return MessageFolderName.SENT_ITEMS;
}
return folder.name;
}
}
@@ -12,13 +12,13 @@ import { EmailAliasManagerModule } from 'src/modules/connected-account/email-ali
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
import { GmailFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-fetch-by-batch.service';
import { GmailGetHistoryService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-history.service';
import { GmailGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service';
import { GmailGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service';
import { GmailHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-handle-error.service';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
@Module({
imports: [
@@ -49,6 +49,7 @@ import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manag
GmailGetMessageListService,
GmailClientProvider,
OAuth2ClientProvider,
GmailHandleErrorService,
],
})
export class MessagingGmailDriverModule {}
@@ -36,7 +36,10 @@ export class GmailGetMessageListService {
ConnectedAccountWorkspaceEntity,
'provider' | 'refreshToken' | 'id' | 'handle'
>,
messageFolders: Pick<MessageFolderWorkspaceEntity, 'name'>[],
messageFolders: Pick<
MessageFolderWorkspaceEntity,
'name' | 'externalId' | 'isSynced'
>[],
): Promise<GetMessageListsResponse> {
const gmailClient =
await this.gmailClientProvider.getGmailClient(connectedAccount);
@@ -45,7 +48,7 @@ export class GmailGetMessageListService {
let hasMoreMessages = true;
const messageExternalIds: string[] = [];
const excludedCategories = this.comptuteExcludedCategories(messageFolders);
const excludedCategories = this.computeExcludedCategories(messageFolders);
while (hasMoreMessages) {
const messageList = await gmailClient.users.messages
@@ -54,6 +57,7 @@ export class GmailGetMessageListService {
maxResults: MESSAGING_GMAIL_USERS_MESSAGES_LIST_MAX_RESULT,
pageToken,
q: computeGmailCategoryExcludeSearchFilter(excludedCategories),
labelIds: this.getCustomLabelIds(messageFolders),
})
.catch((error) => {
this.logger.error(
@@ -181,8 +185,8 @@ export class GmailGetMessageListService {
];
}
private comptuteExcludedCategories(
messageFolders: Pick<MessageFolderWorkspaceEntity, 'name'>[],
private computeExcludedCategories(
messageFolders: Pick<MessageFolderWorkspaceEntity, 'name' | 'externalId'>[],
) {
const includedDefaultCategories = messageFolders
.map((messageFolder) =>
@@ -199,11 +203,11 @@ export class GmailGetMessageListService {
private async getEmailIdsFromExcludedCategories(
gmailClient: gmailV1.Gmail,
lastSyncHistoryId: string,
messageFolders: Pick<MessageFolderWorkspaceEntity, 'name'>[],
messageFolders: Pick<MessageFolderWorkspaceEntity, 'name' | 'externalId'>[],
): Promise<string[]> {
const emailIds: string[] = [];
const excludedCategories = this.comptuteExcludedCategories(messageFolders);
const excludedCategories = this.computeExcludedCategories(messageFolders);
for (const category of excludedCategories) {
const { history } = await this.gmailGetHistoryService.getHistory(
@@ -225,4 +229,23 @@ export class GmailGetMessageListService {
return emailIds;
}
private getCustomLabelIds(
messageFolders: Pick<
MessageFolderWorkspaceEntity,
'name' | 'externalId' | 'isSynced'
>[],
): string[] | undefined {
const customLabelIds = messageFolders
.filter(
(folder) =>
folder.externalId &&
folder.isSynced &&
!mapGmailDefaultFolderToCategoryOrUndefined(folder.name),
)
.map((folder) => folder.externalId)
.filter((id): id is string => !!id);
return customLabelIds.length > 0 ? customLabelIds : undefined;
}
}
@@ -46,6 +46,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
ImapGetMessagesService,
ImapGetMessageListService,
ImapClientProvider,
ImapFindSentFolderService,
],
})
export class MessagingIMAPDriverModule {}
@@ -4,10 +4,8 @@ import { type ImapFlow } from 'imapflow';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { ImapFindSentFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service';
import { ImapHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-handle-error.service';
import { ImapIncrementalSyncService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-incremental-sync.service';
import { MessageFolderName } from 'src/modules/messaging/message-import-manager/drivers/imap/types/folders';
import { createSyncCursor } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/create-sync-cursor.util';
import { extractMailboxState } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/extract-mailbox-state.util';
import {
@@ -26,7 +24,6 @@ export class ImapGetMessageListService {
constructor(
private readonly imapClientProvider: ImapClientProvider,
private readonly imapFindSentFolderService: ImapFindSentFolderService,
private readonly imapIncrementalSyncService: ImapIncrementalSyncService,
private readonly imapHandleErrorService: ImapHandleErrorService,
) {}
@@ -43,19 +40,11 @@ export class ImapGetMessageListService {
for (const folder of messageFolders) {
this.logger.log(`Processing folder: ${folder.name}`);
const folderName = await this.getFolderName(client, folder.name);
if (!folderName) {
this.logger.warn(
`No IMAP folder found for message folder: ${folder.name}`,
);
continue;
}
try {
const response = await this.getMessageList(
client,
folderName,
folder.name,
folder,
);
@@ -65,7 +54,7 @@ export class ImapGetMessageListService {
});
} catch (error) {
this.logger.warn(
`Error fetching from folder ${folder.name} (${folderName}): ${error.message}. Continuing with other folders.`,
`Error fetching from folder ${folder.name}: ${error.message}. Continuing with other folders.`,
);
result.push({
@@ -130,30 +119,6 @@ export class ImapGetMessageListService {
};
}
private async getFolderName(
client: ImapFlow,
folderName: string,
): Promise<string | null> {
if (folderName === MessageFolderName.INBOX) {
return 'INBOX';
}
if (folderName === MessageFolderName.SENT_ITEMS) {
const sentFolder =
await this.imapFindSentFolderService.findSentFolder(client);
if (!sentFolder) {
this.logger.warn('SENT folder not found, skipping');
return null;
}
return sentFolder;
}
return folderName;
}
private async getMessagesFromFolder(
client: ImapFlow,
folder: string,
@@ -0,0 +1,450 @@
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-manager/drivers/utils/get-standard-folder-by-regex';
function testFolderMatches(
variants: string[],
expectedStandardFolder: StandardFolder,
) {
variants.forEach((variant) => {
const result = getStandardFolderByRegex(variant);
expect(result).toBe(expectedStandardFolder);
});
}
describe('getStandardFolderByRegex', () => {
describe('INBOX folder detection', () => {
it('matches English variants', () => {
const englishVariants = [
'Inbox',
'Mail',
'Messages',
'Message',
'Received',
];
testFolderMatches(englishVariants, StandardFolder.INBOX);
});
it('matches French variants', () => {
const frenchVariants = [
'Boîte de réception',
'Courrier entrant',
'Messages reçus',
'Réception',
];
testFolderMatches(frenchVariants, StandardFolder.INBOX);
});
it('matches German variants', () => {
const germanVariants = [
'Posteingang',
'Eingang',
'Eingangsmails',
'Empfangen',
];
testFolderMatches(germanVariants, StandardFolder.INBOX);
});
it('matches Spanish variants', () => {
const spanishVariants = [
'Bandeja de entrada',
'Entrada',
'Correo entrante',
'Recibidos',
];
testFolderMatches(spanishVariants, StandardFolder.INBOX);
});
it('matches Portuguese variants', () => {
const portugueseVariants = [
'Caixa de entrada',
'Entrada',
'Correio de entrada',
'Recebidos',
];
testFolderMatches(portugueseVariants, StandardFolder.INBOX);
});
it('matches Italian variants', () => {
const italianVariants = [
'Posta in arrivo',
'Arrivo',
'Casella postale',
'Ricevuti',
];
testFolderMatches(italianVariants, StandardFolder.INBOX);
});
it('matches Korean variants', () => {
const koreanVariants = ['받은편지함', '수신함', '받은메일'];
testFolderMatches(koreanVariants, StandardFolder.INBOX);
});
it('matches Japanese variants', () => {
const japaneseVariants = ['受信トレイ', '受信箱', '受信メール'];
testFolderMatches(japaneseVariants, StandardFolder.INBOX);
});
it('matches Polish variants', () => {
const polishVariants = [
'Odebrane',
'Skrzynka odbiorcza',
'Wiadomości przychodzące',
];
testFolderMatches(polishVariants, StandardFolder.INBOX);
});
it('matches Russian variants', () => {
const russianVariants = [
'Входящие',
'Папка входящих',
'Полученные сообщения',
'Полученные письма',
];
testFolderMatches(russianVariants, StandardFolder.INBOX);
});
it('matches Gmail special folder', () => {
const gmailVariants = ['[Gmail]/Inbox', '[Gmail]\\Inbox'];
testFolderMatches(gmailVariants, StandardFolder.INBOX);
});
});
describe('DRAFTS folder detection', () => {
it('matches English variants', () => {
const englishVariants = [
'Drafts',
'Draft',
'Draft Items',
'Draft Mail',
'Draft Messages',
];
testFolderMatches(englishVariants, StandardFolder.DRAFTS);
});
it('matches French variants', () => {
const frenchVariants = ['Brouillons', 'Éléments brouillons'];
testFolderMatches(frenchVariants, StandardFolder.DRAFTS);
});
it('matches German variants', () => {
const germanVariants = ['Entwürfe', 'Entwurf'];
testFolderMatches(germanVariants, StandardFolder.DRAFTS);
});
it('matches Spanish variants', () => {
const spanishVariants = ['Borradores', 'Elementos borrador'];
testFolderMatches(spanishVariants, StandardFolder.DRAFTS);
});
it('matches Portuguese variants', () => {
const portugueseVariants = ['Rascunhos', 'Itens rascunho'];
testFolderMatches(portugueseVariants, StandardFolder.DRAFTS);
});
it('matches Italian variants', () => {
const italianVariants = ['Bozze', 'Bozze salvate'];
testFolderMatches(italianVariants, StandardFolder.DRAFTS);
});
it('matches Korean variants', () => {
const koreanVariants = ['임시보관함', '초안'];
testFolderMatches(koreanVariants, StandardFolder.DRAFTS);
});
it('matches Japanese variants', () => {
const japaneseVariants = ['下書き', '草稿'];
testFolderMatches(japaneseVariants, StandardFolder.DRAFTS);
});
it('matches Polish variants', () => {
const polishVariants = ['Wersje robocze', 'Szkice'];
testFolderMatches(polishVariants, StandardFolder.DRAFTS);
});
it('matches Russian variants', () => {
const russianVariants = [
'Черновики',
'Черновые сообщения',
'Неотправленные',
];
testFolderMatches(russianVariants, StandardFolder.DRAFTS);
});
it('matches Gmail special folder', () => {
const gmailVariants = ['[Gmail]/Drafts', '[Gmail]\\Drafts'];
testFolderMatches(gmailVariants, StandardFolder.DRAFTS);
});
});
describe('SENT folder detection', () => {
it('matches English variants', () => {
const englishVariants = [
'Sent',
'Sent Items',
'Sent Mail',
'Sent Messages',
'sent-elements',
];
testFolderMatches(englishVariants, StandardFolder.SENT);
});
it('matches French variants', () => {
const frenchVariants = ['Envoyés', 'Éléments envoyés', 'Objets envoyés'];
testFolderMatches(frenchVariants, StandardFolder.SENT);
});
it('matches German variants', () => {
const germanVariants = ['Gesendet', 'Gesendete Elemente'];
testFolderMatches(germanVariants, StandardFolder.SENT);
});
it('matches Spanish variants', () => {
const spanishVariants = ['Enviados', 'Elementos enviados'];
testFolderMatches(spanishVariants, StandardFolder.SENT);
});
it('matches Portuguese variants', () => {
const portugueseVariants = ['Itens enviados'];
testFolderMatches(portugueseVariants, StandardFolder.SENT);
});
it('matches Italian variants', () => {
const italianVariants = ['Posta inviata', 'Inviati'];
testFolderMatches(italianVariants, StandardFolder.SENT);
});
it('matches Korean variant', () => {
const koreanVariants = ['보낸편지함'];
testFolderMatches(koreanVariants, StandardFolder.SENT);
});
it('matches Japanese variants', () => {
const japaneseVariants = ['送信済みメール', '送信済み'];
testFolderMatches(japaneseVariants, StandardFolder.SENT);
});
it('matches Polish variants', () => {
const polishVariants = ['Wysłane', 'Elementy wysłane'];
testFolderMatches(polishVariants, StandardFolder.SENT);
});
it('matches Russian variants', () => {
const russianVariants = [
'Отправленные',
'Отправленные письма',
'Отправленные сообщения',
'Исходящие',
];
testFolderMatches(russianVariants, StandardFolder.SENT);
});
it('matches Gmail special folder', () => {
const gmailVariants = ['[Gmail]/Sent Mail', '[Gmail]\\Sent Mail'];
testFolderMatches(gmailVariants, StandardFolder.SENT);
});
it('does not match unrelated folders', () => {
const unrelatedFolders = [
'Inbox',
'Drafts',
'Trash',
'Archive',
'Junk',
'Important',
'RandomFolder',
];
unrelatedFolders.forEach((folder) => {
const result = getStandardFolderByRegex(folder);
expect(result).not.toBe(StandardFolder.SENT);
});
});
});
describe('TRASH folder detection', () => {
it('matches English variants', () => {
const englishVariants = [
'Trash',
'Deleted Items',
'Deleted Messages',
'Bin',
'Recycle Bin',
];
testFolderMatches(englishVariants, StandardFolder.TRASH);
});
it('matches French variants', () => {
const frenchVariants = ['Corbeille', 'Supprimés', 'Éléments supprimés'];
testFolderMatches(frenchVariants, StandardFolder.TRASH);
});
it('matches German variants', () => {
const germanVariants = ['Gelöscht', 'Gelöschte Elemente', 'Papierkorb'];
testFolderMatches(germanVariants, StandardFolder.TRASH);
});
it('matches Spanish variants', () => {
const spanishVariants = [
'Papelera',
'Eliminados',
'Elementos eliminados',
];
testFolderMatches(spanishVariants, StandardFolder.TRASH);
});
it('matches Portuguese variants', () => {
const portugueseVariants = ['Lixeira', 'Itens excluídos'];
testFolderMatches(portugueseVariants, StandardFolder.TRASH);
});
it('matches Italian variants', () => {
const italianVariants = ['Cestino', 'Posta eliminata', 'Eliminati'];
testFolderMatches(italianVariants, StandardFolder.TRASH);
});
it('matches Korean variants', () => {
const koreanVariants = ['휴지통', '삭제된편지함'];
testFolderMatches(koreanVariants, StandardFolder.TRASH);
});
it('matches Japanese variants', () => {
const japaneseVariants = ['ごみ箱', '削除済み', '削除済みメール'];
testFolderMatches(japaneseVariants, StandardFolder.TRASH);
});
it('matches Polish variants', () => {
const polishVariants = ['Kosz', 'Usunięte', 'Elementy usunięte'];
testFolderMatches(polishVariants, StandardFolder.TRASH);
});
it('matches Russian variants', () => {
const russianVariants = ['Удалённые', 'Корзина', 'Удалённые сообщения'];
testFolderMatches(russianVariants, StandardFolder.TRASH);
});
it('matches Gmail special folder', () => {
const gmailVariants = ['[Gmail]/Trash', '[Gmail]\\Trash'];
testFolderMatches(gmailVariants, StandardFolder.TRASH);
});
});
describe('JUNK/SPAM folder detection', () => {
it('matches English variants', () => {
const englishVariants = [
'Spam',
'Junk Mail',
'Junk Messages',
'Bulk Mail',
'Bulk Messages',
];
testFolderMatches(englishVariants, StandardFolder.JUNK);
});
it('matches French variants', () => {
const frenchVariants = ['Indésirables', 'Courrier indésirable', 'Spam'];
testFolderMatches(frenchVariants, StandardFolder.JUNK);
});
it('matches German variants', () => {
const germanVariants = ['Spam', 'Junk Mail', 'Unerwünscht'];
testFolderMatches(germanVariants, StandardFolder.JUNK);
});
it('matches Spanish variants', () => {
const spanishVariants = ['Spam', 'Correo basura', 'No deseado'];
testFolderMatches(spanishVariants, StandardFolder.JUNK);
});
it('matches Portuguese variants', () => {
const portugueseVariants = ['Spam', 'Lixo eletrônico', 'Indesejados'];
testFolderMatches(portugueseVariants, StandardFolder.JUNK);
});
it('matches Italian variants', () => {
const italianVariants = ['Spam', 'Posta indesiderata', 'Indesiderata'];
testFolderMatches(italianVariants, StandardFolder.JUNK);
});
it('matches Korean variants', () => {
const koreanVariants = ['스팸', '정크메일'];
testFolderMatches(koreanVariants, StandardFolder.JUNK);
});
it('matches Japanese variants', () => {
const japaneseVariants = ['スパム', '迷惑メール'];
testFolderMatches(japaneseVariants, StandardFolder.JUNK);
});
it('matches Polish variants', () => {
const polishVariants = ['Spam', 'Niechciane', 'Śmieci'];
testFolderMatches(polishVariants, StandardFolder.JUNK);
});
it('matches Russian variants', () => {
const russianVariants = ['Спам', 'Нежелательные', 'Мусор'];
testFolderMatches(russianVariants, StandardFolder.JUNK);
});
it('matches Gmail special folder', () => {
const gmailVariants = ['[Gmail]/Spam', '[Gmail]\\Spam'];
testFolderMatches(gmailVariants, StandardFolder.JUNK);
});
});
});
@@ -1,50 +1,17 @@
import { type ListResponse } from 'imapflow';
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-manager/drivers/utils/get-standard-folder-by-regex';
export function getImapSentFolderCandidatesByRegex(
list: ListResponse[],
): string[] {
const sentFolderPattern = new RegExp(
[
// EN
'sent([\\s_-]?(items|mail|messages|elements))?',
// FR
'envoy[éê]s?',
'[ée]l[ée]ments[\\s_-]?envoy[éê]s',
// DE
'gesendet',
'gesendete[\\s_-]?elemente',
// ES
'enviados?',
'elementos[\\s_-]?enviados',
// PT
'itens[\\s_-]?enviados',
// IT
'posta[\\s_-]?inviata',
'inviati',
// KO
'보낸편지함',
// JA
'送信済みメール',
'送信済み',
// PL
'elementy[\\s_-]?wysłane',
'wysłane',
// RU
'отправленные',
'отправленные[\\s_-]?(сообщения|письма)?',
'исходящие',
// GMAIL
'\\[gmail\\][\\/]+sent[\\s_-]?mail',
]
.map((s) => `(${s})`)
.join('|'),
'i',
);
const regexCandidateFolders = [];
const regexCandidateFolders: string[] = [];
for (const folder of list) {
if (sentFolderPattern.test(folder.path)) {
const standardFolder = getStandardFolderByRegex(folder.path);
if (standardFolder === StandardFolder.SENT) {
regexCandidateFolders.push(folder.path);
}
}
@@ -27,6 +27,7 @@ import { MicrosoftGetMessageListService } from './services/microsoft-get-message
MicrosoftClientProvider,
MicrosoftGetMessageListService,
MicrosoftGetMessagesService,
MicrosoftFetchByBatchService,
MicrosoftHandleErrorService,
MicrosoftOAuth2ClientManagerService,
@@ -35,6 +36,7 @@ import { MicrosoftGetMessageListService } from './services/microsoft-get-message
MicrosoftGetMessageListService,
MicrosoftClientProvider,
MicrosoftGetMessagesService,
MicrosoftHandleErrorService,
],
})
export class MessagingMicrosoftDriverModule {}
@@ -66,6 +66,9 @@ xdescribe('Microsoft dev tests : get message list service', () => {
id: 'inbox-folder-id',
name: MessageFolderName.INBOX,
syncCursor: 'inbox-sync-cursor',
isSynced: false,
isSentFolder: false,
externalId: null,
},
],
});
@@ -91,6 +94,9 @@ xdescribe('Microsoft dev tests : get message list service', () => {
id: 'inbox-folder-id',
name: MessageFolderName.INBOX,
syncCursor: 'inbox-sync-cursor',
isSynced: false,
isSentFolder: false,
externalId: null,
},
],
}),
@@ -107,6 +113,9 @@ xdescribe('Microsoft dev tests : get message list service', () => {
id: 'inbox-folder-id',
name: MessageFolderName.INBOX,
syncCursor: syncCursor,
isSynced: false,
isSentFolder: false,
externalId: null,
},
],
});
@@ -127,6 +136,9 @@ xdescribe('Microsoft dev tests : get message list service', () => {
id: 'inbox-folder-id',
name: MessageFolderName.INBOX,
syncCursor: 'invalid-syncCursor',
isSynced: false,
isSentFolder: false,
externalId: null,
},
],
}),
@@ -6,9 +6,7 @@ import {
type PageIteratorCallback,
} from '@microsoft/microsoft-graph-client';
import { isNonEmptyString } from '@sniptt/guards';
import { v4 } from 'uuid';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import {
@@ -17,7 +15,6 @@ import {
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { MicrosoftHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-handle-error.service';
import { MessageFolderName } from 'src/modules/messaging/message-import-manager/drivers/microsoft/types/folders';
import { isAccessTokenRefreshingError } from 'src/modules/messaging/message-import-manager/drivers/microsoft/utils/is-access-token-refreshing-error.utils';
import { type GetMessageListsArgs } from 'src/modules/messaging/message-import-manager/types/get-message-lists-args.type';
import {
@@ -34,7 +31,6 @@ export class MicrosoftGetMessageListService {
constructor(
private readonly microsoftClientProvider: MicrosoftClientProvider,
private readonly microsoftHandleErrorService: MicrosoftHandleErrorService,
private readonly twentyORMManager: TwentyORMManager,
) {}
public async getMessageLists({
@@ -45,46 +41,10 @@ export class MicrosoftGetMessageListService {
const result: GetMessageListsResponse = [];
if (messageFolders.length === 0) {
// permanent solution:
// throw new MessageImportDriverException(
// `Message channel ${messageChannel.id} has no message folders`,
// MessageImportDriverExceptionCode.NOT_FOUND,
// );
// temporary solution: TODO: remove this once we have a permanent solution
// if no folders exist, most probably a first time sync for microsoft
// so we create the folders INBOX and SENTITEMS
// and fill the INBOX with the previous sync cursor
// and for sentitms, we do the full message list fetch
// console.warn(
// `Message channel ${messageChannel.id} has no message folders, most probably a first time`,
// );
const messageFolderRepository =
await this.twentyORMManager.getRepository<MessageFolderWorkspaceEntity>(
'messageFolder',
);
const newFolder = await messageFolderRepository.save({
id: v4(),
messageChannelId: messageChannel.id,
name: MessageFolderName.INBOX,
syncCursor: messageChannel.syncCursor,
});
const response = await this.getMessageList(connectedAccount, {
name: MessageFolderName.INBOX,
syncCursor: messageChannel.syncCursor,
});
result.push({
...response,
folderId: newFolder.id,
});
// we are ok with not synchronizing the legacy connected microsoft accounts.
// so we return an empty array.
return result;
throw new MessageImportDriverException(
`Message channel ${messageChannel.id} has no message folders`,
MessageImportDriverExceptionCode.NOT_FOUND,
);
}
for (const folder of messageFolders) {
@@ -104,7 +64,10 @@ export class MicrosoftGetMessageListService {
ConnectedAccountWorkspaceEntity,
'provider' | 'refreshToken' | 'id'
>,
messageFolder: Pick<MessageFolderWorkspaceEntity, 'name' | 'syncCursor'>,
messageFolder: Pick<
MessageFolderWorkspaceEntity,
'name' | 'syncCursor' | 'externalId'
>,
): Promise<GetOneMessageListResponse> {
const messageExternalIds: string[] = [];
const messageExternalIdsToDelete: string[] = [];
@@ -112,9 +75,10 @@ export class MicrosoftGetMessageListService {
const microsoftClient =
await this.microsoftClientProvider.getMicrosoftClient(connectedAccount);
const folderId = messageFolder.externalId || messageFolder.name;
const apiUrl = isNonEmptyString(messageFolder.syncCursor)
? messageFolder.syncCursor
: `/me/mailfolders/${messageFolder.name}/messages/delta?$select=id`;
: `/me/mailfolders/${folderId}/messages/delta?$select=id`;
const response: PageCollection = await microsoftClient
.api(apiUrl)
@@ -0,0 +1,7 @@
export enum StandardFolder {
INBOX = 'inbox',
DRAFTS = 'drafts',
SENT = 'sent',
TRASH = 'trash',
JUNK = 'junk',
}
@@ -0,0 +1,225 @@
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
const FOLDER_REGEX_PATTERNS: Record<StandardFolder, string[]> = {
[StandardFolder.INBOX]: [
// EN
'inbox',
'^mail$',
'^messages?$',
'received',
// FR
'boîte[\\s_-]?de[\\s_-]?réception',
'courrier[\\s_-]?entrant',
'messages[\\s_-]?reçus',
'réception',
// DE
'posteingang',
'eingang',
'eingangsmails?',
'empfangen',
// ES
'bandeja[\\s_-]?de[\\s_-]?entrada',
'entrada',
'correo[\\s_-]?entrante',
'recibidos?',
// PT
'caixa[\\s_-]?de[\\s_-]?entrada',
'entrada',
'correio[\\s_-]?de[\\s_-]?entrada',
'recebidos?',
// IT
'posta[\\s_-]?in[\\s_-]?arrivo',
'arrivo',
'casella[\\s_-]?postale',
'ricevuti',
// KO
'받은편지함',
'수신함',
'받은메일',
// JA
'受信トレイ',
'受信箱',
'受信メール',
// PL
'odebrane',
'skrzynka[\\s_-]?odbiorcza',
'wiadomości[\\s_-]?przychodzące',
// RU
'входящие',
'папка[\\s_-]?входящих',
'полученные[\\s_-]?(сообщения|письма)?',
// GMAIL
'\\[gmail\\][\\/]+inbox',
],
[StandardFolder.DRAFTS]: [
// EN
'drafts?',
'draft[\\s_-]?(items|mail|messages|elements)?',
// FR
'brouillons?',
'[ée]l[ée]ments[\\s_-]?brouillons?',
// DE
'entwürfe',
'entwurf',
// ES
'borradores?',
'elementos[\\s_-]?borrador',
// PT
'rascunhos?',
'itens[\\s_-]?rascunho',
// IT
'bozze',
'bozze[\\s_-]?salvate',
// KO
'임시보관함',
'초안',
// JA
'下書き',
'草稿',
// PL
'wersje[\\s_-]?robocze',
'szkice',
// RU
'черновики',
'черновые[\\s_-]?(сообщения|письма)?',
'неотправленные',
// GMAIL
'\\[gmail\\][\\/]+drafts',
],
[StandardFolder.SENT]: [
// EN
'sent([\\s_-]?(items|mail|messages|elements))?',
// FR
'envoy[éê]s?',
'[ée]l[ée]ments[\\s_-]?envoy[éê]s',
// DE
'gesendet',
'gesendete[\\s_-]?elemente',
// ES
'enviados?',
'elementos[\\s_-]?enviados',
// PT
'itens[\\s_-]?enviados',
// IT
'posta[\\s_-]?inviata',
'inviati',
// KO
'보낸편지함',
// JA
'送信済みメール',
'送信済み',
// PL
'elementy[\\s_-]?wysłane',
'wysłane',
// RU
'отправленные',
'отправленные[\\s_-]?(сообщения|письма)?',
'исходящие',
// GMAIL
'\\[gmail\\][\\/]+sent[\\s_-]?mail',
],
[StandardFolder.TRASH]: [
// EN
'trash',
'deleted[\\s_-]?(items|messages|mail)?',
'bin',
'recycle[\\s_-]?bin',
// FR
'corbeille',
'supprim[ée]s',
'[ée]l[ée]ments[\\s_-]?supprim[ée]s',
// DE
'gelöscht',
'gelöschte[\\s_-]?elemente',
'papierkorb',
// ES
'papelera',
'eliminados?',
'elementos[\\s_-]?eliminados',
// PT
'lixeira',
'itens[\\s_-]?excluídos',
// IT
'cestino',
'posta[\\s_-]?eliminata',
'eliminati',
// KO
'휴지통',
'삭제된편지함',
// JA
'ごみ箱',
'削除済み',
'削除済みメール',
// PL
'kosz',
'usunięte',
'elementy[\\s_-]?usunięte',
// RU
'удалённые',
'корзина',
'удалённые[\\s_-]?(сообщения|письма)?',
// GMAIL
'\\[gmail\\][\\/]+trash',
],
[StandardFolder.JUNK]: [
// EN
'spam',
'junk[\\s_-]?(mail|messages|email)?',
'bulk[\\s_-]?(mail|messages)?',
// FR
'indésirables',
'courrier[\\s_-]?indésirable',
'spam',
// DE
'spam',
'junk[\\s_-]?mail',
'unerwünscht',
// ES
'spam',
'correo[\\s_-]?basura',
'no[\\s_-]?deseado',
// PT
'spam',
'lixo[\\s_-]?eletrônico',
'indesejados',
// IT
'spam',
'posta[\\s_-]?indesiderata',
'indesiderata',
// KO
'스팸',
'정크메일',
// JA
'スパム',
'迷惑メール',
// PL
'spam',
'niechciane',
'śmieci',
// RU
'спам',
'нежелательные',
'мусор',
// GMAIL
'\\[gmail\\][\\/]+spam',
],
};
const CACHED_REGEX_PATTERNS = Object.fromEntries(
Object.entries(FOLDER_REGEX_PATTERNS).map(([standardFolder, patterns]) => [
standardFolder,
new RegExp(patterns.map((s) => `(${s})`).join('|'), 'i'),
]),
);
export function getStandardFolderByRegex(
folderName: string,
): StandardFolder | null {
for (const [standardFolder, regex] of Object.entries(CACHED_REGEX_PATTERNS)) {
if (regex.test(folderName)) {
return standardFolder as StandardFolder;
}
}
return null;
}
@@ -39,6 +39,7 @@ import { MessagingMessagesImportService } from 'src/modules/messaging/message-im
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
@Module({
imports: [
@@ -57,6 +58,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
EmailAliasManagerModule,
FeatureFlagModule,
MessageParticipantManagerModule,
MessagingFolderSyncManagerModule,
MessagingMonitoringModule,
MessagingMessageCleanerModule,
WorkspaceEventEmitterModule,
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { GmailGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service';
import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service';
import { MicrosoftGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service';
@@ -12,6 +13,11 @@ import {
} from 'src/modules/messaging/message-import-manager/exceptions/message-import.exception';
import { type GetMessageListsResponse } from 'src/modules/messaging/message-import-manager/types/get-message-lists-response.type';
type MessageFolder = Pick<
MessageFolderWorkspaceEntity,
'name' | 'isSynced' | 'isSentFolder' | 'externalId' | 'syncCursor' | 'id'
>;
@Injectable()
export class MessagingGetMessageListService {
constructor(
@@ -22,25 +28,26 @@ export class MessagingGetMessageListService {
public async getMessageLists(
messageChannel: MessageChannelWorkspaceEntity,
messageFoldersToSync: MessageFolder[],
): Promise<GetMessageListsResponse> {
switch (messageChannel.connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return await this.gmailGetMessageListService.getMessageLists({
messageChannel,
connectedAccount: messageChannel.connectedAccount,
messageFolders: messageChannel.messageFolders,
messageFolders: messageFoldersToSync,
});
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftGetMessageListService.getMessageLists({
messageChannel,
connectedAccount: messageChannel.connectedAccount,
messageFolders: messageChannel.messageFolders,
messageFolders: messageFoldersToSync,
});
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
return await this.imapGetMessageListService.getMessageLists({
messageChannel,
connectedAccount: messageChannel.connectedAccount,
messageFolders: messageChannel.messageFolders,
messageFolders: messageFoldersToSync,
});
}
default:
@@ -9,6 +9,7 @@ import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/se
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { MessagingCursorService } from 'src/modules/messaging/message-import-manager/services/messaging-cursor.service';
import { MessagingGetMessageListService } from 'src/modules/messaging/message-import-manager/services/messaging-get-message-list.service';
@@ -75,6 +76,18 @@ describe('MessagingMessageListFetchService', () => {
delete: jest.fn().mockResolvedValue(undefined),
};
const mockMessageFolderRepository = {
find: jest.fn().mockResolvedValue([
{
id: 'inbox-folder-id',
name: 'inbox',
syncCursor: 'inbox-sync-cursor',
messageChannelId: 'microsoft-message-channel-id',
isSynced: true,
},
]),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagingMessageListFetchService,
@@ -88,41 +101,40 @@ describe('MessagingMessageListFetchService', () => {
{
provide: MessagingGetMessageListService,
useValue: {
getMessageLists: jest
.fn()
.mockImplementation(({ connectedAccount }) => {
if (
connectedAccount.provider === ConnectedAccountProvider.GOOGLE
) {
return [
{
messageExternalIds: [
'external-id-existing-message-1',
'external-id-google-message-1',
'external-id-google-message-2',
],
nextSyncCursor: 'new-google-history-id',
folderId: undefined,
messageExternalIdsToDelete: [],
previousSyncCursor: 'google-sync-cursor',
},
];
} else {
return [
{
messageExternalIds: [
'external-id-existing-message-1',
'external-id-new-message-1',
'external-id-new-message-2',
],
nextSyncCursor: 'new-sync-cursor',
folderId: 'inbox-folder-id',
messageExternalIdsToDelete: [],
previousSyncCursor: 'inbox-sync-cursor',
},
];
}
}),
getMessageLists: jest.fn().mockImplementation((messageChannel) => {
if (
messageChannel.connectedAccount.provider ===
ConnectedAccountProvider.GOOGLE
) {
return [
{
messageExternalIds: [
'external-id-existing-message-1',
'external-id-google-message-1',
'external-id-google-message-2',
],
nextSyncCursor: 'new-google-history-id',
folderId: undefined,
messageExternalIdsToDelete: [],
previousSyncCursor: 'google-sync-cursor',
},
];
} else {
return [
{
messageExternalIds: [
'external-id-existing-message-1',
'external-id-new-message-1',
'external-id-new-message-2',
],
nextSyncCursor: 'new-sync-cursor',
folderId: 'inbox-folder-id',
messageExternalIdsToDelete: [],
previousSyncCursor: 'inbox-sync-cursor',
},
];
}
}),
},
},
{
@@ -175,11 +187,17 @@ describe('MessagingMessageListFetchService', () => {
{
provide: TwentyORMManager,
useValue: {
getRepository: jest
.fn()
.mockResolvedValue(
mockMessageChannelMessageAssociationRepository,
),
getDatasource: jest.fn().mockResolvedValue({
manager: {},
}),
getRepository: jest.fn().mockImplementation((name) => {
if (name === 'messageChannelMessageAssociation') {
return mockMessageChannelMessageAssociationRepository;
}
if (name === 'messageFolder') {
return mockMessageFolderRepository;
}
}),
},
},
{
@@ -207,6 +225,12 @@ describe('MessagingMessageListFetchService', () => {
cleanWorkspaceThreads: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: SyncMessageFoldersService,
useValue: {
syncMessageFolders: jest.fn().mockResolvedValue(undefined),
},
},
],
}).compile();
@@ -258,6 +282,15 @@ describe('MessagingMessageListFetchService', () => {
refreshToken: 'new-microsoft-refresh-token',
},
},
[
{
id: 'inbox-folder-id',
name: 'inbox',
syncCursor: 'inbox-sync-cursor',
messageChannelId: 'microsoft-message-channel-id',
isSynced: true,
},
],
);
expect(twentyORMManager.getRepository).toHaveBeenCalledWith(
@@ -308,6 +341,15 @@ describe('MessagingMessageListFetchService', () => {
refreshToken: 'new-google-refresh-token',
},
},
[
{
id: 'inbox-folder-id',
name: 'inbox',
syncCursor: 'inbox-sync-cursor',
messageChannelId: 'microsoft-message-channel-id',
isSynced: true,
},
],
);
expect(twentyORMManager.getRepository).toHaveBeenCalledWith(
@@ -13,9 +13,11 @@ import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/se
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import {
MessageChannelSyncStage,
type MessageChannelWorkspaceEntity,
MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { MessagingCursorService } from 'src/modules/messaging/message-import-manager/services/messaging-cursor.service';
import { MessagingGetMessageListService } from 'src/modules/messaging/message-import-manager/services/messaging-get-message-list.service';
@@ -41,6 +43,7 @@ export class MessagingMessageListFetchService {
private readonly messagingCursorService: MessagingCursorService,
private readonly messagingMessagesImportService: MessagingMessagesImportService,
private readonly messagingAccountAuthenticationService: MessagingAccountAuthenticationService,
private readonly syncMessageFoldersService: SyncMessageFoldersService,
) {}
public async processMessageListFetch(
@@ -74,9 +77,31 @@ export class MessagingMessageListFetchService {
},
};
const datasource = await this.twentyORMManager.getDatasource();
await this.syncMessageFoldersService.syncMessageFolders({
workspaceId,
messageChannelId: messageChannelWithFreshTokens.id,
connectedAccount: messageChannelWithFreshTokens.connectedAccount,
manager: datasource.manager,
});
const messageFolderRepository =
await this.twentyORMManager.getRepository<MessageFolderWorkspaceEntity>(
'messageFolder',
);
const messageFoldersToSync = await messageFolderRepository.find({
where: {
messageChannelId: messageChannel.id,
isSynced: true,
},
});
const messageLists =
await this.messagingGetMessageListService.getMessageLists(
messageChannelWithFreshTokens,
messageFoldersToSync,
);
await this.cacheStorage.del(
@@ -10,6 +10,6 @@ export type GetMessageListsArgs = {
>;
messageFolders: Pick<
MessageFolderWorkspaceEntity,
'name' | 'syncCursor' | 'id'
'name' | 'syncCursor' | 'id' | 'isSynced' | 'isSentFolder' | 'externalId'
>[];
};