diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts index 00e9b1b22..7f0aaa90e 100644 --- a/lib/__tests__/email-composer-utils.test.ts +++ b/lib/__tests__/email-composer-utils.test.ts @@ -289,6 +289,23 @@ describe("splitRecipients", () => { expect(splitRecipients("")).toEqual([]); }); + it("still splits when an angle run never closes", () => { + expect( + splitRecipients("Ap Reinders { + expect(splitRecipients("Ap ")).toEqual([ + "Ap ", + ]); + }); + 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"]); }); @@ -410,6 +427,24 @@ describe("splitPastedRecipients", () => { it("returns empty arrays for blank input", () => { expect(splitPastedRecipients(" ")).toEqual({ valid: [], invalid: [] }); }); + + it("recovers every recipient from a list whose closing brackets are missing", () => { + const { valid, invalid } = splitPastedRecipients( + "Ap Reinders { + const { valid, invalid } = splitPastedRecipients("John Doe { diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts index abff6ed46..f61985c57 100644 --- a/lib/email-composer-utils.ts +++ b/lib/email-composer-utils.ts @@ -1,4 +1,5 @@ import { isValidEmail } from "@/lib/validation"; +import { splitMailbox } from "@/lib/rfc5322-mailbox"; import { htmlToPlainText } from "@/lib/html-to-text"; import { emailHooks } from "@/lib/plugin-hooks"; import { Ellipsis, Lock, TriangleAlert } from "lucide-react"; @@ -241,6 +242,18 @@ export async function enrichChipsWithColorsAndIcons(chips: Recipient[]): Promise return await emailHooks.onRecipientChipsChange.transform(chips); }; +/** + * True when the angle run open at `from` closes before the next one opens. A + * `<` with no `>` of its own (or whose `>` sits past a later `<`) can never be + * an address delimiter, so separators after it must stay separators. + */ +function angleRunCloses(value: string, from: number): boolean { + const close = value.indexOf('>', from); + if (close === -1) return false; + const nextOpen = value.indexOf('<', from); + return nextOpen === -1 || close < nextOpen; +} + /** * Splits a recipient string into individual entries on any character in * `separators`, treating those characters as literal when they sit inside a @@ -257,7 +270,8 @@ export function splitRecipients(value: string, separators = ','): string[] { let inQuotes = false; let inAngle = false; let inGroup = false; - for (const ch of value) { + for (let i = 0; i < value.length; i++) { + const ch = value[i]; if (ch === '"') { inQuotes = !inQuotes; current += ch; @@ -267,6 +281,13 @@ export function splitRecipients(value: string, separators = ','): string[] { } else if (ch === '>' && !inQuotes) { inAngle = false; current += ch; + } else if (separators.includes(ch) && inAngle && !inQuotes && !angleRunCloses(value, i)) { + // An unclosed `<` would otherwise swallow every later separator, folding + // the whole list into one entry that is not an address at all. + inAngle = false; + const trimmed = current.trim(); + if (trimmed) result.push(trimmed); + current = ''; } else if (ch === ':' && !inQuotes && !inAngle) { // RFC 5322 group syntax ("Team: a@x, b@y;") - keep the whole group, // separators inside it included, as a single entry. A colon inside a @@ -429,6 +450,8 @@ function splitPasteEntries(value: string): string[] { * semicolon/newline separated) split into one chip per address. * - A token wrapped in angle brackets (``) is unwrapped before * validating, so an `a ` fragment still yields the address. + * - `Name `, so the display name survives the paste. + if (tryAdd(splitMailbox(entry))) continue; + + // 3. Fallback: a bare-address run (`a@x.com b@y.com`) or a // `John Doe ` fragment where only the is valid. // Whitespace/semicolon-tokenize; leftover tokens stay behind. for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) {