diff --git a/packages/twenty-server/src/modules/contact-creation-manager/types/parsed-name.type.ts b/packages/twenty-server/src/modules/contact-creation-manager/types/parsed-name.type.ts new file mode 100644 index 0000000000..1e0914eac6 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/types/parsed-name.type.ts @@ -0,0 +1,4 @@ +export type ParsedName = { + firstName: string; + lastName: string; +}; diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-first-name-and-last-name-from-handle-and-display-name.util.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-first-name-and-last-name-from-handle-and-display-name.util.spec.ts new file mode 100644 index 0000000000..e4f8a5433f --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-first-name-and-last-name-from-handle-and-display-name.util.spec.ts @@ -0,0 +1,155 @@ +import { type EachTestingContext } from 'twenty-shared/testing'; + +import { getFirstNameAndLastNameFromHandleAndDisplayName } from 'src/modules/contact-creation-manager/utils/get-first-name-and-last-name-from-handle-and-display-name.util'; + +type TestCase = EachTestingContext<{ + handle: string; + displayName: string; + expected: { firstName: string; lastName: string }; +}>; + +describe('getFirstNameAndLastNameFromHandleAndDisplayName', () => { + const testCases: TestCase[] = [ + { + title: 'should parse a standard "First Last" display name', + context: { + handle: 'john.doe@example.com', + displayName: 'John Doe', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: 'should swap "Last, First" comma format', + context: { + handle: 'john.doe@example.com', + displayName: 'Doe, John', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: 'should de-duplicate "First.Last Last" synthesized form', + context: { + handle: 'john.doe@example.com', + displayName: 'John.Doe Doe', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: 'should keep all trailing tokens as a multi-word last name', + context: { + handle: 'mjw@example.com', + displayName: 'Mary Jane Watson', + expected: { firstName: 'Mary', lastName: 'Jane Watson' }, + }, + }, + { + title: 'should split a single dotted token into first and last', + context: { + handle: 'john.doe@example.com', + displayName: 'john.doe', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: + 'should accept a single-word display name and fall back to handle for last', + context: { + handle: 'first.someone@example.com', + displayName: 'First', + expected: { firstName: 'First', lastName: 'Someone' }, + }, + }, + { + title: 'should fall back to handle when display name is empty', + context: { + handle: 'john.doe@example.com', + displayName: '', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: 'should fall back to handle even when handle has no dot', + context: { + handle: 'noname@example.com', + displayName: '', + expected: { firstName: 'Noname', lastName: '' }, + }, + }, + { + title: 'should strip wrapping quotes before parsing', + context: { + handle: 'john.doe@example.com', + displayName: '"Doe, John"', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: 'should collapse extra whitespace', + context: { + handle: 'jd@example.com', + displayName: ' John Doe ', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: 'should expand "John.Doe Smith" into "John" + "Doe Smith"', + context: { + handle: 'jd@example.com', + displayName: 'John.Doe Smith', + expected: { firstName: 'John', lastName: 'Doe Smith' }, + }, + }, + { + title: 'should preserve multi-dot handle local-parts on fallback', + context: { + handle: 'jean.luc.picard@example.com', + displayName: '', + expected: { firstName: 'Jean', lastName: 'Luc picard' }, + }, + }, + { + title: + 'should fall back to handle when display name contains the address', + context: { + handle: 'jane.smith@example.com', + displayName: 'Jane.smith@example.com Smith', + expected: { firstName: 'Jane', lastName: 'Smith' }, + }, + }, + { + title: + 'should fall back to handle for single-token email-as-display-name', + context: { + handle: 'janesmith@example.com', + displayName: 'Janesmith@example.com', + expected: { firstName: 'Janesmith', lastName: '' }, + }, + }, + { + title: + 'should strip ":XXX" group-code suffix from comma-format first name', + context: { + handle: 'jane.smith@example.com', + displayName: 'Smith, Jane:GROUP', + expected: { firstName: 'Jane', lastName: 'Smith' }, + }, + }, + { + title: 'should strip ":XXX" suffix from a multi-token display name', + context: { + handle: 'jane.smith@example.com', + displayName: 'Jane:GROUP Smith', + expected: { firstName: 'Jane', lastName: 'Smith' }, + }, + }, + ]; + + test.each(testCases)( + '$title', + ({ context: { handle, displayName, expected } }) => { + expect( + getFirstNameAndLastNameFromHandleAndDisplayName(handle, displayName), + ).toEqual(expected); + }, + ); +}); diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-display-name.util.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-display-name.util.spec.ts new file mode 100644 index 0000000000..d1550a40a0 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-display-name.util.spec.ts @@ -0,0 +1,162 @@ +import { type EachTestingContext } from 'twenty-shared/testing'; + +import { getParsedNameFromDisplayName } from 'src/modules/contact-creation-manager/utils/get-parsed-name-from-display-name.util'; + +type TestCase = EachTestingContext<{ + displayName: string; + expected: { firstName: string; lastName: string }; +}>; + +describe('getParsedNameFromDisplayName', () => { + describe('common shapes', () => { + const testCases: TestCase[] = [ + { + title: 'should split a standard "First Last" display name', + context: { + displayName: 'John Doe', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: + 'should keep all trailing tokens together as a multi-word last name', + context: { + displayName: 'Mary Jane Watson', + expected: { firstName: 'Mary', lastName: 'Jane Watson' }, + }, + }, + { + title: 'should treat a single-word display name as the first name only', + context: { + displayName: 'Cher', + expected: { firstName: 'Cher', lastName: '' }, + }, + }, + { + title: 'should collapse surrounding and inner whitespace', + context: { + displayName: ' John Doe ', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + ]; + + test.each(testCases)('$title', ({ context: { displayName, expected } }) => { + expect(getParsedNameFromDisplayName(displayName)).toEqual(expected); + }); + }); + + describe('comma-inverted form "Last, First"', () => { + const testCases: TestCase[] = [ + { + title: 'should swap the two halves into first and last name', + context: { + displayName: 'Doe, John', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: + 'should strip surrounding RFC 5322 quoted-string quotes before swapping', + context: { + displayName: '"Doe, John"', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + ]; + + test.each(testCases)('$title', ({ context: { displayName, expected } }) => { + expect(getParsedNameFromDisplayName(displayName)).toEqual(expected); + }); + }); + + describe('email-derived display names', () => { + const testCases: TestCase[] = [ + { + title: 'should split a single dotted token like an email local part', + context: { + displayName: 'john.doe', + expected: { firstName: 'john', lastName: 'doe' }, + }, + }, + { + title: + 'should de-duplicate "First.Last Last" forms synthesized by some clients', + context: { + displayName: 'John.Doe Doe', + expected: { firstName: 'John', lastName: 'Doe' }, + }, + }, + { + title: + 'should expand "First.Middle Last" forms into a multi-word last name', + context: { + displayName: 'John.Doe Smith', + expected: { firstName: 'John', lastName: 'Doe Smith' }, + }, + }, + ]; + + test.each(testCases)('$title', ({ context: { displayName, expected } }) => { + expect(getParsedNameFromDisplayName(displayName)).toEqual(expected); + }); + }); + + describe('mail-server ":GROUP" tag suffix', () => { + const testCases: TestCase[] = [ + { + title: + 'should strip the tag from the first name in the comma-inverted form', + context: { + displayName: 'Smith, Jane:GROUP', + expected: { firstName: 'Jane', lastName: 'Smith' }, + }, + }, + { + title: + 'should strip the tag from the first token in a space-separated form', + context: { + displayName: 'Jane:GROUP Smith', + expected: { firstName: 'Jane', lastName: 'Smith' }, + }, + }, + ]; + + test.each(testCases)('$title', ({ context: { displayName, expected } }) => { + expect(getParsedNameFromDisplayName(displayName)).toEqual(expected); + }); + }); + + describe('fallback cases (empty result)', () => { + const testCases: TestCase[] = [ + { + title: + 'should return empty names so the caller can fall back to handle parsing when the display name is empty', + context: { + displayName: '', + expected: { firstName: '', lastName: '' }, + }, + }, + { + title: + 'should return empty names when the display name is only whitespace', + context: { + displayName: ' ', + expected: { firstName: '', lastName: '' }, + }, + }, + { + title: + 'should return empty names when forwarders inject an email address into the display-name slot', + context: { + displayName: 'jane.smith@example.com', + expected: { firstName: '', lastName: '' }, + }, + }, + ]; + + test.each(testCases)('$title', ({ context: { displayName, expected } }) => { + expect(getParsedNameFromDisplayName(displayName)).toEqual(expected); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-email-local-part.util.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-email-local-part.util.spec.ts new file mode 100644 index 0000000000..b18c5bf433 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-email-local-part.util.spec.ts @@ -0,0 +1,80 @@ +import { type EachTestingContext } from 'twenty-shared/testing'; + +import { getParsedNameFromEmailLocalPart } from 'src/modules/contact-creation-manager/utils/get-parsed-name-from-email-local-part.util'; + +type TestCase = EachTestingContext<{ + localPart: string; + expected: { firstName: string; lastName: string }; +}>; + +describe('getParsedNameFromEmailLocalPart', () => { + const testCases: TestCase[] = [ + { + title: + 'should split a dotted local part into first and last name segments', + context: { + localPart: 'john.doe', + expected: { firstName: 'john', lastName: 'doe' }, + }, + }, + { + title: + 'should keep the trailing dot-segments together as a multi-word last name', + context: { + localPart: 'jean.luc.picard', + expected: { firstName: 'jean', lastName: 'luc picard' }, + }, + }, + { + title: + 'should fall back to the whole local part as first name when no dot is present', + context: { + localPart: 'noname', + expected: { firstName: 'noname', lastName: '' }, + }, + }, + { + title: + 'should ignore consecutive dots so they do not produce empty segments', + context: { + localPart: 'john..doe', + expected: { firstName: 'john', lastName: 'doe' }, + }, + }, + { + title: + 'should ignore a leading dot rather than producing an empty first name', + context: { + localPart: '.john.doe', + expected: { firstName: 'john', lastName: 'doe' }, + }, + }, + { + title: 'should return empty names for an empty local part', + context: { + localPart: '', + expected: { firstName: '', lastName: '' }, + }, + }, + { + title: + 'should strip an RFC 5233 plus-address tag so routing metadata does not end up in the name', + context: { + localPart: 'john.doe+sales', + expected: { firstName: 'john', lastName: 'doe' }, + }, + }, + { + title: + 'should strip a plus-address tag even when the local part has no dot', + context: { + localPart: 'noname+ticket-123', + expected: { firstName: 'noname', lastName: '' }, + }, + }, + ]; + + test.each(testCases)('$title', ({ context: { localPart, expected } }) => { + expect(getParsedNameFromEmailLocalPart(localPart)).toEqual(expected); + }); +}); diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-handle.util.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-handle.util.spec.ts new file mode 100644 index 0000000000..3b2021ef71 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/get-parsed-name-from-handle.util.spec.ts @@ -0,0 +1,55 @@ +import { type EachTestingContext } from 'twenty-shared/testing'; + +import { getParsedNameFromHandle } from 'src/modules/contact-creation-manager/utils/get-parsed-name-from-handle.util'; + +type TestCase = EachTestingContext<{ + handle: string; + expected: { firstName: string; lastName: string }; +}>; + +describe('getParsedNameFromHandle', () => { + const testCases: TestCase[] = [ + { + title: + 'should derive first and last name from the local part of a standard email', + context: { + handle: 'john.doe@example.com', + expected: { firstName: 'john', lastName: 'doe' }, + }, + }, + { + title: 'should ignore the domain entirely', + context: { + handle: 'john.doe@deeply.nested.subdomain.example.com', + expected: { firstName: 'john', lastName: 'doe' }, + }, + }, + { + title: + 'should return only a first name when the local part has no dot separator', + context: { + handle: 'noname@example.com', + expected: { firstName: 'noname', lastName: '' }, + }, + }, + { + title: + 'should treat an input without an @ as a bare local part rather than rejecting it', + context: { + handle: 'lonely.handle', + expected: { firstName: 'lonely', lastName: 'handle' }, + }, + }, + { + title: 'should return empty names for an empty handle', + context: { + handle: '', + expected: { firstName: '', lastName: '' }, + }, + }, + ]; + + test.each(testCases)('$title', ({ context: { handle, expected } }) => { + expect(getParsedNameFromHandle(handle)).toEqual(expected); + }); +}); diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/get-first-name-and-last-name-from-handle-and-display-name.util.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/get-first-name-and-last-name-from-handle-and-display-name.util.ts index a9afc777a5..648fd4661f 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/utils/get-first-name-and-last-name-from-handle-and-display-name.util.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/get-first-name-and-last-name-from-handle-and-display-name.util.ts @@ -1,17 +1,18 @@ import { capitalize } from 'twenty-shared/utils'; -export function getFirstNameAndLastNameFromHandleAndDisplayName( + +import { type ParsedName } from 'src/modules/contact-creation-manager/types/parsed-name.type'; +import { getParsedNameFromDisplayName } from 'src/modules/contact-creation-manager/utils/get-parsed-name-from-display-name.util'; +import { getParsedNameFromHandle } from 'src/modules/contact-creation-manager/utils/get-parsed-name-from-handle.util'; + +export const getFirstNameAndLastNameFromHandleAndDisplayName = ( handle: string, displayName: string, -): { firstName: string; lastName: string } { - const firstName = displayName.split(' ')[0]; - const lastName = displayName.split(' ')[1]; - - const contactFullNameFromHandle = handle.split('@')[0]; - const firstNameFromHandle = contactFullNameFromHandle.split('.')[0]; - const lastNameFromHandle = contactFullNameFromHandle.split('.')[1]; +): ParsedName => { + const fromDisplayName = getParsedNameFromDisplayName(displayName); + const fromHandle = getParsedNameFromHandle(handle); return { - firstName: capitalize(firstName || firstNameFromHandle || ''), - lastName: capitalize(lastName || lastNameFromHandle || ''), + firstName: capitalize(fromDisplayName.firstName || fromHandle.firstName), + lastName: capitalize(fromDisplayName.lastName || fromHandle.lastName), }; -} +}; diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-display-name.util.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-display-name.util.ts new file mode 100644 index 0000000000..8131cff929 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-display-name.util.ts @@ -0,0 +1,58 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +import { type ParsedName } from 'src/modules/contact-creation-manager/types/parsed-name.type'; +import { getParsedNameFromEmailLocalPart } from 'src/modules/contact-creation-manager/utils/get-parsed-name-from-email-local-part.util'; + +const EMPTY_NAME: ParsedName = { firstName: '', lastName: '' }; + +export const getParsedNameFromDisplayName = ( + displayName: string, +): ParsedName => { + const cleaned = displayName + .trim() + .replace(/^['"]+|['"]+$/g, '') + .trim(); + + if (!isNonEmptyString(cleaned) || cleaned.includes('@')) return EMPTY_NAME; + + const stripTrailingGroupTag = (input: string): string => + input.replace(/:[^:]+$/, '').trim(); + + const withGroupTagsStripped = (parsed: ParsedName): ParsedName => ({ + firstName: stripTrailingGroupTag(parsed.firstName), + lastName: stripTrailingGroupTag(parsed.lastName), + }); + + const commaMatch = cleaned.match(/^([^,]+),\s*([^,]+)$/); + + if (isDefined(commaMatch)) { + return withGroupTagsStripped({ + firstName: commaMatch[2].trim(), + lastName: commaMatch[1].trim(), + }); + } + + const [firstToken, ...rest] = cleaned.split(/\s+/); + const restAsLastName = rest.join(' '); + const { firstName: head, lastName: dotTail } = + getParsedNameFromEmailLocalPart(firstToken); + + if (!isNonEmptyString(dotTail)) { + return withGroupTagsStripped({ + firstName: head, + lastName: restAsLastName, + }); + } + + const dotTailAlreadyInLastName = restAsLastName + .toLowerCase() + .startsWith(dotTail.toLowerCase()); + + return withGroupTagsStripped({ + firstName: head, + lastName: dotTailAlreadyInLastName + ? restAsLastName + : `${dotTail} ${restAsLastName}`.trim(), + }); +}; diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-email-local-part.util.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-email-local-part.util.ts new file mode 100644 index 0000000000..2e35381106 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-email-local-part.util.ts @@ -0,0 +1,15 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { type ParsedName } from 'src/modules/contact-creation-manager/types/parsed-name.type'; + +export const getParsedNameFromEmailLocalPart = ( + localPart: string, +): ParsedName => { + const [withoutPlusAddressTag = ''] = localPart.split('+'); + const parts = withoutPlusAddressTag.split('.').filter(isNonEmptyString); + + return { + firstName: parts[0] ?? '', + lastName: parts.slice(1).join(' '), + }; +}; diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-handle.util.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-handle.util.ts new file mode 100644 index 0000000000..265aeda87e --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/get-parsed-name-from-handle.util.ts @@ -0,0 +1,8 @@ +import { type ParsedName } from 'src/modules/contact-creation-manager/types/parsed-name.type'; +import { getParsedNameFromEmailLocalPart } from 'src/modules/contact-creation-manager/utils/get-parsed-name-from-email-local-part.util'; + +export const getParsedNameFromHandle = (handle: string): ParsedName => { + const [localPart = ''] = handle.split('@'); + + return getParsedNameFromEmailLocalPart(localPart); +}; 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 index ec43d80333..c3c3cbfb57 100644 --- 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 @@ -67,6 +67,25 @@ describe('parseAndFormatGmailMessage', () => { ]); }); + it('should preserve the display name on the FROM participant', () => { + const result = parseAndFormatGmailMessage( + buildMessage([ + { name: 'From', value: '"Doe, John" ' }, + { name: 'To', value: 'me@example.com' }, + { name: 'Message-ID', value: '' }, + ]), + connectedAccount, + ); + + const fromParticipant = result?.participants.find((p) => p.role === 'FROM'); + + expect(fromParticipant).toEqual({ + role: 'FROM', + handle: 'john.doe@example.com', + displayName: 'Doe, John', + }); + }); + it('should mark messages from the connected account as OUTGOING', () => { const result = parseAndFormatGmailMessage( buildMessage([ 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 d84b8f255d..f9619bbeac 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 @@ -42,10 +42,7 @@ export const parseAndFormatGmailMessage = ( : []; const participants = [ - ...formatAddressObjectAsParticipants( - [{ address: from }], - MessageParticipantRole.FROM, - ), + ...formatAddressObjectAsParticipants([from], MessageParticipantRole.FROM), ...formatAddressObjectAsParticipants( toParticipants, MessageParticipantRole.TO, @@ -72,7 +69,7 @@ export const parseAndFormatGmailMessage = ( subject: subject || '', messageThreadExternalId: threadId, receivedAt: new Date(parseInt(internalDate)), - direction: computeMessageDirection(from || '', connectedAccount), + direction: computeMessageDirection(from.address || '', connectedAccount), participants, text: sanitizeString(textWithoutReplyQuotations), attachments, 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 a2a11b892a..1a2f61f5be 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 @@ -45,7 +45,7 @@ export const parseGmailMessage = (message: gmail_v1.Schema$Message) => { historyId, internalDate, subject, - from: rawFrom ? safeParseEmailAddressAddress(rawFrom) : undefined, + from: rawFrom ? safeParseEmailAddresses(rawFrom)[0] : undefined, deliveredTo: rawDeliveredTo ? safeParseEmailAddressAddress(rawDeliveredTo) : undefined,