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.
This commit is contained in:
Félix Malfait
2026-07-17 21:20:14 +02:00
committed by GitHub
parent ccbd3b6c46
commit 7e133a4930
19 changed files with 430 additions and 140 deletions
-2
View File
@@ -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",
@@ -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();
@@ -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<string>();
const dedupedRecordSuggestions = [
...peopleSuggestions,
...memberSuggestions,
].filter((suggestion) => {
const dedupedRecordSuggestions = recordSuggestions.filter((suggestion) => {
const recipientKey = getEmailRecipientKey(suggestion.recipient.address);
if (seenRecipientKeys.has(recipientKey)) {
@@ -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,
});
@@ -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 },
);
};
@@ -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 }),
@@ -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,
});
};
@@ -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';
@@ -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}
<StyledLabel title={title ?? label} maxWidth={maxLabelWidth}>
<StyledLabel title={title ?? label} maxWidth={maxWidth}>
{label}
</StyledLabel>
@@ -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 <bob@example.com>;, 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.
@@ -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),
);
};
@@ -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,
});
};
+2
View File
@@ -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",
@@ -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 <alice@example.com>');
});
it('should quote names containing special characters', () => {
expect(
formatEmailAddress({ address: 'jd@example.com', name: 'Doe, John' }),
).toBe('"Doe, John" <jd@example.com>');
});
it('should escape double quotes inside quoted names', () => {
expect(
formatEmailAddress({ address: 'a@example.com', name: 'A "B" C' }),
).toBe('"A \\"B\\" C" <a@example.com>');
});
it('should quote and escape backslashes so they cannot neutralize quote escaping', () => {
expect(
formatEmailAddress({ address: 'a@example.com', name: 'A \\ B' }),
).toBe('"A \\\\ B" <a@example.com>');
expect(
formatEmailAddress({ address: 'a@example.com', name: 'x\\"y' }),
).toBe('"x\\\\\\"y" <a@example.com>');
});
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" <a@b.com>');
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)" <b@c.com>');
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?= <user@example.com>');
});
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' },
]);
});
});
@@ -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 <alice@example.com>, "Bob Smith" <bob@example.com>',
),
).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" <jd@example.com>, 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 <bob@example.com>;, 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([]);
});
});
@@ -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}>`;
};
@@ -0,0 +1,35 @@
import addressparser from 'addressparser';
export type ParsedEmailAddress = {
address: string;
name: string;
};
type AddressparserEntry = ReturnType<typeof addressparser>[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 [];
}
};
@@ -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';
+2 -2
View File
@@ -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"