feat: split a pasted address list into recipient chips

Pasting a list of addresses into To/Cc/Bcc now creates one chip per
address instead of dropping the whole blob in as a single invalid chip.
A paste is split only when it actually contains a separator; a lone
address falls through to normal editing.

- Separators: commas, semicolons, and any whitespace/newline - covers
  comma/space dumps, spreadsheet columns and Outlook-style `;` lists.
- Display names are preserved: `Name <email>`, a fully-quoted
  `"Name <email>"` entry, and `"Doe, John" <email>` (comma inside a
  quoted name) each stay a single chip with the name intact.
- Bare-address runs split per address; a `<addr>` token is unwrapped;
  tokens that aren't valid addresses are left behind in the input for
  the user to fix rather than becoming junk chips.
- Deduped case-insensitively within the paste and against existing chips.

Implemented as splitPastedRecipients in email-composer-utils, layered on
the shared quote/angle-aware splitter: splitRecipients gains an optional
`separators` argument so the composer/mailto serialization boundary
(comma-only) and the paste path (`,;\n\r`) share one implementation.
Wired into the recipient chip input's onPaste handler (To/Cc/Bcc).
This commit is contained in:
Stefan Hildebrandt
2026-06-19 12:30:43 +02:00
committed by Linus Rath
parent 638fc7db4e
commit 344795a8d9
5 changed files with 487 additions and 7 deletions
@@ -9,6 +9,7 @@ import {
parseRecipient,
parseRecipientList,
formatRecipientList,
splitPastedRecipients,
} from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
@@ -150,6 +151,16 @@ describe("splitRecipients", () => {
it("returns an empty array for an empty string", () => {
expect(splitRecipients("")).toEqual([]);
});
it("only splits on the given separators (default comma keeps semicolons/newlines literal)", () => {
expect(splitRecipients("a@x.com; b@y.com")).toEqual(["a@x.com; b@y.com"]);
});
it("splits on a wider separator set while keeping quotes/angles literal", () => {
expect(
splitRecipients('"Doo, John" <john@doo.org>; a@x.com\nb@y.com', ',;\n\r'),
).toEqual(['"Doo, John" <john@doo.org>', "a@x.com", "b@y.com"]);
});
});
describe("formatRecipient / parseRecipient", () => {
@@ -198,3 +209,68 @@ describe("parseRecipientList / formatRecipientList", () => {
expect(parseRecipientList("")).toEqual([]);
});
});
describe("splitPastedRecipients", () => {
it("splits on commas, semicolons and whitespace (incl. newline/tab)", () => {
const { valid, invalid } = splitPastedRecipients(
"a@x.com, b@y.com; c@z.com\nd@w.com\te@v.com f@u.com",
);
expect(valid.map((r) => r.email)).toEqual([
"a@x.com", "b@y.com", "c@z.com", "d@w.com", "e@v.com", "f@u.com",
]);
expect(invalid).toEqual([]);
});
it("collapses runs of mixed separators and drops empties", () => {
const { valid } = splitPastedRecipients(" a@x.com ,; , b@y.com ");
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "b@y.com"]);
});
it("partitions invalid tokens into `invalid`, keeping valid as chips", () => {
const { valid, invalid } = splitPastedRecipients("a@x.com not-an-email b@y.com");
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "b@y.com"]);
expect(invalid).toEqual(["not-an-email"]);
});
it("unwraps an angle-bracketed token before validating", () => {
const { valid } = splitPastedRecipients("<a@x.com>");
expect(valid).toEqual([{ email: "a@x.com" }]);
});
it("keeps a `Name <email>` pair as a single chip with its display name", () => {
const { valid, invalid } = splitPastedRecipients("John Doe <j@x.com>");
expect(valid).toEqual([{ name: "John Doe", email: "j@x.com" }]);
expect(invalid).toEqual([]);
});
it("keeps a fully-quoted `\"Name <email>\"` entry with its display name", () => {
const { valid, invalid } = splitPastedRecipients(
'"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"',
);
expect(valid).toEqual([
{ name: "Alice Smith", email: "alice@x.com" },
{ name: "Alex Smith", email: "alex@x.com" },
]);
expect(invalid).toEqual([]);
});
it("keeps a comma inside a quoted display name intact", () => {
const { valid } = splitPastedRecipients('"Doe, John" <j@x.com>; bob@z.com');
expect(valid).toEqual([
{ name: "Doe, John", email: "j@x.com" },
{ email: "bob@z.com" },
]);
});
it("dedupes case-insensitively within the paste and against existing emails", () => {
const { valid } = splitPastedRecipients(
"a@x.com A@X.com b@y.com c@z.com",
["B@Y.com"],
);
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "c@z.com"]);
});
it("returns empty arrays for blank input", () => {
expect(splitPastedRecipients(" ")).toEqual({ valid: [], invalid: [] });
});
});
+77 -7
View File
@@ -1,3 +1,5 @@
import { isValidEmail } from "@/lib/validation";
const HTML_ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
@@ -56,13 +58,16 @@ export function rewriteCidImagesForEditor(html: string): string {
export type Recipient = { name?: string; email: string };
/**
* Splits a comma-separated recipient string into individual entries. Commas
* inside a quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
* (`<a,b@x>`) are treated as literal, not separators. Only used at the
* (de)serialization boundary — the live composer state is an array, so the UI
* never round-trips through this. Trims each part and drops empties.
* Splits a recipient string into individual entries on any character in
* `separators`, treating those characters as literal when they sit inside a
* quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
* (`<a,b@x>`). Trims each part and drops empties.
*
* Defaults to comma-only, the (de)serialization boundary used by the composer
* state and mailto handling. Pasted lists pass a wider set (see
* {@link splitPasteEntries}) because they also use `;` and line breaks.
*/
export function splitRecipients(value: string): string[] {
export function splitRecipients(value: string, separators = ','): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
@@ -77,7 +82,7 @@ export function splitRecipients(value: string): string[] {
} else if (ch === '>' && !inQuotes) {
inAngle = false;
current += ch;
} else if (ch === ',' && !inQuotes && !inAngle) {
} else if (separators.includes(ch) && !inQuotes && !inAngle) {
const trimmed = current.trim();
if (trimmed) result.push(trimmed);
current = '';
@@ -140,6 +145,71 @@ export function formatRecipientList(recipients: Recipient[]): string {
return recipients.map((r) => formatRecipient(r.name, r.email)).join(', ');
}
/**
* Top-level split of a pasted block into recipient entries on commas,
* semicolons and newlines (separators inside a quoted name or angle brackets
* stay literal). Broader than the comma-only default of {@link splitRecipients}
* because pasted lists also use `;` and line breaks as separators.
*/
function splitPasteEntries(value: string): string[] {
return splitRecipients(value, ',;\n\r');
}
/**
* Splits pasted text into recipient candidates and partitions them: valid email
* addresses become `Recipient`s (deduped case-insensitively against
* `existingEmails` and within the paste), and everything else is returned as
* `invalid` for the caller to drop back into the input field.
*
* Handles both structured and bare lists, preserving display names:
* - `"Name <email>"` (the whole recipient quoted), `Name <email>`, and
* `"Doe, John" <email>` entries are kept intact with their display name.
* - Bare-address dumps (`a@x.com b@y.com`, spreadsheet columns, comma/space/
* semicolon/newline separated) split into one chip per address.
* - A token wrapped in angle brackets (`<a@x.com>`) is unwrapped before
* validating, so an `a <a@x.com>` fragment still yields the address.
*/
export function splitPastedRecipients(
text: string,
existingEmails: string[] = [],
): { valid: Recipient[]; invalid: string[] } {
const seen = new Set(existingEmails.map((e) => e.toLowerCase()));
const valid: Recipient[] = [];
const invalid: string[] = [];
// Adds a recipient if its address is valid and unseen. Returns true when the
// entry is fully handled (valid or a known duplicate) so the caller can stop.
const tryAdd = (r: Recipient): boolean => {
if (!isValidEmail(r.email)) return false;
const key = r.email.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
valid.push(r.name ? { name: r.name, email: r.email } : { email: r.email });
}
return true;
};
// Split on commas, semicolons and newlines in a quote/angle-aware way so a
// `"Doe, John" <j@x.com>` or fully-quoted `"Name <email>"` entry stays a
// single recipient (separators inside the name or the address are literal).
for (const entry of splitPasteEntries(text)) {
// 1. Structured: `Name <email>`, a bare address, or the whole
// `Name <email>` wrapped in quotes (unwrap once and retry).
if (tryAdd(parseRecipient(entry))) continue;
const unwrapped = unquoteName(entry);
if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue;
// 2. Fallback: a bare-address run (`a@x.com b@y.com`) or a
// `John Doe <j@x.com>` fragment where only the <addr> is valid.
// Whitespace/semicolon-tokenize; leftover tokens stay behind.
for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) {
if (!tryAdd({ email: token.replace(/^<|>$/g, '') })) invalid.push(token);
}
}
return { valid, invalid };
}
/**
* Replaces the placeholder src on `<img data-cid="...">` elements with the
* resolved data URL once the inline blob has been fetched. Leaves images