fix(messaging): stop group and bulk emails from creating contacts (#23137)

Group addresses were only checked against the message sender, so
anything arriving as reply-to or cc slipped through and became a
contact. Now checked per participant at contact creation.

The group word list can't keep up with real senders (posts-recap@,
showinfo@, follow-suggestions@), so bulk-mail headers back it up:
`List-Unsubscribe, List-Id, Precedence, Auto-Submitted` (Industry
standard headers)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23137?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
neo773
2026-07-22 17:17:45 +05:30
committed by GitHub
parent 8d943e1f68
commit 0489d39f2b
15 changed files with 212 additions and 1 deletions
@@ -31,6 +31,7 @@ export const parseAndFormatGmailMessage = (
attachments,
deliveredTo,
labelIds,
messageHeaders,
} = parseGmailMessage(message);
const isDraft = (labelIds ?? []).includes('DRAFT');
@@ -86,5 +87,6 @@ export const parseAndFormatGmailMessage = (
messageFolderExternalIds: labelIds,
labelIds,
isDraft,
messageHeaders,
};
};
@@ -1,6 +1,7 @@
import assert from 'assert';
import { type gmail_v1 } from 'googleapis';
import { isDefined } from 'twenty-shared/utils';
import { getAttachmentData } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/get-attachment-data.util';
import { getBodyData } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/get-body-data.util';
@@ -22,6 +23,10 @@ export const parseGmailMessage = (message: gmail_v1.Schema$Message) => {
const historyId = message.historyId;
const internalDate = message.internalDate;
const labelIds = message.labelIds ?? [];
const messageHeaders = (message.payload?.headers ?? []).flatMap(
({ name, value }) =>
isDefined(name) && isDefined(value) ? [{ name, value }] : [],
);
assert(id, 'ID is missing');
assert(historyId, 'History-ID is missing');
@@ -54,5 +59,6 @@ export const parseGmailMessage = (message: gmail_v1.Schema$Message) => {
isHtml,
attachments,
labelIds,
messageHeaders,
};
};
@@ -187,6 +187,10 @@ export class ImapGetMessagesService {
participants: extractParticipantsFromParsedEmail(parsed),
messageFolderExternalIds: [folderExternalId],
isDraft: flags?.has('\\Draft') ?? false,
messageHeaders: parsed.headers.map(({ key, value }) => ({
name: key,
value,
})),
};
}
}
@@ -36,6 +36,10 @@ export class InboundEmailParserService {
attachments: [],
participants: extractParticipantsFromParsedEmail(parsedEmail),
isDraft: false,
messageHeaders: parsedEmail.headers.map(({ key, value }) => ({
name: key,
value,
})),
};
}
}
@@ -17,6 +17,23 @@ export class MicrosoftFetchByBatchService {
messageIdsByBatch: string[][];
batchResponses: MicrosoftGraphBatchResponse[];
}> {
const selectedFields = [
'id',
'subject',
'body',
'receivedDateTime',
'internetMessageId',
'internetMessageHeaders',
'conversationId',
'parentFolderId',
'isDraft',
'from',
'replyTo',
'toRecipients',
'ccRecipients',
'bccRecipients',
].join(',');
const batchLimit = 20;
const batchResponses: MicrosoftGraphBatchResponse[] = [];
const messageIdsByBatch: string[][] = [];
@@ -33,7 +50,7 @@ export class MicrosoftFetchByBatchService {
const batchRequests = batchMessageIds.map((messageId, index) => ({
id: (index + 1).toString(),
method: 'GET',
url: `/me/messages/${messageId}`,
url: `/me/messages/${messageId}?$select=${selectedFields}`,
headers: {
'Content-Type': 'application/json',
Prefer: 'outlook.body-content-type="text"',
@@ -19,6 +19,7 @@ export interface MicrosoftGraphBatchResponse {
sentDateTime?: string;
hasAttachments?: boolean;
internetMessageId?: string;
internetMessageHeaders?: { name: string; value: string }[];
subject?: string;
bodyPreview?: string;
importance?: string;
@@ -104,6 +104,7 @@ describe('Microsoft get messages service', () => {
messageFolderExternalIds: responseExample1.body.parentFolderId
? [responseExample1.body.parentFolderId]
: [],
messageHeaders: [],
});
const responseExample2 =
@@ -151,6 +152,7 @@ describe('Microsoft get messages service', () => {
messageFolderExternalIds: responseExample2.body.parentFolderId
? [responseExample2.body.parentFolderId]
: [],
messageHeaders: [],
});
});
@@ -195,6 +197,7 @@ describe('Microsoft get messages service', () => {
messageFolderExternalIds: responseExample.body.parentFolderId
? [responseExample.body.parentFolderId]
: [],
messageHeaders: [],
});
});
});
@@ -164,6 +164,7 @@ export class MicrosoftGetMessagesService {
? [response.parentFolderId]
: [],
isDraft: response.isDraft ?? false,
messageHeaders: response.internetMessageHeaders ?? [],
};
});
@@ -305,6 +305,54 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
},
);
});
it('should not create contacts for non-sender group email participants when excludeGroupEmails is enabled', async () => {
await service.saveMessagesAndEnqueueContactCreation(
[
{
...mockMessages[1],
participants: [
{
role: MessageParticipantRole.FROM,
handle: 'tim@apple.com',
displayName: 'Tim',
},
{
role: MessageParticipantRole.REPLY_TO,
handle: 'no-reply@mail.instagram.com',
displayName: 'Instagram',
},
{
role: MessageParticipantRole.CC,
handle: 'support@company.com',
displayName: 'Support',
},
],
},
],
mockMessageChannel,
mockConnectedAccount,
workspaceId,
);
expect(messageQueueService.add).toHaveBeenCalledWith(
CreateCompanyAndContactJob.name,
{
workspaceId,
connectedAccount: mockConnectedAccount,
source: FieldActorSource.EMAIL,
contactsToCreate: [
{
handle: 'tim@apple.com',
displayName: 'Tim',
role: MessageParticipantRole.FROM,
shouldCreateContact: true,
messageId: 'db-message-id-2',
},
],
},
);
});
it('should not create contact if the participant is the connected account', async () => {
const mockMessagesWithConnectedAccount = [
{
@@ -29,6 +29,7 @@ import {
} from 'src/modules/messaging/message-import-manager/services/messaging-message-folder-association.service';
import { MessagingMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-message.service';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { isGroupEmail } from 'src/modules/messaging/message-import-manager/utils/is-group-email';
import { MessagingMessageParticipantService } from 'src/modules/messaging/message-participant-manager/services/messaging-message-participant.service';
import { isWorkEmail } from 'src/utils/is-work-email';
@@ -103,6 +104,10 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
messageChannel.excludeNonProfessionalEmails &&
!isWorkEmail(participant.handle);
const isExcludedByGroupEmails =
messageChannel.excludeGroupEmails &&
isGroupEmail(participant.handle);
// Drafts are outgoing, so don't turn recipients of an
// unsent email into CRM contacts.
const shouldCreateContact =
@@ -110,6 +115,7 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
!!participant.handle &&
!isParticipantConnectedAccount &&
!isExcludedByNonProfessionalEmails &&
!isExcludedByGroupEmails &&
(messageChannel.contactAutoCreationPolicy ===
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED ||
(messageChannel.contactAutoCreationPolicy ===
@@ -26,6 +26,12 @@ export type Message = Omit<
messageFolderIds?: string[];
messageFolderExternalIds?: string[];
labelIds?: string[];
messageHeaders?: MessageHeader[];
};
export type MessageHeader = {
name: string;
value: string;
};
export type MessageAttachment = {
@@ -139,6 +139,38 @@ describe('filterEmails', () => {
expect(result[0].externalId).toBe('regular-message');
});
it('should filter out bulk mail whose sender does not look like a group address', () => {
const primaryHandle = 'user@example.com';
const messages: MessageWithParticipants[] = [
{
externalId: 'newsletter-message',
subject: 'Your weekly recap',
receivedAt: new Date('2025-01-09T09:54:37.000Z'),
text: 'Recap',
headerMessageId: '<posts-recap@mail.instagram.com>',
messageThreadExternalId: 'thread-1',
direction: MessageDirection.INCOMING,
participants: [
{
role: MessageParticipantRole.FROM,
handle: 'posts-recap@mail.instagram.com',
displayName: 'Instagram',
},
],
attachments: [],
isDraft: false,
messageHeaders: [
{ name: 'List-Unsubscribe', value: '<https://instagram.com/unsub>' },
],
},
];
expect(filterEmails(primaryHandle, [], messages, [])).toEqual([]);
expect(filterEmails(primaryHandle, [], messages, [], false)).toEqual(
messages,
);
});
it('should not filter out group emails when excludeGroupEmails is false', () => {
const primaryHandle = 'user@example.com';
const messages: MessageWithParticipants[] = [
@@ -0,0 +1,50 @@
import { isBulkMail } from 'src/modules/messaging/message-import-manager/utils/is-bulk-mail.util';
describe('isBulkMail', () => {
it('should detect a List-Unsubscribe header regardless of casing', () => {
expect(
isBulkMail([
{ name: 'List-Unsubscribe', value: '<https://example.com/unsub>' },
]),
).toBe(true);
expect(
isBulkMail([
{ name: 'list-unsubscribe', value: '<mailto:unsub@example.com>' },
]),
).toBe(true);
});
it('should detect a List-Id header', () => {
expect(
isBulkMail([{ name: 'List-Id', value: '<newsletter.example.com>' }]),
).toBe(true);
});
it('should detect bulk precedence values only', () => {
expect(isBulkMail([{ name: 'Precedence', value: 'bulk' }])).toBe(true);
expect(isBulkMail([{ name: 'Precedence', value: ' List ' }])).toBe(true);
expect(isBulkMail([{ name: 'Precedence', value: 'urgent' }])).toBe(false);
});
it('should treat Auto-Submitted as bulk unless it is "no"', () => {
expect(
isBulkMail([{ name: 'Auto-Submitted', value: 'auto-generated' }]),
).toBe(true);
expect(isBulkMail([{ name: 'Auto-Submitted', value: 'no' }])).toBe(false);
});
it('should not flag a regular message', () => {
expect(
isBulkMail([
{ name: 'From', value: 'tim@apple.com' },
{ name: 'Subject', value: 'Lunch' },
{ name: 'List-Unsubscribe', value: '' },
]),
).toBe(false);
});
it('should not flag a message without headers', () => {
expect(isBulkMail([])).toBe(false);
});
});
@@ -5,6 +5,7 @@ import { type MessageWithParticipants } from 'src/modules/messaging/message-impo
import { filterOutBlocklistedMessages } from 'src/modules/messaging/message-import-manager/utils/filter-out-blocklisted-messages.util';
import { filterOutIcsAttachments } from 'src/modules/messaging/message-import-manager/utils/filter-out-ics-attachments.util';
import { filterOutInternals } from 'src/modules/messaging/message-import-manager/utils/filter-out-internals.util';
import { isBulkMail } from 'src/modules/messaging/message-import-manager/utils/is-bulk-mail.util';
import { isGroupEmail } from 'src/modules/messaging/message-import-manager/utils/is-group-email';
import { isMessageSenderMatchingHandles } from 'src/modules/messaging/message-import-manager/utils/is-message-sender-matching-handles.util';
import { isWorkEmail } from 'src/utils/is-work-email';
@@ -45,6 +46,10 @@ export const filterEmails = (
return true;
}
if (isBulkMail(message.messageHeaders ?? [])) {
return false;
}
const senderHandle = message.participants?.find(
(participant) => participant.role === MessageParticipantRole.FROM,
)?.handle;
@@ -0,0 +1,26 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type MessageHeader } from 'src/modules/messaging/message-import-manager/types/message';
const BULK_LIST_HEADER_NAMES = ['list-unsubscribe', 'list-id'];
const BULK_PRECEDENCE_VALUES = ['bulk', 'list', 'junk'];
export const isBulkMail = (headers: MessageHeader[]): boolean =>
headers.some(({ name, value }) => {
const headerName = name.toLowerCase();
const headerValue = value.trim().toLowerCase();
if (BULK_LIST_HEADER_NAMES.includes(headerName)) {
return isNonEmptyString(headerValue);
}
if (headerName === 'precedence') {
return BULK_PRECEDENCE_VALUES.includes(headerValue);
}
if (headerName === 'auto-submitted') {
return isNonEmptyString(headerValue) && headerValue !== 'no';
}
return false;
});