diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/__tests__/parse-and-format-gmail-message.util.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/__tests__/parse-and-format-gmail-message.util.spec.ts new file mode 100644 index 0000000000..ec43d80333 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/__tests__/parse-and-format-gmail-message.util.spec.ts @@ -0,0 +1,103 @@ +import { type gmail_v1 as gmailV1 } from 'googleapis'; +import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum'; +import { parseAndFormatGmailMessage } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-and-format-gmail-message.util'; + +const connectedAccount = { + handle: 'me@example.com', + handleAliases: null, +}; + +const buildMessage = ( + headers: { name: string; value: string }[], + overrides: Partial = {}, +): gmailV1.Schema$Message => ({ + id: 'msg-1', + threadId: 'thread-1', + historyId: '42', + internalDate: '1700000000000', + labelIds: [], + payload: { + headers, + mimeType: 'text/plain', + body: { data: Buffer.from('hello').toString('base64'), size: 5 }, + }, + ...overrides, +}); + +describe('parseAndFormatGmailMessage', () => { + it('should emit one participant per recipient in a multi-address `To` header', () => { + // Regression: prior implementation kept only the first parsed address. + const result = parseAndFormatGmailMessage( + buildMessage([ + { name: 'From', value: 'sender@example.com' }, + { + name: 'To', + value: 'alice@example.com, bob@example.com, carol@example.com', + }, + { name: 'Message-ID', value: '' }, + ]), + connectedAccount, + ); + + const toHandles = result?.participants + .filter((p) => p.role === 'TO') + .map((p) => p.handle); + + expect(toHandles).toEqual([ + 'alice@example.com', + 'bob@example.com', + 'carol@example.com', + ]); + }); + + it('should fall back to `Delivered-To` when `To` header is absent', () => { + const result = parseAndFormatGmailMessage( + buildMessage([ + { name: 'From', value: 'sender@example.com' }, + { name: 'Delivered-To', value: 'me@example.com' }, + { name: 'Message-ID', value: '' }, + ]), + connectedAccount, + ); + + const toParticipants = result?.participants.filter((p) => p.role === 'TO'); + + expect(toParticipants).toEqual([ + { role: 'TO', handle: 'me@example.com', displayName: '' }, + ]); + }); + + it('should mark messages from the connected account as OUTGOING', () => { + const result = parseAndFormatGmailMessage( + buildMessage([ + { name: 'From', value: 'me@example.com' }, + { name: 'To', value: 'alice@example.com' }, + { name: 'Message-ID', value: '' }, + ]), + connectedAccount, + ); + + expect(result?.direction).toBe(MessageDirection.OUTGOING); + }); + + it('should return null when required headers (`From`, `Message-ID`) are missing', () => { + const result = parseAndFormatGmailMessage( + buildMessage([{ name: 'To', value: 'alice@example.com' }]), + connectedAccount, + ); + + expect(result).toBeNull(); + }); + + it('should return null when no recipients are present', () => { + const result = parseAndFormatGmailMessage( + buildMessage([ + { name: 'From', value: 'sender@example.com' }, + { name: 'Message-ID', value: '' }, + ]), + connectedAccount, + ); + + expect(result).toBeNull(); + }); +}); 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 dcc014bc19..d84b8f255d 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 @@ -1,6 +1,8 @@ import { type gmail_v1 as gmailV1 } from 'googleapis'; import planer from 'planer'; import { MessageParticipantRole } from 'twenty-shared/types'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util'; @@ -29,44 +31,37 @@ export const parseAndFormatGmailMessage = ( labelIds, } = parseGmailMessage(message); - if ( - !from || - (!to && !deliveredTo && !bcc && !cc) || - !headerMessageId || - !threadId - ) { + if (!isDefined(from) || !isDefined(headerMessageId) || !isDefined(threadId)) { return null; } - const toParticipants = to ?? deliveredTo; + const toParticipants = isNonEmptyArray(to) + ? to + : isNonEmptyString(deliveredTo) + ? [{ address: deliveredTo }] + : []; const participants = [ - ...(from - ? formatAddressObjectAsParticipants( - [{ address: from }], - MessageParticipantRole.FROM, - ) - : []), - ...(toParticipants - ? formatAddressObjectAsParticipants( - [{ address: toParticipants, name: '' }], - MessageParticipantRole.TO, - ) - : []), - ...(cc - ? formatAddressObjectAsParticipants( - [{ address: cc }], - MessageParticipantRole.CC, - ) - : []), - ...(bcc - ? formatAddressObjectAsParticipants( - [{ address: bcc }], - MessageParticipantRole.BCC, - ) - : []), + ...formatAddressObjectAsParticipants( + [{ address: from }], + MessageParticipantRole.FROM, + ), + ...formatAddressObjectAsParticipants( + toParticipants, + MessageParticipantRole.TO, + ), + ...formatAddressObjectAsParticipants(cc, MessageParticipantRole.CC), + ...formatAddressObjectAsParticipants(bcc, MessageParticipantRole.BCC), ]; + const hasRecipientParticipant = participants.some( + (participant) => participant.role !== MessageParticipantRole.FROM, + ); + + if (!hasRecipientParticipant) { + return null; + } + const textWithoutReplyQuotations = text ? planer.extractFrom(text, 'text/plain') : ''; 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 e657b387b5..a2a11b892a 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 @@ -6,7 +6,8 @@ import { getAttachmentData } from 'src/modules/messaging/message-import-manager/ import { getBodyData } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/get-body-data.util'; import { getPropertyFromHeaders } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/get-property-from-headers.util'; import { createHtmlToTextConverter } from 'src/modules/messaging/message-import-manager/utils/create-html-to-text-converter.util'; -import { safeParseEmailAddressAddress } from 'src/modules/messaging/message-import-manager/utils/safe-parse.util'; +import { safeParseEmailAddressAddress } from 'src/modules/messaging/message-import-manager/utils/safe-parse-email-address-address.util'; +import { safeParseEmailAddresses } from 'src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util'; export const parseGmailMessage = (message: gmail_v1.Schema$Message) => { const subject = getPropertyFromHeaders(message, 'Subject'); @@ -48,9 +49,9 @@ export const parseGmailMessage = (message: gmail_v1.Schema$Message) => { deliveredTo: rawDeliveredTo ? safeParseEmailAddressAddress(rawDeliveredTo) : undefined, - to: rawTo ? safeParseEmailAddressAddress(rawTo) : undefined, - cc: rawCc ? safeParseEmailAddressAddress(rawCc) : undefined, - bcc: rawBcc ? safeParseEmailAddressAddress(rawBcc) : undefined, + to: rawTo ? safeParseEmailAddresses(rawTo) : [], + cc: rawCc ? safeParseEmailAddresses(rawCc) : [], + bcc: rawBcc ? safeParseEmailAddresses(rawBcc) : [], text, attachments, labelIds, 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 ee78ec826f..b302d05671 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 @@ -11,7 +11,7 @@ import { MicrosoftImportDriverException } from 'src/modules/messaging/message-im 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 { 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.util'; +import { safeParseEmailAddress } from 'src/modules/messaging/message-import-manager/utils/safe-parse-email-address.util'; import { MicrosoftFetchByBatchService } from './microsoft-fetch-by-batch.service'; import { MicrosoftMessagesImportErrorHandler } from './microsoft-messages-import-error-handler.service'; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/safe-parse-email-addresses.util.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/safe-parse-email-addresses.util.spec.ts new file mode 100644 index 0000000000..742b1ad78f --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/safe-parse-email-addresses.util.spec.ts @@ -0,0 +1,49 @@ +import { safeParseEmailAddresses } from 'src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util'; + +describe('safeParseEmailAddresses', () => { + it('should return every recipient from a multi-address header', () => { + // Regression: previously only the first recipient survived, silently dropping + // CCs/BCCs and additional TOs for any Gmail-synced message with >1 recipient. + expect( + safeParseEmailAddresses( + 'alice@example.com, bob@example.com, carol@example.com', + ), + ).toHaveLength(3); + }); + + it('should preserve display names alongside addresses', () => { + expect( + safeParseEmailAddresses( + 'Alice , "Bob Smith" ', + ), + ).toEqual([ + { address: 'alice@example.com', name: 'Alice' }, + { address: 'bob@example.com', name: 'Bob Smith' }, + ]); + }); + + it('should default an absent display name to empty string, not undefined', () => { + const [first] = safeParseEmailAddresses('alice@example.com'); + + expect(first.name).toBe(''); + }); + + it('should drop entries that parse without an address', () => { + // addressparser yields entries with no `address` for tokens like a bare name — + // those would produce participants with handle "" and break matching downstream. + expect(safeParseEmailAddresses('NoAddressHere, bob@example.com')).toEqual([ + { address: 'bob@example.com', name: '' }, + ]); + }); + + it('should not split on commas inside quoted display names', () => { + // RFC 5322 allows commas inside quoted strings — splitting on them would + // produce a phantom recipient with a garbage address. + expect( + safeParseEmailAddresses('"Doe, John" , bob@example.com'), + ).toEqual([ + { address: 'jd@example.com', name: 'Doe, John' }, + { address: 'bob@example.com', name: '' }, + ]); + }); +}); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-address-address.util.ts similarity index 56% rename from packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse.util.ts rename to packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-address-address.util.ts index b64e6275d5..2001a85ffc 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse.util.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-address-address.util.ts @@ -2,8 +2,6 @@ import { Logger } from '@nestjs/common'; import addressparser from 'addressparser'; -import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address'; - export const safeParseEmailAddressAddress = ( address: string, ): string | undefined => { @@ -17,12 +15,3 @@ export const safeParseEmailAddressAddress = ( return undefined; } }; - -export const safeParseEmailAddress = ( - emailAddress: EmailAddress, -): EmailAddress => { - return { - address: safeParseEmailAddressAddress(emailAddress.address) || '', - name: emailAddress.name, - }; -}; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-address.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-address.util.ts new file mode 100644 index 0000000000..c94bc9bf8d --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-address.util.ts @@ -0,0 +1,11 @@ +import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address'; +import { safeParseEmailAddressAddress } from 'src/modules/messaging/message-import-manager/utils/safe-parse-email-address-address.util'; + +export const safeParseEmailAddress = ( + emailAddress: EmailAddress, +): EmailAddress => { + return { + address: safeParseEmailAddressAddress(emailAddress.address) || '', + name: emailAddress.name, + }; +}; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util.ts new file mode 100644 index 0000000000..e0fbd4c6a8 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util.ts @@ -0,0 +1,16 @@ +import addressparser from 'addressparser'; + +import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address'; + +export const safeParseEmailAddresses = (header: string): EmailAddress[] => { + try { + return addressparser(header) + .filter((parsed) => parsed.address) + .map((parsed) => ({ + address: parsed.address, + name: parsed.name ?? '', + })); + } catch (error) { + return []; + } +};