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));
+});