fix(messaging): preserve all gmail to/cc/bcc recipients as participants (#20491)

As title but I also refactored it a little to match our current file and
code conventions since the code was very old

Reported by a cloud customer

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
neo773
2026-05-20 21:10:29 +05:30
committed by GitHub
parent 4c4dc4cb21
commit b869107a22
8 changed files with 211 additions and 47 deletions
@@ -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> = {},
): 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: '<abc@example.com>' },
]),
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: '<abc@example.com>' },
]),
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: '<abc@example.com>' },
]),
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: '<abc@example.com>' },
]),
connectedAccount,
);
expect(result).toBeNull();
});
});
@@ -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')
: '';
@@ -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,
@@ -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';
@@ -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 <alice@example.com>, "Bob Smith" <bob@example.com>',
),
).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" <jd@example.com>, bob@example.com'),
).toEqual([
{ address: 'jd@example.com', name: 'Doe, John' },
{ address: 'bob@example.com', name: '' },
]);
});
});
@@ -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,
};
};
@@ -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,
};
};
@@ -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 [];
}
};