fix: stop sending a contact group whose member is not an address #795

This commit is contained in:
Linus Rath
2026-10-17 19:17:52 +02:00
parent bfe95e0ebd
commit 89ffb7270a
4 changed files with 43 additions and 6 deletions
@@ -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(<EmailComposer initialData={{ ...DRAFT, to: 'Team: bob@example.com, junk;' }} onSend={onSend} />);
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(<EmailComposer initialData={{ ...DRAFT, to: 'bob@example.com' }} onSend={onSend} />);
+4 -3
View File
@@ -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 (
@@ -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' },
+15 -3
View File
@@ -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