refactor: model composer recipients as arrays instead of delimited strings

Alternative to the quote-aware string fix: represent committed To/Cc/Bcc
recipients as Recipient[] ({name?, email}) with a separate input-text
string per field, instead of a single comma-joined string parsed with
split(','). Structured recipients can never be torn apart on a delimiter,
so a display name containing a comma ("Doo, John <john@doo.org>", as
produced on Reply-All) stays a single chip.

- email-composer-utils: add Recipient type, parseRecipient/formatRecipient,
  and parseRecipientList/formatRecipientList for the (de)serialization
  boundary (ComposerDraftData stays a string; quoting keeps it lossless).
  Remove the now-unused string-chip helpers.
- email-composer: to/cc/bcc are Recipient[]; toInput/ccInput/bccInput hold
  the in-progress text. Reply/forward init, autocomplete, chip edit, drag &
  drop (payload now carries the structured recipient), send/draft/validation
  and template paths all operate on arrays. withInput() folds uncommitted
  typed text into the send/validation set.
- Tests updated for the array contract; add comma-in-name chip coverage.
This commit is contained in:
Stefan Hildebrandt
2026-06-15 22:59:46 +02:00
committed by Linus Rath
parent c51c3655d5
commit 94b1f5aa48
4 changed files with 337 additions and 254 deletions
+66 -39
View File
@@ -4,8 +4,11 @@ import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
INLINE_IMAGE_PLACEHOLDER,
removeChipFromFieldValue,
addChipToFieldValue,
splitRecipients,
formatRecipient,
parseRecipient,
parseRecipientList,
formatRecipientList,
} from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
@@ -115,59 +118,83 @@ describe("replaceInlineImagePlaceholders", () => {
});
});
describe("removeChipFromFieldValue", () => {
it("removes the target chip and preserves others", () => {
const result = removeChipFromFieldValue("alice@example.com, bob@example.com, ", "alice@example.com");
expect(result).toBe("bob@example.com, ");
describe("splitRecipients", () => {
it("splits a plain comma-separated list", () => {
expect(splitRecipients("alice@x.com, bob@x.com")).toEqual([
"alice@x.com",
"bob@x.com",
]);
});
it("removes a chip with a display name", () => {
const result = removeChipFromFieldValue("Alice <alice@example.com>, bob@example.com, ", "Alice <alice@example.com>");
expect(result).toBe("bob@example.com, ");
it("trims whitespace and drops empty segments", () => {
expect(splitRecipients(" alice@x.com ,, bob@x.com ,")).toEqual([
"alice@x.com",
"bob@x.com",
]);
});
it("handles removing the only chip", () => {
const result = removeChipFromFieldValue("alice@example.com, ", "alice@example.com");
expect(result).toBe("");
it("keeps a quoted display name containing a comma intact", () => {
expect(splitRecipients('"Doo, John" <john@doo.org>, alice@x.com')).toEqual([
'"Doo, John" <john@doo.org>',
"alice@x.com",
]);
});
it("returns the value unchanged when chip is not found", () => {
const value = "alice@example.com, bob@example.com, ";
expect(removeChipFromFieldValue(value, "carol@example.com")).toBe(value);
it("does not split on a comma inside angle brackets", () => {
expect(splitRecipients("Group <a,b@x.com>, c@x.com")).toEqual([
"Group <a,b@x.com>",
"c@x.com",
]);
});
it("preserves in-progress input text after removing a chip", () => {
const result = removeChipFromFieldValue("alice@example.com, bob@example.com, car", "alice@example.com");
expect(result).toBe("bob@example.com, car");
});
it("handles an empty field value", () => {
expect(removeChipFromFieldValue("", "alice@example.com")).toBe("");
});
it("removes only the first occurrence when chip appears multiple times", () => {
const result = removeChipFromFieldValue("alice@example.com, alice@example.com, bob@example.com, ", "alice@example.com");
expect(result).toBe("alice@example.com, bob@example.com, ");
it("returns an empty array for an empty string", () => {
expect(splitRecipients("")).toEqual([]);
});
});
describe("addChipToFieldValue", () => {
it("appends a chip to a field with existing chips", () => {
const result = addChipToFieldValue("alice@example.com, ", "bob@example.com");
expect(result).toBe("alice@example.com, bob@example.com, ");
describe("formatRecipient / parseRecipient", () => {
it("returns a bare email when there is no name", () => {
expect(formatRecipient(undefined, "a@x.com")).toBe("a@x.com");
});
it("appends a chip to an empty field", () => {
expect(addChipToFieldValue("", "alice@example.com")).toBe("alice@example.com, ");
it("returns a bare email when the name equals the email", () => {
expect(formatRecipient("a@x.com", "a@x.com")).toBe("a@x.com");
});
it("preserves in-progress input text when appending", () => {
const result = addChipToFieldValue("alice@example.com, bob", "carol@example.com");
expect(result).toBe("alice@example.com, carol@example.com, bob");
it("formats a simple name without quoting", () => {
expect(formatRecipient("Alice", "a@x.com")).toBe("Alice <a@x.com>");
});
it("appends a chip with a display name", () => {
const result = addChipToFieldValue("alice@example.com, ", "Bob <bob@example.com>");
expect(result).toBe("alice@example.com, Bob <bob@example.com>, ");
it("quotes a name containing a comma", () => {
expect(formatRecipient("Doo, John", "john@doo.org")).toBe(
'"Doo, John" <john@doo.org>'
);
});
it("parses a bare email", () => {
expect(parseRecipient("a@x.com")).toEqual({ email: "a@x.com" });
});
it("parses and unquotes a quoted comma name", () => {
expect(parseRecipient('"Doo, John" <john@doo.org>')).toEqual({
name: "Doo, John",
email: "john@doo.org",
});
});
});
describe("parseRecipientList / formatRecipientList", () => {
it("round-trips a comma-name recipient through serialize + parse", () => {
const list = [
{ name: "Doo, John", email: "john@doo.org" },
{ email: "alice@x.com" },
];
const serialized = formatRecipientList(list);
expect(serialized).toBe('"Doo, John" <john@doo.org>, alice@x.com');
expect(parseRecipientList(serialized)).toEqual(list);
});
it("parses an empty string to an empty array", () => {
expect(parseRecipientList("")).toEqual([]);
});
});
+78 -23
View File
@@ -52,37 +52,92 @@ export function rewriteCidImagesForEditor(html: string): string {
return touched ? doc.body.innerHTML : html;
}
/** A composer recipient. Display name is optional; email is required. */
export type Recipient = { name?: string; email: string };
/**
* Parses the chip array and trailing in-progress input text from a
* comma-separated recipient field value (e.g. "Alice <a@x.com>, bob@x.com, b").
* A trailing comma means "bob@x.com" is a committed chip and "b" is the live input.
* 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.
*/
function parseFieldValue(fieldValue: string): { chips: string[]; inputText: string } {
const allParts = fieldValue.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = fieldValue.trimEnd().endsWith(',');
const chips = hasTrailingComma ? allParts : allParts.slice(0, -1);
const inputText = hasTrailingComma ? '' : (allParts[allParts.length - 1] ?? '');
return { chips, inputText };
export function splitRecipients(value: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
let inAngle = false;
for (const ch of value) {
if (ch === '"') {
inQuotes = !inQuotes;
current += ch;
} else if (ch === '<' && !inQuotes) {
inAngle = true;
current += ch;
} else if (ch === '>' && !inQuotes) {
inAngle = false;
current += ch;
} else if (ch === ',' && !inQuotes && !inAngle) {
const trimmed = current.trim();
if (trimmed) result.push(trimmed);
current = '';
} else {
current += ch;
}
}
const trimmed = current.trim();
if (trimmed) result.push(trimmed);
return result;
}
function buildFieldValue(chips: string[], inputText: string): string {
if (chips.length === 0) return inputText;
return chips.join(', ') + ', ' + inputText;
// Display names containing any of these must be wrapped in a quoted-string so
// they survive comma-splitting at the serialization boundary and round-trip.
const NAME_NEEDS_QUOTING = /[,<>"@;:]/;
/**
* Formats a recipient as a string. Bare email when there's no distinct name;
* otherwise `Name <email>`, RFC 5322 quoting the name when it contains a comma
* or other special character.
*/
export function formatRecipient(name: string | undefined, email: string): string {
const trimmedName = name?.trim();
if (!trimmedName || trimmedName === email) return email;
const quoted = NAME_NEEDS_QUOTING.test(trimmedName)
? `"${trimmedName.replace(/(["\\])/g, '\\$1')}"`
: trimmedName;
return `${quoted} <${email}>`;
}
/** Removes the first occurrence of `chip` from a recipient field value string. */
export function removeChipFromFieldValue(fieldValue: string, chip: string): string {
const { chips, inputText } = parseFieldValue(fieldValue);
const idx = chips.indexOf(chip);
if (idx === -1) return fieldValue;
const remaining = chips.filter((_, i) => i !== idx);
return buildFieldValue(remaining, inputText);
/** Strips a surrounding quoted-string (and its escapes) from a display name. */
function unquoteName(name: string): string {
const trimmed = name.trim();
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1');
}
return trimmed;
}
/** Appends `chip` as a committed entry to a recipient field value string. */
export function addChipToFieldValue(fieldValue: string, chip: string): string {
const { chips, inputText } = parseFieldValue(fieldValue);
return buildFieldValue([...chips, chip], inputText);
/**
* Parses a single recipient string (`Name <email>`, `"Quoted, Name" <email>`,
* or bare `email`) into a {@link Recipient}. The display name is unquoted.
*/
export function parseRecipient(s: string): Recipient {
const trimmed = s.trim();
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
if (angleMatch) {
return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() };
}
return { email: trimmed };
}
/** Parses a serialized comma-separated recipient string into an array. */
export function parseRecipientList(value: string): Recipient[] {
return splitRecipients(value).map(parseRecipient);
}
/** Serializes a recipient array into a comma-separated string. */
export function formatRecipientList(recipients: Recipient[]): string {
return recipients.map((r) => formatRecipient(r.name, r.email)).join(', ');
}
/**