From 9747e3a7a3e7b6b2d1596d8f398007dcf66842b2 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:58:51 +0530 Subject: [PATCH] feat(messaging): link emails by Reply-To as a REPLY_TO participant (#22216) Relay senders (e.g. a website form sending as a shared address with the real contact in Reply-To) never linked to the contact because matching only used From/To/Cc/Bcc. Record Reply-To addresses under a new REPLY_TO participant role across the Gmail, Microsoft and IMAP drivers, excluding any that just repeat the sender. Adds the REPLY_TO option to the messageParticipant role field and a 2.17 workspace command to backfill it for existing workspaces. QAed with real test run Review in cubic --- .../2-17-upgrade-version-command.module.ts | 2 + ...message-participant-role-option.command.ts | 93 +++++++++++++++ ...t-role-option-sync-operations.util.spec.ts | 107 ++++++++++++++++++ ...cipant-role-option-sync-operations.util.ts | 64 +++++++++++ ...ipant-standard-flat-field-metadata.util.ts | 7 ++ .../parse-and-format-gmail-message.util.ts | 3 + .../gmail/utils/parse-gmail-message.util.ts | 2 + .../microsoft-get-messages.service.ts | 8 ++ .../build-reply-to-participants.util.spec.ts | 43 +++++++ .../utils/build-reply-to-participants.util.ts | 21 ++++ ...act-participants-from-parsed-email.util.ts | 17 ++- .../src/types/MessageParticipantRole.ts | 1 + 12 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-workspace-command-1801000001000-add-reply-to-message-participant-role-option.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-17/utils/__tests__/build-reply-to-message-participant-role-option-sync-operations.util.spec.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-17/utils/build-reply-to-message-participant-role-option-sync-operations.util.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/build-reply-to-participants.util.spec.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/utils/build-reply-to-participants.util.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-upgrade-version-command.module.ts index 16a103ca87..d4567db855 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-upgrade-version-command.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { AddReplyToMessageParticipantRoleOptionCommand } from 'src/database/commands/upgrade-version-command/2-17/2-17-workspace-command-1801000001000-add-reply-to-message-participant-role-option.command'; import { SyncCallRecordingNavigationCommandMenuItemAvailabilityExpressionCommand } from 'src/database/commands/upgrade-version-command/2-17/2-17-workspace-command-1801000000000-sync-call-recording-navigation-command-menu-item-availability-expression.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -15,6 +16,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace ], providers: [ SyncCallRecordingNavigationCommandMenuItemAvailabilityExpressionCommand, + AddReplyToMessageParticipantRoleOptionCommand, ], }) export class V2_17_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-workspace-command-1801000001000-add-reply-to-message-participant-role-option.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-workspace-command-1801000001000-add-reply-to-message-participant-role-option.command.ts new file mode 100644 index 0000000000..c5563e64d3 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/2-17-workspace-command-1801000001000-add-reply-to-message-participant-role-option.command.ts @@ -0,0 +1,93 @@ +import { Command } from 'nest-commander'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { buildReplyToMessageParticipantRoleOptionSyncOperations } from 'src/database/commands/upgrade-version-command/2-17/utils/build-reply-to-message-participant-role-option-sync-operations.util'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +@RegisteredWorkspaceCommand('2.17.0', 1801000001000) +@Command({ + name: 'upgrade:2-17:add-reply-to-message-participant-role-option', + description: + 'Add the Reply To option to the messageParticipant role field in existing workspaces', +}) +export class AddReplyToMessageParticipantRoleOptionCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const { flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + ]); + + const fieldMetadataOperations = + buildReplyToMessageParticipantRoleOptionSyncOperations({ + existingFlatFieldMetadataMaps: flatFieldMetadataMaps, + now: new Date().toISOString(), + }); + + if (fieldMetadataOperations.flatEntityToUpdate.length === 0) { + this.logger.log( + `messageParticipant role REPLY_TO option already present for workspace ${workspaceId}, skipping`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Adding REPLY_TO option to messageParticipant role field for workspace ${workspaceId}`, + ); + + if (isDryRun) { + return; + } + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + isSystemBuild: true, + workspaceId, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + allFlatEntityOperationByMetadataName: { + fieldMetadata: fieldMetadataOperations, + }, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to add REPLY_TO option to messageParticipant role field:\n${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + + throw new Error( + `Failed to add REPLY_TO option to messageParticipant role field for workspace ${workspaceId}`, + ); + } + + this.logger.log( + `Successfully added REPLY_TO option to messageParticipant role field for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/utils/__tests__/build-reply-to-message-participant-role-option-sync-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/utils/__tests__/build-reply-to-message-participant-role-option-sync-operations.util.spec.ts new file mode 100644 index 0000000000..ee51d05115 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/utils/__tests__/build-reply-to-message-participant-role-option-sync-operations.util.spec.ts @@ -0,0 +1,107 @@ +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { + type FieldMetadataComplexOption, + FieldMetadataType, + MessageParticipantRole, +} from 'twenty-shared/types'; + +import { + buildReplyToMessageParticipantRoleOptionSyncOperations, + REPLY_TO_MESSAGE_PARTICIPANT_ROLE_OPTION, +} from 'src/database/commands/upgrade-version-command/2-17/utils/build-reply-to-message-participant-role-option-sync-operations.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +const ROLE_FIELD_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.messageParticipant.fields.role.universalIdentifier; +const NOW = '2026-06-26T00:00:00.000Z'; + +const buildRoleOption = ( + value: MessageParticipantRole, + position: number, +): FieldMetadataComplexOption => ({ + id: `option-${value}`, + value, + label: value, + position, + color: 'gray', +}); + +const buildFlatFieldMetadataMaps = ( + flatFieldMetadatas: FlatFieldMetadata[], +): FlatEntityMaps => ({ + byUniversalIdentifier: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.universalIdentifier, + flatFieldMetadata, + ]), + ), + universalIdentifierById: Object.fromEntries( + flatFieldMetadatas.map((flatFieldMetadata) => [ + flatFieldMetadata.id, + flatFieldMetadata.universalIdentifier, + ]), + ), + universalIdentifiersByApplicationId: {}, +}); + +const buildRoleField = (options: FieldMetadataComplexOption[]) => + getFlatFieldMetadataMock({ + universalIdentifier: ROLE_FIELD_UNIVERSAL_IDENTIFIER, + objectMetadataId: 'message-participant-object-id', + type: FieldMetadataType.SELECT, + options, + }); + +describe('buildReplyToMessageParticipantRoleOptionSyncOperations', () => { + it('appends the Reply To option to the existing role options without touching them', () => { + const roleField = buildRoleField([ + buildRoleOption(MessageParticipantRole.FROM, 0), + buildRoleOption(MessageParticipantRole.TO, 1), + buildRoleOption(MessageParticipantRole.CC, 2), + buildRoleOption(MessageParticipantRole.BCC, 3), + ]); + + const { flatEntityToUpdate } = + buildReplyToMessageParticipantRoleOptionSyncOperations({ + existingFlatFieldMetadataMaps: buildFlatFieldMetadataMaps([roleField]), + now: NOW, + }); + + expect(flatEntityToUpdate).toHaveLength(1); + expect(flatEntityToUpdate[0]).toMatchObject({ + universalIdentifier: ROLE_FIELD_UNIVERSAL_IDENTIFIER, + updatedAt: NOW, + options: [ + ...(roleField.options as FieldMetadataComplexOption[]), + REPLY_TO_MESSAGE_PARTICIPANT_ROLE_OPTION, + ], + }); + }); + + it('does nothing when the Reply To option is already present so the upgrade can be re-run safely', () => { + const roleField = buildRoleField([ + buildRoleOption(MessageParticipantRole.FROM, 0), + REPLY_TO_MESSAGE_PARTICIPANT_ROLE_OPTION, + ]); + + const { flatEntityToUpdate } = + buildReplyToMessageParticipantRoleOptionSyncOperations({ + existingFlatFieldMetadataMaps: buildFlatFieldMetadataMaps([roleField]), + now: NOW, + }); + + expect(flatEntityToUpdate).toHaveLength(0); + }); + + it('does nothing when the workspace has no messageParticipant role field', () => { + const { flatEntityToUpdate } = + buildReplyToMessageParticipantRoleOptionSyncOperations({ + existingFlatFieldMetadataMaps: buildFlatFieldMetadataMaps([]), + now: NOW, + }); + + expect(flatEntityToUpdate).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/utils/build-reply-to-message-participant-role-option-sync-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/utils/build-reply-to-message-participant-role-option-sync-operations.util.ts new file mode 100644 index 0000000000..fa520290ed --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-17/utils/build-reply-to-message-participant-role-option-sync-operations.util.ts @@ -0,0 +1,64 @@ +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { + type FieldMetadataComplexOption, + FieldMetadataType, + MessageParticipantRole, +} from 'twenty-shared/types'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +const MESSAGE_PARTICIPANT_ROLE_FIELD_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.messageParticipant.fields.role.universalIdentifier; + +export const REPLY_TO_MESSAGE_PARTICIPANT_ROLE_OPTION: FieldMetadataComplexOption = + { + id: '20202020-3b1a-4e2c-9d7f-8a6b5c4d3e2f', + value: MessageParticipantRole.REPLY_TO, + label: 'Reply To', + position: 4, + color: 'purple', + }; + +export const buildReplyToMessageParticipantRoleOptionSyncOperations = ({ + existingFlatFieldMetadataMaps, + now, +}: { + existingFlatFieldMetadataMaps: FlatEntityMaps; + now: string; +}): FlatEntityToCreateDeleteUpdate<'fieldMetadata'> => { + const roleField = + existingFlatFieldMetadataMaps.byUniversalIdentifier[ + MESSAGE_PARTICIPANT_ROLE_FIELD_UNIVERSAL_IDENTIFIER + ]; + + const existingOptions = (roleField?.options ?? + []) as FieldMetadataComplexOption[]; + + const replyToOptionIsMissing = + roleField?.type === FieldMetadataType.SELECT && + !existingOptions.some( + (option) => option.id === REPLY_TO_MESSAGE_PARTICIPANT_ROLE_OPTION.id, + ); + + if (!replyToOptionIsMissing) { + return { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: [], + }; + } + + return { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: [ + { + ...roleField, + options: [...existingOptions, REPLY_TO_MESSAGE_PARTICIPANT_ROLE_OPTION], + updatedAt: now, + }, + ], + }; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-participant-standard-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-participant-standard-flat-field-metadata.util.ts index 3025810717..381338d2f1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-participant-standard-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-participant-standard-flat-field-metadata.util.ts @@ -240,6 +240,13 @@ export const buildMessageParticipantStandardFlatFieldMetadatas = ({ position: 3, color: 'red', }, + { + id: '20202020-3b1a-4e2c-9d7f-8a6b5c4d3e2f', + value: MessageParticipantRole.REPLY_TO, + label: i18nLabel(msg`Reply To`), + position: 4, + color: 'purple', + }, ], }, standardObjectMetadataRelatedEntityIds, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-and-format-gmail-message.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-and-format-gmail-message.util.ts index 367cab14ed..a7f30951f2 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-and-format-gmail-message.util.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-and-format-gmail-message.util.ts @@ -7,6 +7,7 @@ import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connect import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util'; import { parseGmailMessage } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-message.util'; import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message'; +import { buildReplyToParticipants } from 'src/modules/messaging/message-import-manager/utils/build-reply-to-participants.util'; import { extractMessageBodyText } from 'src/modules/messaging/message-import-manager/utils/extract-message-body-text.util'; import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util'; @@ -20,6 +21,7 @@ export const parseAndFormatGmailMessage = ( internalDate, subject, from, + replyTo, to, cc, bcc, @@ -43,6 +45,7 @@ export const parseAndFormatGmailMessage = ( const participants = [ ...formatAddressObjectAsParticipants([from], MessageParticipantRole.FROM), + ...buildReplyToParticipants(replyTo, from), ...formatAddressObjectAsParticipants( toParticipants, MessageParticipantRole.TO, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-message.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-message.util.ts index e8f178f7c5..486ec9dd6a 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-message.util.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-message.util.ts @@ -11,6 +11,7 @@ import { safeParseEmailAddresses } from 'src/modules/messaging/message-import-ma export const parseGmailMessage = (message: gmail_v1.Schema$Message) => { const subject = getPropertyFromHeaders(message, 'Subject'); const rawFrom = getPropertyFromHeaders(message, 'From'); + const rawReplyTo = getPropertyFromHeaders(message, 'Reply-To'); const rawTo = getPropertyFromHeaders(message, 'To'); const rawDeliveredTo = getPropertyFromHeaders(message, 'Delivered-To'); const rawCc = getPropertyFromHeaders(message, 'Cc'); @@ -42,6 +43,7 @@ export const parseGmailMessage = (message: gmail_v1.Schema$Message) => { internalDate, subject, from: rawFrom ? safeParseEmailAddresses(rawFrom)[0] : undefined, + replyTo: rawReplyTo ? safeParseEmailAddresses(rawReplyTo) : [], deliveredTo: rawDeliveredTo ? safeParseEmailAddressAddress(rawDeliveredTo) : undefined, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.ts index eb7e77a23d..da4bc0f6ac 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service.ts @@ -10,6 +10,7 @@ import { computeMessageDirection } from 'src/modules/messaging/message-import-ma import { MicrosoftImportDriverException } from 'src/modules/messaging/message-import-manager/drivers/microsoft/exceptions/microsoft-import-driver.exception'; import { type MicrosoftGraphBatchResponse } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.interface'; import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message'; +import { buildReplyToParticipants } from 'src/modules/messaging/message-import-manager/utils/build-reply-to-participants.util'; import { extractMessageBodyText } from 'src/modules/messaging/message-import-manager/utils/extract-message-body-text.util'; import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util'; import { safeParseEmailAddress } from 'src/modules/messaging/message-import-manager/utils/safe-parse-email-address.util'; @@ -86,6 +87,12 @@ export class MicrosoftGetMessagesService { ? [safeParseEmailAddress(response.from.emailAddress)] : []; + const safeParseReplyTo = response?.replyTo + ?.filter(isDefined) + .map((recipient: { emailAddress: EmailAddress }) => + safeParseEmailAddress(recipient.emailAddress), + ); + const safeParseTo = response?.toRecipients ?.filter(isDefined) .map((recipient: { emailAddress: EmailAddress }) => @@ -111,6 +118,7 @@ export class MicrosoftGetMessagesService { MessageParticipantRole.FROM, ) : []), + ...buildReplyToParticipants(safeParseReplyTo, safeParseFrom[0]), ...(safeParseTo ? formatAddressObjectAsParticipants( safeParseTo, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/build-reply-to-participants.util.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/build-reply-to-participants.util.spec.ts new file mode 100644 index 0000000000..654f211c6e --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/build-reply-to-participants.util.spec.ts @@ -0,0 +1,43 @@ +import { MessageParticipantRole } from 'twenty-shared/types'; + +import { buildReplyToParticipants } from 'src/modules/messaging/message-import-manager/utils/build-reply-to-participants.util'; + +describe('buildReplyToParticipants', () => { + it('exposes every Reply-To address as a REPLY_TO participant so relayed messages link to the real contacts', () => { + const participants = buildReplyToParticipants( + [ + { address: 'jane@acme.com', name: 'Jane' }, + { address: 'sales@acme.com' }, + ], + { address: 'wordpress@forms.example', name: 'Contact form' }, + ); + + expect(participants).toEqual([ + { + role: MessageParticipantRole.REPLY_TO, + handle: 'jane@acme.com', + displayName: 'Jane', + }, + { + role: MessageParticipantRole.REPLY_TO, + handle: 'sales@acme.com', + displayName: '', + }, + ]); + }); + + it('skips the Reply-To entry that merely echoes the sender, regardless of casing', () => { + const participants = buildReplyToParticipants( + [{ address: 'WordPress@Forms.Example' }, { address: 'jane@acme.com' }], + { address: 'wordpress@forms.example' }, + ); + + expect(participants).toEqual([ + { + role: MessageParticipantRole.REPLY_TO, + handle: 'jane@acme.com', + displayName: '', + }, + ]); + }); +}); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/build-reply-to-participants.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/build-reply-to-participants.util.ts new file mode 100644 index 0000000000..4237d26275 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/build-reply-to-participants.util.ts @@ -0,0 +1,21 @@ +import { MessageParticipantRole } from 'twenty-shared/types'; + +import { type Participant } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-message.type'; +import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address'; +import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util'; + +export const buildReplyToParticipants = ( + replyTo: EmailAddress[] | undefined, + from: EmailAddress | undefined, +): Participant[] => { + const senderHandle = from?.address?.toLowerCase(); + + const replyToExcludingSender = (replyTo ?? []).filter( + (emailAddress) => emailAddress.address.toLowerCase() !== senderHandle, + ); + + return formatAddressObjectAsParticipants( + replyToExcludingSender, + MessageParticipantRole.REPLY_TO, + ); +}; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util.ts index 882cb1e583..ca2cb4ead2 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util.ts @@ -1,6 +1,7 @@ import { type Email as ParsedEmail } from 'postal-mime'; import { MessageParticipantRole } from 'twenty-shared/types'; +import { buildReplyToParticipants } from 'src/modules/messaging/message-import-manager/utils/build-reply-to-participants.util'; import { extractAddressesFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util'; import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util'; @@ -12,10 +13,16 @@ export const extractParticipantsFromParsedEmail = (parsed: ParsedEmail) => { { field: parsed.bcc, role: MessageParticipantRole.BCC }, ] as const; - return addressFields.flatMap(({ field, role }) => - formatAddressObjectAsParticipants( - extractAddressesFromParsedEmail(field), - role, + const from = extractAddressesFromParsedEmail(parsed.from)[0]; + const replyTo = extractAddressesFromParsedEmail(parsed.replyTo); + + return [ + ...addressFields.flatMap(({ field, role }) => + formatAddressObjectAsParticipants( + extractAddressesFromParsedEmail(field), + role, + ), ), - ); + ...buildReplyToParticipants(replyTo, from), + ]; }; diff --git a/packages/twenty-shared/src/types/MessageParticipantRole.ts b/packages/twenty-shared/src/types/MessageParticipantRole.ts index 6f0739d706..00edde9f8f 100644 --- a/packages/twenty-shared/src/types/MessageParticipantRole.ts +++ b/packages/twenty-shared/src/types/MessageParticipantRole.ts @@ -3,4 +3,5 @@ export enum MessageParticipantRole { 'TO' = 'TO', 'CC' = 'CC', 'BCC' = 'BCC', + 'REPLY_TO' = 'REPLY_TO', }