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