From fbca6cb728de4122dbfa30fe141b8d447fa5170b Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 28 Sep 2026 08:09:09 +0200 Subject: [PATCH] Compose: typed text became a recipient without being an address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blurring the To/Cc/Bcc field (or Enter, or a comma) committed whatever was typed — a name like "John" became a recipient that could never be delivered. Only valid addresses ("a@x.com" or "Name ") now become chips; anything else stays in the input, marked red, to fix or complete from the suggestions. A pasted list keeps just its invalid parts. Co-Authored-By: Claude Opus 5.5 --- .../src/components/compose/RecipientChips.tsx | 45 ++++++++-- frontend/src/test/recipientChips.test.tsx | 86 +++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 frontend/src/test/recipientChips.test.tsx diff --git a/frontend/src/components/compose/RecipientChips.tsx b/frontend/src/components/compose/RecipientChips.tsx index 09e2905..85ef97c 100644 --- a/frontend/src/components/compose/RecipientChips.tsx +++ b/frontend/src/components/compose/RecipientChips.tsx @@ -17,6 +17,13 @@ function parseAddr(token: string): { name: string; email: string } { return { name: "", email: token }; } +// Something that can actually be sent to: a bare address or "Name
", +// with a dotted domain. A typed name alone ("John") is not one. +const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/; +export function isValidRecipient(token: string): boolean { + return EMAIL.test(parseAddr(token.trim()).email.trim()); +} + // A recipient field rendering committed addresses as removable chips with an // inline autocomplete input (Fastmail-style). The value stays a comma-joined // string ("Name " or bare email) so the draft payload is unchanged. @@ -38,16 +45,34 @@ export function RecipientChips({ const [draft, setDraft] = useState(""); const [suggestions, setSuggestions] = useState([]); const [open, setOpen] = useState(false); + // Typed text that is not an address, left in the input (red) rather than + // turned into a recipient that could never be delivered. + const [invalid, setInvalid] = useState(false); const boxRef = useRef(null); - const commit = (token: string) => { - const tok = token.trim().replace(/,+$/, "").trim(); - if (!tok) return; - onChange([...chips, tok].join(", ")); + // A chosen suggestion is an address by construction. + const pick = (token: string) => { + onChange([...chips, token].join(", ")); setDraft(""); + setInvalid(false); setOpen(false); setSuggestions([]); }; + + // Typed text becomes chips only where it is a valid address; a paste of + // several ("a@x.com, John") keeps the rest in the input to fix or complete. + const commit = (text: string) => { + const tokens = splitList(text); + const good = tokens.filter(isValidRecipient); + const bad = tokens.filter((tok) => !isValidRecipient(tok)); + if (good.length) onChange([...chips, ...good].join(", ")); + setDraft(bad.join(", ")); + setInvalid(bad.length > 0); + if (!bad.length) { + setOpen(false); + setSuggestions([]); + } + }; const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", ")); useEffect(() => { @@ -106,12 +131,18 @@ export function RecipientChips({ ); })} setDraft(e.target.value)} + onChange={(e) => { + setDraft(e.target.value); + setInvalid(false); + }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); @@ -134,7 +165,7 @@ export function RecipientChips({ className="flex w-full flex-col px-3 py-1.5 text-left hover:bg-app-hover" onMouseDown={(e) => { e.preventDefault(); - commit(s.name ? `${s.name} <${s.email}>` : s.email); + pick(s.name ? `${s.name} <${s.email}>` : s.email); }} > {s.name && {s.name}} diff --git a/frontend/src/test/recipientChips.test.tsx b/frontend/src/test/recipientChips.test.tsx new file mode 100644 index 0000000..e1e68b7 --- /dev/null +++ b/frontend/src/test/recipientChips.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { + isValidRecipient, + RecipientChips, +} from "../components/compose/RecipientChips"; +import { I18nProvider } from "../i18n"; + +vi.mock("../lib/api", () => ({ + api: { suggestRecipients: vi.fn().mockResolvedValue([]) }, +})); + +// A controlled field, reporting every value it is given. +function Field({ seen }: { seen: string[] }) { + const [value, setValue] = useState(""); + return ( + + { + seen.push(v); + setValue(v); + }} + /> + + ); +} + +function type(text: string) { + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: text } }); + return input as HTMLInputElement; +} + +describe("RecipientChips", () => { + it("leaves typed text that is not an address in the input on blur", () => { + const seen: string[] = []; + render(); + const input = type("John"); + fireEvent.blur(input); + expect(seen).toEqual([]); + expect(input.value).toBe("John"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + }); + + it("does not turn a non-address into a recipient on Enter either", () => { + const seen: string[] = []; + render(); + const input = type("john@"); + fireEvent.keyDown(input, { key: "Enter" }); + expect(seen).toEqual([]); + expect(input.value).toBe("john@"); + }); + + it("commits a valid address on blur", () => { + const seen: string[] = []; + render(); + const input = type("john@example.com"); + fireEvent.blur(input); + expect(seen).toEqual(["john@example.com"]); + expect(input.value).toBe(""); + }); + + it("keeps only the invalid part of a pasted list", () => { + const seen: string[] = []; + render(); + const input = type("a@x.com, John, Bob "); + fireEvent.keyDown(input, { key: "Enter" }); + expect(seen).toEqual(["a@x.com, Bob "]); + expect(input.value).toBe("John"); + }); +}); + +describe("isValidRecipient", () => { + it.each([ + ["john@example.com", true], + ["John Smith ", true], + ["John", false], + ["john@", false], + ["john@localhost", false], + ["John ", false], + ])("%s -> %s", (token, ok) => expect(isValidRecipient(token)).toBe(ok)); +});