fix: do not drop recipients while recovering unclosed angle runs

Review follow-ups on the unclosed-angle split:

- The paste path fell back to splitMailbox for every entry, which keeps the
  last angle run only and folds everything ahead of it into a display name.
  `Ann <a@x.com Bob <b@y.com` became one chip and a@x.com disappeared without
  even landing in `invalid`. Take that path only when the prefix is not an
  address itself.
- The new separator branch ignored `inGroup`, so an unclosed `<` inside RFC
  5322 group syntax shattered the group its sibling branch keeps intact.
- angleRunCloses now scans quote-aware, so a `>` inside a quoted display name
  no longer counts as closing the run, and it starts past `from` so the helper
  is correct whether it gets the separator or the opening `<`.
This commit is contained in:
Linus Rath
2026-08-16 22:25:15 +02:00
parent 149e16ec4a
commit abc800eab5
2 changed files with 54 additions and 11 deletions
@@ -306,6 +306,19 @@ describe("splitRecipients", () => {
]);
});
it("does not count a `>` inside a quoted display name as closing the run", () => {
expect(splitRecipients('Ap <ap@x.com, "b>c" <b@y.com>')).toEqual([
"Ap <ap@x.com",
'"b>c" <b@y.com>',
]);
});
it("keeps a group intact even when a member's angle run never closes", () => {
expect(splitRecipients("Team: Ann <a@x.com, Bob <b@y.com;")).toEqual([
"Team: Ann <a@x.com, Bob <b@y.com;",
]);
});
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"]);
});
@@ -445,6 +458,14 @@ describe("splitPastedRecipients", () => {
expect(valid).toEqual([{ name: "John Doe", email: "j@x.com" }]);
expect(invalid).toEqual([]);
});
it("keeps both addresses when one entry carries two unclosed mailboxes", () => {
// The name-preserving path keeps the last angle run only, so it must not
// claim an entry whose prefix is an address itself - that would drop it.
const { valid, invalid } = splitPastedRecipients("Ann <a@x.com Bob <b@y.com");
expect(valid).toEqual([{ email: "a@x.com" }, { email: "b@y.com" }]);
expect(invalid).toEqual(["Ann", "Bob"]);
});
});
describe("waitForPendingUploads", () => {
+33 -11
View File
@@ -243,15 +243,23 @@ export async function enrichChipsWithColorsAndIcons(chips: Recipient[]): Promise
};
/**
* 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.
* True when the angle run that is open at `from` closes before the next one
* opens. `from` is the index of the character under test (a separator, or the
* opening `<` itself); the scan starts after it either way. A `>` inside a
* quoted display name does not close anything, and 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;
let inQuotes = false;
for (let i = from + 1; i < value.length; i++) {
const ch = value[i];
if (ch === '"') inQuotes = !inQuotes;
else if (inQuotes) continue;
else if (ch === '>') return true;
else if (ch === '<') return false;
}
return false;
}
/**
@@ -281,9 +289,10 @@ 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)) {
} else if (separators.includes(ch) && inAngle && !inQuotes && !inGroup && !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.
// the whole list into one entry that is not an address at all. Inside a
// group the separators belong to the group, so `inGroup` still wins.
inAngle = false;
const trimmed = current.trim();
if (trimmed) result.push(trimmed);
@@ -437,6 +446,11 @@ function splitPasteEntries(value: string): string[] {
return splitRecipients(value, ',;\n\r');
}
/** True when a whitespace/semicolon token of `value` is an address in its own right. */
function carriesAddress(value: string): boolean {
return value.split(/[\s;]+/).some((t) => isValidEmail(t.trim().replace(/^<|>$/g, '')));
}
/**
* Splits pasted text into recipient candidates and partitions them: valid email
* addresses become `Recipient`s (deduped case-insensitively against
@@ -484,8 +498,16 @@ export function splitPastedRecipients(
if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue;
// 2. `Name <email` with the closing bracket lost - splitMailbox tolerates
// the missing `>`, so the display name survives the paste.
if (tryAdd(splitMailbox(entry))) continue;
// the missing `>`, so the display name survives the paste. It keeps the
// last angle run only, so everything ahead of it would become display
// name: skip this step when that prefix carries an address of its own
// (`a@x.com <b@y.com`) rather than silently swallowing it.
const lastOpenAngle = entry.lastIndexOf('<');
if (
lastOpenAngle !== -1 &&
!carriesAddress(entry.slice(0, lastOpenAngle)) &&
tryAdd(splitMailbox(entry))
) continue;
// 3. 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.