feat(messaging): add workspace toggle to sync internal emails (#20457)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
neo773
2026-05-12 01:12:51 +05:30
committed by GitHub
parent 75c22a2119
commit b03f044d0f
27 changed files with 235 additions and 12 deletions
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@@ -14,7 +15,11 @@ import { CreatePersonService } from 'src/modules/contact-creation-manager/servic
@Module({
imports: [
WorkspaceDataSourceModule,
TypeOrmModule.forFeature([FeatureFlagEntity, UserWorkspaceEntity]),
TypeOrmModule.forFeature([
FeatureFlagEntity,
UserWorkspaceEntity,
WorkspaceEntity,
]),
TypeOrmModule.forFeature([ObjectMetadataEntity, FieldMetadataEntity]),
SecureHttpClientModule,
],
@@ -5,6 +5,7 @@ import { FieldActorSource } from 'twenty-shared/types';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { CreateCompanyAndPersonService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service';
@@ -56,6 +57,12 @@ describe('CreateCompanyAndPersonService', () => {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
@@ -14,6 +14,7 @@ import { v4 } from 'uuid';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
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';
@@ -40,6 +41,8 @@ export class CreateCompanyAndPersonService {
private readonly exceptionHandlerService: ExceptionHandlerService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
async createCompaniesAndPeople(
@@ -75,11 +78,17 @@ export class CreateCompanyAndPersonService {
const workspaceMembers = await workspaceMemberRepository.find();
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
select: ['id', 'isInternalMessagesImportEnabled'],
});
const peopleToCreateFromOtherCompanies =
filterOutContactsThatBelongToSelfOrWorkspaceMembers(
contactsToCreate,
connectedAccount,
workspaceMembers,
workspace?.isInternalMessagesImportEnabled ?? false,
);
const { uniqueContacts, uniqueHandles } = getUniqueContactsAndHandles(
@@ -0,0 +1,56 @@
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type Contact } from 'src/modules/contact-creation-manager/types/contact.type';
import { filterOutContactsThatBelongToSelfOrWorkspaceMembers } from 'src/modules/contact-creation-manager/utils/filter-out-contacts-that-belong-to-self-or-workspace-members.util';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
const account = (
handle: string,
handleAliases: string[] = [],
): ConnectedAccountEntity =>
({ handle, handleAliases }) as ConnectedAccountEntity;
const contact = (handle: string): Contact => ({ handle, displayName: handle });
describe('filterOutContactsThatBelongToSelfOrWorkspaceMembers', () => {
it('drops same-domain contacts by default for work domains', () => {
const contacts = [contact('alice@acme.com'), contact('bob@other.com')];
const result = filterOutContactsThatBelongToSelfOrWorkspaceMembers(
contacts,
account('me@acme.com'),
[],
);
expect(result).toEqual([contact('bob@other.com')]);
});
it('keeps same-domain contacts when isInternalMessagesImportEnabled is true', () => {
const contacts = [contact('alice@acme.com'), contact('bob@other.com')];
const result = filterOutContactsThatBelongToSelfOrWorkspaceMembers(
contacts,
account('me@acme.com'),
[],
true,
);
expect(result).toEqual(contacts);
});
it('still drops workspace members and self even when flag is true', () => {
const contacts = [
contact('alice@acme.com'),
contact('me@acme.com'),
contact('member@acme.com'),
];
const workspaceMembers = [
{ userEmail: 'member@acme.com' } as WorkspaceMemberWorkspaceEntity,
];
const result = filterOutContactsThatBelongToSelfOrWorkspaceMembers(
contacts,
account('me@acme.com'),
workspaceMembers,
true,
);
expect(result).toEqual([contact('alice@acme.com')]);
});
});
@@ -10,6 +10,7 @@ export function filterOutContactsThatBelongToSelfOrWorkspaceMembers(
contacts: Contact[],
connectedAccount: ConnectedAccountEntity,
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
isInternalMessagesImportEnabled: boolean = false,
): Contact[] {
if (!isDefined(connectedAccount.handle)) {
throw new Error('Connected account handle is missing');
@@ -41,7 +42,8 @@ export function filterOutContactsThatBelongToSelfOrWorkspaceMembers(
return contacts.filter(
(contact) =>
(isDifferentDomain(contact, selfDomainName) ||
!isWorkDomain(selfDomainName)) &&
!isWorkDomain(selfDomainName) ||
isInternalMessagesImportEnabled) &&
// @ts-expect-error legacy noImplicitAny
!workspaceMembersMap[contact.handle.toLowerCase()] &&
!allHandles.includes(contact.handle.toLowerCase()),
@@ -26,6 +26,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { MessagingMonitoringService } from 'src/modules/messaging/monitoring/services/messaging-monitoring.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
describe('MessagingMessagesImportService', () => {
let service: MessagingMessagesImportService;
@@ -183,6 +184,14 @@ describe('MessagingMessagesImportService', () => {
findOne: jest.fn().mockResolvedValue({ userId: 'user-id' }),
},
},
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: {
findOne: jest
.fn()
.mockResolvedValue({ isInternalMessagesImportEnabled: false }),
},
},
{
provide: TwentyConfigService,
useValue: {
@@ -9,6 +9,7 @@ import { CacheStorageService } from 'src/engine/core-modules/cache-storage/servi
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
@@ -55,6 +56,8 @@ export class MessagingMessagesImportService {
private readonly messagingAccountAuthenticationService: MessagingAccountAuthenticationService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly twentyConfigService: TwentyConfigService,
) {}
@@ -197,6 +200,11 @@ export class MessagingMessagesImportService {
);
}
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
select: ['id', 'isInternalMessagesImportEnabled'],
});
const messagesToSave = filterEmails(
messageChannel.handle,
[...connectedAccountWithFreshTokens.handleAliases],
@@ -205,6 +213,7 @@ export class MessagingMessagesImportService {
.map((blocklistItem) => blocklistItem.handle)
.filter(isDefined),
messageChannel.excludeGroupEmails,
workspace?.isInternalMessagesImportEnabled ?? false,
);
if (messagesToSave.length > 0) {
@@ -37,6 +37,27 @@ describe('filterEmails', () => {
expect(filteredMessages).toEqual([]);
});
it('Should keep same-domain emails when isInternalMessagesImportEnabled is true', () => {
// Workspace opted into syncing internal emails (e.g. university or
// shared-domain institution). Same-domain participants must not be
// dropped — the toggle bypasses filterOutInternals.
const primaryHandle = 'guillim@acme.com';
const messages = messagingGetMessagesServiceGetMessages.filter(
(message) => message.externalId === 'AA-work-emails-internal',
);
const filteredMessages = filterEmails(
primaryHandle,
[],
messages,
[],
true,
true,
);
expect(filteredMessages).toEqual(messages);
});
it('Should filter messages with participant from the blocklist', () => {
const primaryHandle = 'guillim@acme.com';
const messages = messagingGetMessagesServiceGetMessages.filter(
@@ -15,6 +15,7 @@ export const filterEmails = (
messages: MessageWithParticipants[],
blocklist: string[],
excludeGroupEmails: boolean = true,
isInternalMessagesImportEnabled: boolean = false,
) => {
const messagesWithoutIcsAttachments = filterOutIcsAttachments(messages);
@@ -24,7 +25,10 @@ export const filterEmails = (
blocklist,
);
const messagesWithoutInternals = isWorkEmail(primaryHandle)
const shouldFilterOutInternals =
isWorkEmail(primaryHandle) && !isInternalMessagesImportEnabled;
const messagesWithoutInternals = shouldFilterOutInternals
? filterOutInternals(primaryHandle, messagesWithoutBlocklisted)
: messagesWithoutBlocklisted;