fix: split recipient lists whose angle brackets never close

A `<` with no matching `>` latched splitRecipients into "inside an
address" for the rest of the string, so every later separator was kept
literal and the whole list collapsed into a single entry. Pasting

    Ap Reinders <ap@x.com, Erwin Beets <erwin@x.com, jaco@x.com

therefore dropped ap@ and erwin@ into `invalid` and kept only jaco@.
The text left behind in the input can then be committed as one chip
whose address is the entire list, which reaches the server as a single
malformed mailbox and gets the message rejected at DATA:

    550 (0.0.0) 'Invalid To email address'

Treat a separator as a separator when the open angle run cannot close
before the next one opens, and fall back to splitMailbox so a
`Name <email` entry keeps its display name instead of falling apart
into bare words.
This commit is contained in:
Bram Buijs
2026-08-12 16:17:39 +02:00
parent 6d827e430f
commit 149e16ec4a
2 changed files with 64 additions and 2 deletions
@@ -289,6 +289,23 @@ describe("splitRecipients", () => {
expect(splitRecipients("")).toEqual([]);
});
it("still splits when an angle run never closes", () => {
expect(
splitRecipients("Ap Reinders <ap@x.com, Erwin Beets <erwin@x.com, jaco@x.com"),
).toEqual([
"Ap Reinders <ap@x.com",
"Erwin Beets <erwin@x.com",
"jaco@x.com",
]);
});
it("splits when only the last entry closes its angle brackets", () => {
expect(splitRecipients("Ap <ap@x.com, Bob <bob@y.com>")).toEqual([
"Ap <ap@x.com",
"Bob <bob@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"]);
});
@@ -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 <ap@x.com, Erwin Beets <erwin@x.com, jaco@x.com",
);
expect(valid).toEqual([
{ name: "Ap Reinders", email: "ap@x.com" },
{ name: "Erwin Beets", email: "erwin@x.com" },
{ email: "jaco@x.com" },
]);
expect(invalid).toEqual([]);
});
it("keeps the display name of a `Name <email` entry missing its bracket", () => {
const { valid, invalid } = splitPastedRecipients("John Doe <j@x.com");
expect(valid).toEqual([{ name: "John Doe", email: "j@x.com" }]);
expect(invalid).toEqual([]);
});
});
describe("waitForPendingUploads", () => {
+29 -2
View File
@@ -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 (`<a@x.com>`) is unwrapped before
* validating, so an `a <a@x.com>` fragment still yields the address.
* - `Name <email` entries that lost their closing bracket keep both the name
* and the address instead of falling apart into bare words.
*/
export function splitPastedRecipients(
text: string,
@@ -460,7 +483,11 @@ export function splitPastedRecipients(
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
// 2. `Name <email` with the closing bracket lost - splitMailbox tolerates
// the missing `>`, 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 <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)) {