fix(contact-creation): handle common email display-name shapes when auto-creating People (#20639)

## Summary

When messages are imported, Twenty auto-creates a Person record for any
recipient that doesn't exist yet. The display-name parser used at that
point is `displayName.split(' ')[0] / [1]`, which silently mangles
several common header shapes:

| Header | Old result |

|-------------------------------------------------|-----------------------------------------|
| `"Doe, John" <...>` | `firstName="Doe,"`, `lastName="John"` |
| `"John.Doe Doe" <...>` | `firstName="John.Doe"`, `lastName="Doe"`|
| `"Mary Jane Watson" <...>` | `lastName="Jane"` ("Watson" dropped) |
| `"john.doe@x.com" <john.doe@x.com>` (forwarder) | full address in
`firstName` |
| `"Doe, John:GROUP" <...>` (group-tag servers) |
`firstName="John:GROUP"` |

This PR rewrites `getFirstNameAndLastNameFromHandleAndDisplayName` to
handle each pattern. Behaviour in order:

1. Trim + strip wrapping quotes
2. Swap `"Last, First"` comma form
3. Fall back to handle parsing when display name contains `@` (real
names don't)
4. Split single dotted tokens (`"john.doe"` → `"John"`, `"Doe"`)
5. Preserve multi-word last names (`tokens.slice(1).join(' ')`)
6. De-synthesize dot-glued first names (`"John.Doe Doe"` → `"John"`,
`"Doe"`)
7. Strip `:XXX` trailing tag suffix from each parsed field

## Test plan

- [x] 16 new unit test cases covering each shape
(`__tests__/get-first-name-and-last-name-from-handle-and-display-name.util.spec.ts`)
- [x] Lint + typecheck clean
- [ ] No regression in the messaging import flow

---------

Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
This commit is contained in:
Clive F
2026-05-27 16:49:46 +01:00
committed by GitHub
parent 6f3541fd7c
commit 46e7f23df1
12 changed files with 571 additions and 17 deletions
@@ -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" <john.doe@example.com>' },
{ name: 'To', value: 'me@example.com' },
{ name: 'Message-ID', value: '<abc@example.com>' },
]),
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([
@@ -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,
@@ -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,