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 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22216?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:
+3
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+43
@@ -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: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+21
@@ -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,
|
||||
);
|
||||
};
|
||||
+12
-5
@@ -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),
|
||||
];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user