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:
+103
@@ -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();
|
||||
});
|
||||
});
|
||||
+26
-31
@@ -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')
|
||||
: '';
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+49
@@ -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: '' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
-11
@@ -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,
|
||||
};
|
||||
};
|
||||
+11
@@ -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,
|
||||
};
|
||||
};
|
||||
+16
@@ -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 [];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user