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"