From 89ffb7270a6ed006681c73589e5bb1d7ac9348cc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:28:25 +0200 Subject: [PATCH] fix: stop sending a contact group whose member is not an address #795 --- .../composer-recipient-validation.test.tsx | 13 +++++++++++++ components/email/email-composer.tsx | 7 ++++--- lib/__tests__/email-composer-utils.test.ts | 11 +++++++++++ lib/email-composer-utils.ts | 18 +++++++++++++++--- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/components/email/__tests__/composer-recipient-validation.test.tsx b/components/email/__tests__/composer-recipient-validation.test.tsx index de2414ee6..5f250a716 100644 --- a/components/email/__tests__/composer-recipient-validation.test.tsx +++ b/components/email/__tests__/composer-recipient-validation.test.tsx @@ -256,6 +256,19 @@ describe('composer recipient validation', () => { expect(toastError).toHaveBeenCalledWith('validation.invalid_recipients'); }); + it('does not send a group whose member is not an address and names the member', async () => { + const onSend = vi.fn(); + render(); + + expect(screen.getByText('Team (2)').closest('[data-invalid]')).not.toBeNull(); + + fireEvent.click(sendButton()); + await act(async () => {}); + + expect(onSend).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation.invalid_recipients'); + }); + it('sends when every recipient is an address', async () => { const onSend = vi.fn(); render(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index afda215ab..6983aa4b6 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -105,6 +105,7 @@ import { formatRecipientList, expandRecipients, splitPastedRecipients, + invalidRecipientAddresses, defaultSuggestionIndex, waitForPendingUploads, extractUserAuthoredText, @@ -2881,11 +2882,11 @@ export function EmailComposer({ // Catch addresses the server would reject before asking it. Its answer // ("No recipients found in email") does not say which one is wrong. - const invalidRecipients = [ + const invalidRecipients = invalidRecipientAddresses([ ...withInput(to, toInput), ...withInput(cc, ccInput), ...withInput(bcc, bccInput), - ].filter(r => !r.group && !isValidEmail(r.email)).map(r => r.email); + ]); if (invalidRecipients.length > 0) { toast.error(t('validation.invalid_recipients', { addresses: invalidRecipients.join(', ') })); return; @@ -4999,7 +5000,7 @@ function RecipientChipInput({ if(chip.extra?.icon){ IconComponent = ICON_MAP[chip.extra?.icon]; } - const isInvalid = !chip.group && !isValidEmail(chip.email); + const isInvalid = invalidRecipientAddresses([chip]).length > 0; const customColor = isInvalid ? 'destructive' : chip.extra?.color; return ( diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts index 117614a79..c835f0963 100644 --- a/lib/__tests__/email-composer-utils.test.ts +++ b/lib/__tests__/email-composer-utils.test.ts @@ -16,6 +16,7 @@ import { extractUserAuthoredText, formatRecipientEntry, expandRecipients, + invalidRecipientAddresses, getQuoteBodies, } from '../email-composer-utils'; @@ -594,6 +595,16 @@ describe('contact group recipients (RFC 5322 group syntax)', () => { expect(parsed[1].group?.members).toHaveLength(2); }); + it('keeps typed text with a colon as a plain recipient when no member is an address', () => { + expect(parseRecipient('Subject: hello;')).toEqual({ email: 'Subject: hello;' }); + }); + + it('lists group members that are not addresses as invalid', () => { + const parsed = parseRecipientList('ok@x.de, Team: a@x.de, junk;, nope'); + expect(invalidRecipientAddresses(parsed)).toEqual(['junk', 'nope']); + expect(invalidRecipientAddresses([group])).toEqual([]); + }); + it('expandRecipients flattens groups and dedupes against individuals', () => { const expanded = expandRecipients([ { name: 'Anna Alt', email: 'ANNA@example.com' }, diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts index a3cc1c1b6..0ac4babef 100644 --- a/lib/email-composer-utils.ts +++ b/lib/email-composer-utils.ts @@ -383,9 +383,9 @@ export function parseRecipient(s: string): Recipient { const members = splitRecipients(trimmed.slice(colon + 1, -1)) .map(parseRecipient) .filter((m) => m.email && !m.group); - // Only accept the group form when it actually carries members - typed - // garbage like "Subject: hello;" stays a plain (invalid) recipient. - if (members.length > 0) { + // Only accept the group form when at least one member is an address - + // typed garbage like "Subject: hello;" stays a plain (invalid) recipient. + if (members.some((m) => isValidEmail(m.email))) { return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } }; } } @@ -462,6 +462,18 @@ export function expandRecipients(recipients: Recipient[]): Recipient[] { return out; } +/** + * The entries of `recipients` that are not email addresses, group members + * included. Sending any of them puts a malformed mailbox into the header, + * which relays reject only after the whole message went through (#795). + */ +export function invalidRecipientAddresses(recipients: Recipient[]): string[] { + return recipients + .flatMap((r) => (r.group ? r.group.members : [r])) + .filter((r) => !isValidEmail(r.email)) + .map((r) => r.email); +} + /** * Top-level split of a pasted block into recipient entries on commas, * semicolons and newlines (separators inside a quoted name or angle brackets