From 7e133a493019024875a527f92a389b8fe9cef2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 17 Jul 2026 21:20:14 +0200 Subject: [PATCH] Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997) # Why Follow-up to #22668, addressing @charlesBochet's five post-merge review comments. They all point the same direction: the recipient fields rebuilt things the codebase already had. This PR converges on the existing patterns where that holds up, and answers on the threads where it deliberately does not. # What changed, per comment **Parser duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064242))**: `parseEmailAddressList` now lives in twenty-shared (addressparser, group flattening, try/catch). The server's `safeParseEmailAddresses` delegates to it, the front wrapper keeps only paste normalization (newlines to commas) and invalid-token preservation for red chips. The `addressparser` dependency moves from twenty-front to twenty-shared. Side effect worth knowing: RFC 5322 group members in inbound To/Cc headers were previously dropped entirely (group entries have no top-level address, so the filter removed them); flattening now imports those participants. Covered by a new regression test. **Formatter duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064243))**: `formatEmailAddress` (quote only when specials require it) lives in twenty-shared. The composer chips and the server's `formatMessageFromHeader` both delegate to it. The Gmail From header output is byte-identical: the name is mime-encoded first and encoded words never contain characters that trigger quoting. CodeQL then caught that the quoting (ported from the original front util) escaped quotes but not backslashes, letting a crafted name close the quoted string early; escaping now covers both as RFC 5322 quoted-pairs, with a containment test proving a hostile name cannot split into extra recipients on reparse. **Member search divergence ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064231))**: suggestions now search WorkspaceMember through the search index in the same `useObjectRecordSearchRecords` call as Person (one ranked query), and enrich hits from `currentWorkspaceMembersState`, exactly like `SettingsRoleAssignmentWorkspaceMemberPickerDropdown`. The client-side `filterBySearchQuery` pass is gone. The hook is now what the comment described: the merge of context people, searched people, and members into one ranked list, rendered with the same `SelectableList`/`MenuItemAvatar` primitives the pickers use. Also fixed while in there: searched person ids are sliced to the suggestion limit before hydration, so top-ranked people can no longer be crowded out of the hydration page. **Chip resolution duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064236))**: the display-name preference is now one rule, `getEmailIdentityDisplayName`, used by both `getDisplayNameFromParticipant` (threads) and the composer chip/menu, so the same address renders identically everywhere. The order is workspace member, then person, then display name, then handle: when an address belongs to both a teammate and a Person record, the internal identity wins (product call from Felix). `BaseChip.maxLabelWidth` is renamed `maxWidth` to match the twenty-ui `Chip` API. `ParticipantChip` itself is not used inside the field: it renders a navigating `RecordChip` when a person is linked, and navigation from the composer destroys the draft (no draft persistence yet), plus the field chips need remove/selected/danger/edit affordances it does not have. **Rebuilding on MultiItemFieldInput ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064221))**: answered on the thread rather than in code, deliberately. `MultiItemFieldInput` is a dropdown-panel list editor (vertical rows, one input at a time, bound to record-field contexts and `FieldMetadataType`), and its own TODO says the API should be refactored into a hook before growing. The inline wrapping chip row commits batches (paste), dedupes with a flash, and keeps a persistent inline input with suggestions; layering that through `renderItem`/`renderInput` would strain both components. On the menu overlap: after comparing side by side, the shared surface between `MultiItemFieldMenuItem`'s dropdown and the chip menu is three `MenuItem` rows with different copy, order, and neighbors; `MenuItem` is already the shared primitive, and a config-driven fragment would be indirection without deduplication. If deeper convergence is wanted, the honest path is the existing TODO (extract the multi-item state machine into a hook, rebase both editors on it); that touches the Links/Phones/Emails/Array/Files cell editors and deserves its own PR. # Verification - New twenty-shared suites for the parser and formatter (16 tests), including parse/format round-trips, the encoded-word case, and the backslash-escaping containment case. - Server messaging util specs all pass (70 tests), including new group-flattening regression tests; From-header spec output unchanged. - Front email module suites all pass (59 tests) with the slimmed wrappers. - Typecheck and lint green on twenty-shared, twenty-front, twenty-server; oxfmt clean on all three. - Playwright smoke against the seeded dev stack passes end to end: context suggestions on the Google company, typed search showing people and the workspace member row (now served by the search index), Enter picking the top suggestion, duplicate merge, keyboard delete, chip menu with clipboard copy, Ctrl+Enter committing the buffer then triggering send. --- packages/twenty-front/package.json | 2 - .../components/EmailRecipientsFieldChip.tsx | 16 +-- .../hooks/useEmailRecipientSuggestions.ts | 117 ++++++++++-------- .../recipients/utils/formatEmailRecipient.ts | 19 +-- .../recipients/utils/parseEmailRecipients.ts | 29 ++--- .../getDisplayNameFromParticipant.test.ts | 40 +++++- .../utils/getDisplayNameFromParticipant.ts | 44 ++++--- .../utils/getEmailIdentityDisplayName.ts | 16 +++ .../ui/form-types/components/BaseChip.tsx | 6 +- .../safe-parse-email-addresses.util.spec.ts | 18 +++ .../utils/safe-parse-email-addresses.util.ts | 16 +-- .../utils/format-message-from-header.util.ts | 8 +- packages/twenty-shared/package.json | 2 + .../__tests__/formatEmailAddress.test.ts | 97 +++++++++++++++ .../__tests__/parseEmailAddressList.test.ts | 76 ++++++++++++ .../src/utils/email/formatEmailAddress.ts | 22 ++++ .../src/utils/email/parseEmailAddressList.ts | 35 ++++++ packages/twenty-shared/src/utils/index.ts | 3 + yarn.lock | 4 +- 19 files changed, 430 insertions(+), 140 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/emails/utils/getEmailIdentityDisplayName.ts create mode 100644 packages/twenty-shared/src/utils/email/__tests__/formatEmailAddress.test.ts create mode 100644 packages/twenty-shared/src/utils/email/__tests__/parseEmailAddressList.test.ts create mode 100644 packages/twenty-shared/src/utils/email/formatEmailAddress.ts create mode 100644 packages/twenty-shared/src/utils/email/parseEmailAddressList.ts diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 3fba58bffc..5923755afb 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -83,7 +83,6 @@ "@tiptap/react": "3.4.2", "@types/marked": "^6.0.0", "@xyflow/react": "^12.4.2", - "addressparser": "1.0.1", "ai": "6.0.97", "apollo-link-rest": "^0.10.0-rc.2", "apollo-upload-client": "^19.0.0", @@ -177,7 +176,6 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@tiptap/suggestion": "3.4.2", - "@types/addressparser": "^1.0.3", "@types/deep-equal": "^1.0.1", "@types/file-saver": "^2.0.7", "@types/jest": "^30.0.0", diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldChip.tsx b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldChip.tsx index ce87253ebc..b5ea3f7ba5 100644 --- a/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldChip.tsx +++ b/packages/twenty-front/src/modules/activities/emails/recipients/components/EmailRecipientsFieldChip.tsx @@ -1,5 +1,4 @@ import { useLingui } from '@lingui/react/macro'; -import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { Avatar } from 'twenty-ui/data-display'; @@ -7,11 +6,12 @@ import { EmailRecipientChipMenuContent } from '@/activities/emails/recipients/co import { type EmailRecipientResolution } from '@/activities/emails/recipients/hooks/useEmailRecipientsResolution'; import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient'; +import { getEmailIdentityDisplayName } from '@/activities/emails/utils/getEmailIdentityDisplayName'; import { BaseChip } from '@/object-record/record-field/ui/form-types/components/BaseChip'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl'; -const CHIP_MAX_LABEL_WIDTH = 240; +const CHIP_MAX_WIDTH = 240; type EmailRecipientsFieldChipProps = { chipId: string; @@ -48,10 +48,12 @@ export const EmailRecipientsFieldChip = ({ ? `${person.firstName} ${person.lastName}`.trim() : ''; - const resolvedLabel = - [workspaceMemberFullName, personFullName, recipient.displayName ?? ''].find( - isNonEmptyString, - ) ?? recipient.address; + const resolvedLabel = getEmailIdentityDisplayName({ + personName: personFullName, + workspaceMemberName: workspaceMemberFullName, + displayName: recipient.displayName, + handle: recipient.address, + }); const avatar = isDefined(workspaceMember) || isDefined(person) ? ( @@ -84,7 +86,7 @@ export const EmailRecipientsFieldChip = ({ selected={selected} isFlashing={isFlashing} onDoubleClick={onEdit} - maxLabelWidth={CHIP_MAX_LABEL_WIDTH} + maxWidth={CHIP_MAX_WIDTH} onRemove={(event) => { event.stopPropagation(); onRemove(); diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientSuggestions.ts b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientSuggestions.ts index 31fdbace8a..9403dc7d41 100644 --- a/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientSuggestions.ts +++ b/packages/twenty-front/src/modules/activities/emails/recipients/hooks/useEmailRecipientSuggestions.ts @@ -15,7 +15,6 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; export type EmailRecipientSuggestion = { suggestionId: string; @@ -116,14 +115,23 @@ export const useEmailRecipientSuggestions = ({ }); const { searchRecords } = useObjectRecordSearchRecords({ - objectNameSingulars: [CoreObjectNameSingular.Person], + objectNameSingulars: [ + CoreObjectNameSingular.Person, + CoreObjectNameSingular.WorkspaceMember, + ], searchInput: hasSearchInput ? trimmedSearchInput : undefined, - limit: EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT, + limit: + EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT + + EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT, }); - const searchedPersonIds = searchRecords.map( - (searchRecord) => searchRecord.recordId, - ); + const searchedPersonIds = searchRecords + .filter( + (searchRecord) => + searchRecord.objectNameSingular === CoreObjectNameSingular.Person, + ) + .map((searchRecord) => searchRecord.recordId) + .slice(0, EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT); const { records: searchedPeopleRecords } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.Person, @@ -149,61 +157,62 @@ export const useEmailRecipientSuggestions = ({ getEmailRecipientPersonFromRecord(personRecord), ]), ); - const orderedSearchedPeople = searchedPersonIds - .map((personId) => searchedPeopleById.get(personId)) - .filter(isDefined); const contextPersonIds = new Set(contextPeople.map((person) => person.id)); - const orderedPeople = hasSearchInput - ? [ - ...orderedSearchedPeople.filter((person) => - contextPersonIds.has(person.id), - ), - ...orderedSearchedPeople.filter( - (person) => !contextPersonIds.has(person.id), - ), - ] - : contextPeople; + const workspaceMembersById = new Map( + currentWorkspaceMembers.map((workspaceMember) => [ + workspaceMember.id, + workspaceMember, + ]), + ); - const peopleSuggestions = orderedPeople - .filter(isSuggestablePerson) - .map(getPersonSuggestion); + const contextRankedSuggestions: EmailRecipientSuggestion[] = []; + const rankedSuggestions: EmailRecipientSuggestion[] = []; + let memberSuggestionCount = 0; - const memberSuggestions: EmailRecipientSuggestion[] = hasSearchInput - ? filterBySearchQuery({ - items: currentWorkspaceMembers.filter( - (workspaceMember) => - isNonEmptyString(workspaceMember.userEmail) && - !excludedKeySet.has( - getEmailRecipientKey(workspaceMember.userEmail), - ), - ), - searchQuery: trimmedSearchInput, - getSearchableValues: (workspaceMember) => [ - `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim(), - workspaceMember.userEmail, - ], - }) - .slice(0, EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT) - .map((workspaceMember) => - getSuggestion({ - suggestionId: `workspace-member-${workspaceMember.id}`, - fullName: - `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim(), - address: workspaceMember.userEmail, - secondaryText: `${workspaceMember.userEmail} · ${t`Team member`}`, - avatarUrl: workspaceMember.avatarUrl ?? null, - avatarColorSeed: workspaceMember.id, - }), - ) - : []; + for (const searchRecord of searchRecords) { + if (searchRecord.objectNameSingular === CoreObjectNameSingular.Person) { + const person = searchedPeopleById.get(searchRecord.recordId); + + if (isDefined(person) && isSuggestablePerson(person)) { + (contextPersonIds.has(person.id) + ? contextRankedSuggestions + : rankedSuggestions + ).push(getPersonSuggestion(person)); + } + continue; + } + + const workspaceMember = workspaceMembersById.get(searchRecord.recordId); + + if ( + isDefined(workspaceMember) && + isNonEmptyString(workspaceMember.userEmail) && + !excludedKeySet.has(getEmailRecipientKey(workspaceMember.userEmail)) && + memberSuggestionCount < EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT + ) { + memberSuggestionCount += 1; + rankedSuggestions.push( + getSuggestion({ + suggestionId: `workspace-member-${workspaceMember.id}`, + fullName: + `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim(), + address: workspaceMember.userEmail, + secondaryText: `${workspaceMember.userEmail} · ${t`Team member`}`, + avatarUrl: workspaceMember.avatarUrl ?? null, + avatarColorSeed: workspaceMember.id, + }), + ); + } + } + + const recordSuggestions = hasSearchInput + ? [...contextRankedSuggestions, ...rankedSuggestions] + : contextPeople.filter(isSuggestablePerson).map(getPersonSuggestion); const seenRecipientKeys = new Set(); - const dedupedRecordSuggestions = [ - ...peopleSuggestions, - ...memberSuggestions, - ].filter((suggestion) => { + const dedupedRecordSuggestions = recordSuggestions.filter((suggestion) => { const recipientKey = getEmailRecipientKey(suggestion.recipient.address); if (seenRecipientKeys.has(recipientKey)) { diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/formatEmailRecipient.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/formatEmailRecipient.ts index 6c801303a4..3190990195 100644 --- a/packages/twenty-front/src/modules/activities/emails/recipients/utils/formatEmailRecipient.ts +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/formatEmailRecipient.ts @@ -1,16 +1,9 @@ -import { isNonEmptyString } from '@sniptt/guards'; +import { formatEmailAddress } from 'twenty-shared/utils'; import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; -export const formatEmailRecipient = (recipient: EmailRecipient): string => { - if (!isNonEmptyString(recipient.displayName)) { - return recipient.address; - } - - const requiresQuoting = /[,;<>@"]/.test(recipient.displayName); - const formattedDisplayName = requiresQuoting - ? `"${recipient.displayName.replaceAll('"', '\\"')}"` - : recipient.displayName; - - return `${formattedDisplayName} <${recipient.address}>`; -}; +export const formatEmailRecipient = (recipient: EmailRecipient): string => + formatEmailAddress({ + address: recipient.address, + name: recipient.displayName, + }); diff --git a/packages/twenty-front/src/modules/activities/emails/recipients/utils/parseEmailRecipients.ts b/packages/twenty-front/src/modules/activities/emails/recipients/utils/parseEmailRecipients.ts index dca7bb6e22..fdc4aa49e2 100644 --- a/packages/twenty-front/src/modules/activities/emails/recipients/utils/parseEmailRecipients.ts +++ b/packages/twenty-front/src/modules/activities/emails/recipients/utils/parseEmailRecipients.ts @@ -1,26 +1,19 @@ -import addressparser from 'addressparser'; import { isNonEmptyString } from '@sniptt/guards'; +import { parseEmailAddressList } from 'twenty-shared/utils'; import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient'; export const parseEmailRecipients = (rawText: string): EmailRecipient[] => { const normalizedText = rawText.replace(/\r?\n/g, ','); - try { - return addressparser(normalizedText) - .flatMap((parsedAddress) => parsedAddress.group ?? [parsedAddress]) - .map((parsedAddress) => - isNonEmptyString(parsedAddress.address) - ? { - address: parsedAddress.address, - displayName: isNonEmptyString(parsedAddress.name) - ? parsedAddress.name - : undefined, - } - : { address: parsedAddress.name.trim(), displayName: undefined }, - ) - .filter((recipient) => isNonEmptyString(recipient.address)); - } catch { - return []; - } + return parseEmailAddressList(normalizedText).map((parsedAddress) => + isNonEmptyString(parsedAddress.address) + ? { + address: parsedAddress.address, + displayName: isNonEmptyString(parsedAddress.name) + ? parsedAddress.name + : undefined, + } + : { address: parsedAddress.name }, + ); }; diff --git a/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getDisplayNameFromParticipant.test.ts b/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getDisplayNameFromParticipant.test.ts index 94a4290bbe..103824e0f6 100644 --- a/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getDisplayNameFromParticipant.test.ts +++ b/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getDisplayNameFromParticipant.test.ts @@ -65,19 +65,19 @@ describe('getDisplayNameFromParticipant', () => { role: MessageParticipantRole.FROM, } as EmailThreadMessageParticipant; - it('should return full name when shouldUseFullName is true', () => { + it('should prefer the workspace member full name when shouldUseFullName is true', () => { expect( getDisplayNameFromParticipant({ participant: participantWithName, shouldUseFullName: true, }), - ).toBe('John Doe'); + ).toBe('Jane Smith'); }); - it('should return first name when shouldUseFullName is false', () => { + it('should prefer the workspace member first name when shouldUseFullName is false', () => { expect( getDisplayNameFromParticipant({ participant: participantWithName }), - ).toBe('John'); + ).toBe('Jane'); }); it('should return displayName if it is a non-empty string', () => { @@ -94,6 +94,38 @@ describe('getDisplayNameFromParticipant', () => { ).toBe('user_handle'); }); + it('should not append whitespace for a missing last name', () => { + const participantWithFirstNameOnly = { + displayName: '', + handle: '', + role: MessageParticipantRole.FROM, + person: { name: { firstName: 'John', lastName: '' } }, + } as unknown as EmailThreadMessageParticipant; + + expect( + getDisplayNameFromParticipant({ + participant: participantWithFirstNameOnly, + shouldUseFullName: true, + }), + ).toBe('John'); + }); + + it('should fall back to displayName when the resolved name is empty', () => { + const participantWithEmptyPersonName = { + displayName: 'User123', + handle: '', + role: MessageParticipantRole.FROM, + person: { name: { firstName: '', lastName: '' } }, + } as unknown as EmailThreadMessageParticipant; + + expect( + getDisplayNameFromParticipant({ + participant: participantWithEmptyPersonName, + shouldUseFullName: true, + }), + ).toBe('User123'); + }); + it('should return Unknown if no suitable information is available', () => { expect( getDisplayNameFromParticipant({ participant: participantWithoutInfo }), diff --git a/packages/twenty-front/src/modules/activities/emails/utils/getDisplayNameFromParticipant.ts b/packages/twenty-front/src/modules/activities/emails/utils/getDisplayNameFromParticipant.ts index 90036e80d4..e392c06fd2 100644 --- a/packages/twenty-front/src/modules/activities/emails/utils/getDisplayNameFromParticipant.ts +++ b/packages/twenty-front/src/modules/activities/emails/utils/getDisplayNameFromParticipant.ts @@ -1,7 +1,8 @@ import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant'; -import { isDefined } from 'twenty-shared/utils'; +import { getEmailIdentityDisplayName } from '@/activities/emails/utils/getEmailIdentityDisplayName'; export const getDisplayNameFromParticipant = ({ participant, @@ -10,29 +11,26 @@ export const getDisplayNameFromParticipant = ({ participant: EmailThreadMessageParticipant; shouldUseFullName?: boolean; }) => { - if (isDefined(participant.person)) { - return ( - `${participant.person?.name?.firstName}` + - (shouldUseFullName ? ` ${participant.person?.name?.lastName}` : '') - ); - } + const buildName = (name?: { firstName?: string; lastName?: string }) => { + if (!isDefined(name)) { + return undefined; + } - if (isDefined(participant.workspaceMember)) { - return ( - participant.workspaceMember?.name?.firstName + - (shouldUseFullName - ? ` ${participant.workspaceMember?.name?.lastName}` - : '') - ); - } + const nameParts = shouldUseFullName + ? [name.firstName, name.lastName] + : [name.firstName]; - if (isNonEmptyString(participant.displayName)) { - return participant.displayName; - } + return nameParts.filter(isNonEmptyString).join(' '); + }; - if (isNonEmptyString(participant.handle)) { - return participant.handle; - } - - return 'Unknown'; + return getEmailIdentityDisplayName({ + personName: isDefined(participant.person) + ? buildName(participant.person.name) + : undefined, + workspaceMemberName: isDefined(participant.workspaceMember) + ? buildName(participant.workspaceMember.name) + : undefined, + displayName: participant.displayName, + handle: participant.handle, + }); }; diff --git a/packages/twenty-front/src/modules/activities/emails/utils/getEmailIdentityDisplayName.ts b/packages/twenty-front/src/modules/activities/emails/utils/getEmailIdentityDisplayName.ts new file mode 100644 index 0000000000..ce7bae26b6 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/utils/getEmailIdentityDisplayName.ts @@ -0,0 +1,16 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +export const getEmailIdentityDisplayName = ({ + personName, + workspaceMemberName, + displayName, + handle, +}: { + personName?: string; + workspaceMemberName?: string; + displayName?: string; + handle?: string; +}): string => + [workspaceMemberName, personName, displayName, handle].find( + isNonEmptyString, + ) ?? 'Unknown'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx index 0a2d44a34d..c534c29fae 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx @@ -108,7 +108,7 @@ type BaseChipProps = { selected?: boolean; isFlashing?: boolean; onDoubleClick?: () => void; - maxLabelWidth?: number; + maxWidth?: number; leftIcon?: ReactNode; }; @@ -122,7 +122,7 @@ export const BaseChip = ({ selected = false, isFlashing = false, onDoubleClick, - maxLabelWidth, + maxWidth, leftIcon, }: BaseChipProps) => { const { theme } = useContext(ThemeContext); @@ -138,7 +138,7 @@ export const BaseChip = ({ onDoubleClick={onDoubleClick} > {leftIcon} - + {label} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/safe-parse-email-addresses.util.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/safe-parse-email-addresses.util.spec.ts index 742b1ad78f..5885e99435 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/safe-parse-email-addresses.util.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/safe-parse-email-addresses.util.spec.ts @@ -36,6 +36,24 @@ describe('safeParseEmailAddresses', () => { ]); }); + it('should flatten RFC 5322 address groups into their members', () => { + // Group members previously vanished: addressparser nests them under `group` + // with no top-level address, so the filter dropped the whole entry. + expect( + safeParseEmailAddresses( + 'Team: alice@example.com, Bob ;, carol@example.com', + ), + ).toEqual([ + { address: 'alice@example.com', name: '' }, + { address: 'bob@example.com', name: 'Bob' }, + { address: 'carol@example.com', name: '' }, + ]); + }); + + it('should return nothing for an empty group', () => { + expect(safeParseEmailAddresses('undisclosed-recipients:;')).toEqual([]); + }); + 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. diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util.ts index 2eda05bde9..d5319cc41f 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/safe-parse-email-addresses.util.ts @@ -1,16 +1,10 @@ -import addressparser from 'addressparser'; +import { isNonEmptyString } from '@sniptt/guards'; +import { parseEmailAddressList } from 'twenty-shared/utils'; 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 { - return []; - } + return parseEmailAddressList(header).filter((parsedAddress) => + isNonEmptyString(parsedAddress.address), + ); }; diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/format-message-from-header.util.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/format-message-from-header.util.ts index c5e95915d0..4f06616bda 100644 --- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/format-message-from-header.util.ts +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/format-message-from-header.util.ts @@ -1,4 +1,5 @@ import { isNonEmptyString } from '@sniptt/guards'; +import { formatEmailAddress } from 'twenty-shared/utils'; import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util'; @@ -9,7 +10,8 @@ export const formatMessageFromHeader = ({ fromEmail: string; fromName?: string | null; }) => { - return isNonEmptyString(fromName) - ? `${mimeEncode(fromName)} <${fromEmail}>` - : fromEmail; + return formatEmailAddress({ + address: fromEmail, + name: isNonEmptyString(fromName) ? mimeEncode(fromName) : undefined, + }); }; diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json index 451f13482a..ffa3979d42 100644 --- a/packages/twenty-shared/package.json +++ b/packages/twenty-shared/package.json @@ -21,6 +21,7 @@ "@prettier/sync": "^0.5.2", "@swc/core": "^1.15.11", "@swc/jest": "^0.2.39", + "@types/addressparser": "^1.0.3", "@types/babel__preset-env": "^7", "@types/handlebars": "^4.1.0", "@types/jest": "^30.0.0", @@ -44,6 +45,7 @@ "dependencies": { "@dagrejs/dagre": "^1.1.8", "@sniptt/guards": "^0.2.0", + "addressparser": "1.0.1", "ai": "6.0.97", "class-validator": "^0.14.0", "expr-eval-fork": "3.0.3", diff --git a/packages/twenty-shared/src/utils/email/__tests__/formatEmailAddress.test.ts b/packages/twenty-shared/src/utils/email/__tests__/formatEmailAddress.test.ts new file mode 100644 index 0000000000..0f2fa74d2e --- /dev/null +++ b/packages/twenty-shared/src/utils/email/__tests__/formatEmailAddress.test.ts @@ -0,0 +1,97 @@ +import { formatEmailAddress } from '../formatEmailAddress'; +import { parseEmailAddressList } from '../parseEmailAddressList'; + +describe('formatEmailAddress', () => { + it('should return the bare address when there is no name', () => { + expect(formatEmailAddress({ address: 'alice@example.com' })).toBe( + 'alice@example.com', + ); + expect(formatEmailAddress({ address: 'alice@example.com', name: '' })).toBe( + 'alice@example.com', + ); + }); + + it('should append the name unquoted when it contains no special characters', () => { + expect( + formatEmailAddress({ address: 'alice@example.com', name: 'Alice Doe' }), + ).toBe('Alice Doe '); + }); + + it('should quote names containing special characters', () => { + expect( + formatEmailAddress({ address: 'jd@example.com', name: 'Doe, John' }), + ).toBe('"Doe, John" '); + }); + + it('should escape double quotes inside quoted names', () => { + expect( + formatEmailAddress({ address: 'a@example.com', name: 'A "B" C' }), + ).toBe('"A \\"B\\" C" '); + }); + + it('should quote and escape backslashes so they cannot neutralize quote escaping', () => { + expect( + formatEmailAddress({ address: 'a@example.com', name: 'A \\ B' }), + ).toBe('"A \\\\ B" '); + expect( + formatEmailAddress({ address: 'a@example.com', name: 'x\\"y' }), + ).toBe('"x\\\\\\"y" '); + }); + + it('should keep a backslash-and-quote name inside one recipient when reparsed', () => { + const formatted = formatEmailAddress({ + address: 'a@example.com', + name: 'x\\"y', + }); + const reparsed = parseEmailAddressList(`${formatted}, b@example.com`); + + expect(reparsed).toHaveLength(2); + expect(reparsed[0].address).toBe('a@example.com'); + // addressparser collapses the escaped backslash: containment is the + // contract here, not byte fidelity of exotic display names. + expect(reparsed[0].name).toBe('x"y'); + expect(reparsed[1]).toEqual({ address: 'b@example.com', name: '' }); + }); + + it('should quote names containing group or comment syntax so they round-trip', () => { + const colonFormatted = formatEmailAddress({ + address: 'a@b.com', + name: 'Re: update', + }); + + expect(colonFormatted).toBe('"Re: update" '); + expect(parseEmailAddressList(colonFormatted)).toEqual([ + { address: 'a@b.com', name: 'Re: update' }, + ]); + + const parenthesesFormatted = formatEmailAddress({ + address: 'b@c.com', + name: 'Bob (Sales)', + }); + + expect(parenthesesFormatted).toBe('"Bob (Sales)" '); + expect(parseEmailAddressList(parenthesesFormatted)).toEqual([ + { address: 'b@c.com', name: 'Bob (Sales)' }, + ]); + }); + + it('should not quote RFC 2047 encoded words', () => { + expect( + formatEmailAddress({ + address: 'user@example.com', + name: '=?UTF-8?B?VGVzdCBVc2Vy?=', + }), + ).toBe('=?UTF-8?B?VGVzdCBVc2Vy?= '); + }); + + it('should round-trip through parseEmailAddressList', () => { + const formatted = formatEmailAddress({ + address: 'jd@example.com', + name: 'Doe, John', + }); + + expect(parseEmailAddressList(formatted)).toEqual([ + { address: 'jd@example.com', name: 'Doe, John' }, + ]); + }); +}); diff --git a/packages/twenty-shared/src/utils/email/__tests__/parseEmailAddressList.test.ts b/packages/twenty-shared/src/utils/email/__tests__/parseEmailAddressList.test.ts new file mode 100644 index 0000000000..8dde83f2e9 --- /dev/null +++ b/packages/twenty-shared/src/utils/email/__tests__/parseEmailAddressList.test.ts @@ -0,0 +1,76 @@ +import { parseEmailAddressList } from '../parseEmailAddressList'; + +describe('parseEmailAddressList', () => { + it('should parse a comma-separated list of bare addresses', () => { + expect(parseEmailAddressList('alice@example.com, bob@example.com')).toEqual( + [ + { address: 'alice@example.com', name: '' }, + { address: 'bob@example.com', name: '' }, + ], + ); + }); + + it('should parse display names in angle-bracket form', () => { + expect( + parseEmailAddressList( + 'Alice , "Bob Smith" ', + ), + ).toEqual([ + { address: 'alice@example.com', name: 'Alice' }, + { address: 'bob@example.com', name: 'Bob Smith' }, + ]); + }); + + it('should not split on commas inside quoted display names', () => { + expect( + parseEmailAddressList('"Doe, John" , bob@example.com'), + ).toEqual([ + { address: 'jd@example.com', name: 'Doe, John' }, + { address: 'bob@example.com', name: '' }, + ]); + }); + + it('should split on semicolons', () => { + expect(parseEmailAddressList('alice@example.com; bob@example.com')).toEqual( + [ + { address: 'alice@example.com', name: '' }, + { address: 'bob@example.com', name: '' }, + ], + ); + }); + + it('should flatten address groups into their members', () => { + expect( + parseEmailAddressList( + 'Team: alice@example.com, Bob ;, carol@example.com', + ), + ).toEqual([ + { address: 'alice@example.com', name: '' }, + { address: 'bob@example.com', name: 'Bob' }, + { address: 'carol@example.com', name: '' }, + ]); + }); + + it('should drop empty groups without emitting entries', () => { + expect(parseEmailAddressList('undisclosed-recipients:;')).toEqual([]); + }); + + it('should flatten nested groups recursively', () => { + expect(parseEmailAddressList('Outer: Inner: a@b.com;;, c@d.com')).toEqual([ + { address: 'a@b.com', name: '' }, + { address: 'c@d.com', name: '' }, + ]); + }); + + it('should keep name-only tokens with an empty address', () => { + expect(parseEmailAddressList('NoAddressHere, bob@example.com')).toEqual([ + { address: '', name: 'NoAddressHere' }, + { address: 'bob@example.com', name: '' }, + ]); + }); + + it('should return an empty list for empty input', () => { + expect(parseEmailAddressList('')).toEqual([]); + expect(parseEmailAddressList(' ')).toEqual([]); + }); +}); diff --git a/packages/twenty-shared/src/utils/email/formatEmailAddress.ts b/packages/twenty-shared/src/utils/email/formatEmailAddress.ts new file mode 100644 index 0000000000..d7a50dcab7 --- /dev/null +++ b/packages/twenty-shared/src/utils/email/formatEmailAddress.ts @@ -0,0 +1,22 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +const DISPLAY_NAME_CHARACTERS_REQUIRING_QUOTING = /[()<>[\]:;@\\,."]/; + +export const formatEmailAddress = ({ + address, + name, +}: { + address: string; + name?: string; +}): string => { + if (!isNonEmptyString(name)) { + return address; + } + + const requiresQuoting = DISPLAY_NAME_CHARACTERS_REQUIRING_QUOTING.test(name); + const formattedName = requiresQuoting + ? `"${name.replace(/[\\"]/g, '\\$&')}"` + : name; + + return `${formattedName} <${address}>`; +}; diff --git a/packages/twenty-shared/src/utils/email/parseEmailAddressList.ts b/packages/twenty-shared/src/utils/email/parseEmailAddressList.ts new file mode 100644 index 0000000000..d18d14c90c --- /dev/null +++ b/packages/twenty-shared/src/utils/email/parseEmailAddressList.ts @@ -0,0 +1,35 @@ +import addressparser from 'addressparser'; + +export type ParsedEmailAddress = { + address: string; + name: string; +}; + +type AddressparserEntry = ReturnType[number]; + +const flattenEmailAddressGroups = ( + parsedAddresses: AddressparserEntry[], +): AddressparserEntry[] => + parsedAddresses.flatMap((parsedAddress) => + parsedAddress.group + ? flattenEmailAddressGroups(parsedAddress.group) + : [parsedAddress], + ); + +export const parseEmailAddressList = ( + rawAddressList: string, +): ParsedEmailAddress[] => { + try { + return flattenEmailAddressGroups(addressparser(rawAddressList)) + .map((parsedAddress) => ({ + address: parsedAddress.address ?? '', + name: (parsedAddress.name ?? '').trim(), + })) + .filter( + (parsedAddress) => + parsedAddress.address.length > 0 || parsedAddress.name.length > 0, + ); + } catch { + return []; + } +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 466aecda76..6e50e913ce 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -49,6 +49,9 @@ export { turnJSDateToPlainDate } from './date/turnJSDateToPlainDate'; export { turnPlainDateIntoUserTimeZoneInstantString } from './date/turnPlainDateIntoUserTimeZoneInstantString'; export { turnPlainDateToShiftedDateInSystemTimeZone } from './date/turnPlainDateToShiftedDateInSystemTimeZone'; export { deepMerge } from './deepMerge'; +export { formatEmailAddress } from './email/formatEmailAddress'; +export type { ParsedEmailAddress } from './email/parseEmailAddressList'; +export { parseEmailAddressList } from './email/parseEmailAddressList'; export { CustomError } from './errors/CustomError'; export { evalFromContext } from './evalFromContext'; export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields'; diff --git a/yarn.lock b/yarn.lock index 6c3ead848c..a2b8863543 100644 --- a/yarn.lock +++ b/yarn.lock @@ -52928,7 +52928,6 @@ __metadata: "@tiptap/extensions": "npm:3.4.2" "@tiptap/react": "npm:3.4.2" "@tiptap/suggestion": "npm:3.4.2" - "@types/addressparser": "npm:^1.0.3" "@types/deep-equal": "npm:^1.0.1" "@types/file-saver": "npm:^2.0.7" "@types/jest": "npm:^30.0.0" @@ -52941,7 +52940,6 @@ __metadata: "@vitest/coverage-istanbul": "npm:^4.1.0" "@wyw-in-js/vite": "npm:^1.1.0" "@xyflow/react": "npm:^12.4.2" - addressparser: "npm:1.0.1" ai: "npm:6.0.97" apollo-link-rest: "npm:^0.10.0-rc.2" apollo-upload-client: "npm:^19.0.0" @@ -53322,6 +53320,7 @@ __metadata: "@sniptt/guards": "npm:^0.2.0" "@swc/core": "npm:^1.15.11" "@swc/jest": "npm:^0.2.39" + "@types/addressparser": "npm:^1.0.3" "@types/babel__preset-env": "npm:^7" "@types/handlebars": "npm:^4.1.0" "@types/jest": "npm:^30.0.0" @@ -53330,6 +53329,7 @@ __metadata: "@types/qs": "npm:6.9.16" "@types/uuid": "npm:^9.0.2" "@typescript/native-preview": "npm:^7.0.0-dev.20260116.1" + addressparser: "npm:1.0.1" ai: "npm:6.0.97" babel-plugin-module-resolver: "npm:^5.0.2" class-validator: "npm:^0.14.0"