mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user