From d143e711d4874b6256634a5d1f6054d56010c155 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 23 Aug 2026 14:46:38 -0700 Subject: [PATCH] Add contacts by right-clicking anyone named in a message Right-clicking a sender, or any address in the message details, opens a menu offering to add that person to the address book - plus edit them when they are already known, write to them, or copy the address. "Add to contacts" opens the contact editor prefilled rather than saving silently, so the address book gets a real card that the user can complete, not a bare email address. contactFromAddress splits the display name into JSContact name components: "Ada Lovelace" into given and surname, "Lovelace, Ada" unpicked, a single word as the given name, and a name that is really just an address left off entirely. Addresses in the details block were joined into one string, so they are now rendered per address to be individually targetable. ContactEditor previously ignored a prefilled name on an unsaved card - it read name components only when the card had an id - so it now reads them either way. --- README.md | 1 + web/src/lib/__tests__/contacts.test.ts | 36 +++++++++ web/src/lib/contacts.ts | 31 ++++++++ web/src/styles/app.css | 4 + web/src/views/contacts/ContactEditor.tsx | 3 +- web/src/views/mail/AddressMenu.tsx | 93 ++++++++++++++++++++++++ web/src/views/mail/MessageView.tsx | 21 +++--- 7 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/__tests__/contacts.test.ts create mode 100644 web/src/views/mail/AddressMenu.tsx diff --git a/README.md b/README.md index 4ad0094..b9ea0da 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a - Messages sit on a light card by default, untouched as the sender designed them. *Appearance › Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone - Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer - Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click +- **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address - Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel - Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, autosaved drafts, reply/reply-all/forward with quoting and inline images preserved - Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge diff --git a/web/src/lib/__tests__/contacts.test.ts b/web/src/lib/__tests__/contacts.test.ts new file mode 100644 index 0000000..408f63a --- /dev/null +++ b/web/src/lib/__tests__/contacts.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { contactFromAddress, nameParts } from "../contacts"; +import type { ContactCard } from "@/jmap/types"; + +const parts = (name: string | null, email = "a@b.io") => + nameParts(contactFromAddress({ name, email }) as ContactCard); + +describe("contactFromAddress", () => { + it("keeps the address as the preferred email", () => { + const card = contactFromAddress({ name: "Ada Lovelace", email: "ada@example.org" }); + const emails = Object.values(card.emails ?? {}); + expect(emails).toHaveLength(1); + expect(emails[0]).toMatchObject({ address: "ada@example.org", pref: 1 }); + expect(card.kind).toBe("individual"); + }); + + it("splits a display name into components", () => { + expect(parts("Ada Lovelace")).toMatchObject({ given: "Ada", surname: "Lovelace" }); + expect(parts("Ada King Lovelace")).toMatchObject({ given: "Ada", middle: "King", surname: "Lovelace" }); + expect(parts("Prince")).toMatchObject({ given: "Prince", surname: "" }); + }); + + it("unpicks the surname-first form", () => { + expect(parts("Lovelace, Ada")).toMatchObject({ given: "Ada", surname: "Lovelace" }); + }); + + it("strips surrounding quotes", () => { + expect(parts('"Ada Lovelace"')).toMatchObject({ given: "Ada", surname: "Lovelace" }); + }); + + it("leaves the name empty when the header carries an address, not a name", () => { + expect(contactFromAddress({ name: "ada@example.org", email: "ada@example.org" }).name).toBeUndefined(); + expect(contactFromAddress({ name: null, email: "ada@example.org" }).name).toBeUndefined(); + expect(contactFromAddress({ name: " ", email: "ada@example.org" }).name).toBeUndefined(); + }); +}); diff --git a/web/src/lib/contacts.ts b/web/src/lib/contacts.ts index f369cfc..e8e4dc3 100644 --- a/web/src/lib/contacts.ts +++ b/web/src/lib/contacts.ts @@ -147,3 +147,34 @@ function fold(line: string): string { export function newKey(prefix = "k"): string { return `${prefix}${Math.random().toString(36).slice(2, 8)}`; } + +/** + * A new contact card seeded from an email address. + * + * The display name in a From header is one string, so it has to be split into + * name components: "Ada Lovelace" gives given + surname, the "Lovelace, Ada" + * form is unpicked, and a single word becomes the given name. Anything that + * looks like an address rather than a name is left out — a card named + * "ada@example.org" helps nobody. + */ +export function contactFromAddress(addr: EmailAddress): Partial { + const card: Partial = { + kind: "individual", + emails: { [newKey("e")]: { "@type": "EmailAddress", address: addr.email, pref: 1 } }, + }; + const raw = (addr.name ?? "").trim().replace(/^["']|["']$/g, "").trim(); + if (!raw || raw.includes("@")) return card; + const [surnameFirst, givenRest] = raw.includes(",") ? raw.split(",", 2) : []; + const parts = surnameFirst && givenRest + ? { given: givenRest.trim(), surname: surnameFirst.trim() } + : splitName(raw); + const name = buildName(parts); + if (name) card.name = name; + return card; +} + +function splitName(full: string): { given: string; middle: string; surname: string } { + const words = full.split(/\s+/).filter(Boolean); + if (words.length === 1) return { given: words[0]!, middle: "", surname: "" }; + return { given: words[0]!, middle: words.slice(1, -1).join(" "), surname: words[words.length - 1]! }; +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 48b49c0..469d90a 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -851,6 +851,10 @@ img { max-width: 100%; } *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } } +/* Addresses in a message carry a right-click menu (see mail/AddressMenu.tsx). */ +.addr { cursor: context-menu; } +.message-details .addr:hover, .message-head .from .addr:hover { text-decoration: underline dotted; text-underline-offset: 2px; } + /* ---- Date & time fields (custom pickers; see ui/datefield.tsx) ---- */ .dp-field { position: relative; display: inline-flex; align-items: center; width: 100%; } .dp-field .input { width: 100%; padding-right: 30px; } diff --git a/web/src/views/contacts/ContactEditor.tsx b/web/src/views/contacts/ContactEditor.tsx index 5a744eb..f6bec13 100644 --- a/web/src/views/contacts/ContactEditor.tsx +++ b/web/src/views/contacts/ContactEditor.tsx @@ -26,7 +26,8 @@ type AddrRow = { key: string; ctx: string; street: string; city: string; region: export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props) { const contacts = useContacts(); const isNew = !card.id; - const np = card.id ? nameParts(card as ContactCard) : { given: "", surname: "", middle: "", prefix: "", suffix: "" }; + // Read from the card whether it is saved or seeded (e.g. from a message header). + const np = nameParts(card as ContactCard); const [kind, setKind] = useState<"individual" | "group" | "org">((card.kind as "individual" | "group" | "org") ?? "individual"); const [given, setGiven] = useState(np.given); const [surname, setSurname] = useState(np.surname); diff --git a/web/src/views/mail/AddressMenu.tsx b/web/src/views/mail/AddressMenu.tsx new file mode 100644 index 0000000..224f363 --- /dev/null +++ b/web/src/views/mail/AddressMenu.tsx @@ -0,0 +1,93 @@ +import { useCallback, useState, type MouseEvent, type ReactNode } from "react"; +import { Copy, Mail, Pencil, UserPlus } from "lucide-react"; +import type { EmailAddress } from "@/jmap/types"; +import { useContacts } from "@/store/contacts"; +import { useCompose } from "@/store/compose"; +import { contactFromAddress } from "@/lib/contacts"; +import { formatAddress } from "@/lib/address"; +import { MenuItem, MenuSep, Popover, type Anchor } from "@/ui/popover"; +import { toast } from "@/ui/toast"; +import { ContactEditor } from "../contacts/ContactEditor"; + +/** + * Right-click on anyone named in a message — sender, recipients, Reply-To — to + * add them to the address book. The contact editor opens prefilled rather than + * saving silently, so the address book gets a real card and not just a stray + * email address. + */ +export function useAddressMenu() { + const [menu, setMenu] = useState<{ anchor: Anchor; address: EmailAddress } | null>(null); + const [editing, setEditing] = useState | null>(null); + const contacts = useContacts(); + const openCompose = useCompose((s) => s.open); + + const open = useCallback((ev: MouseEvent, address: EmailAddress) => { + if (!address.email) return; + ev.preventDefault(); + ev.stopPropagation(); + setMenu({ anchor: { x: ev.clientX, y: ev.clientY }, address }); + // The books are needed the moment "Add to contacts" is chosen. + const st = useContacts.getState(); + if (st.available && !st.loaded && !st.loading) void st.loadAll(); + }, []); + + const close = () => setMenu(null); + const known = menu ? contacts.lookupByEmail(menu.address.email) : undefined; + const books = Object.values(contacts.books); + const defaultBookId = (books.find((b) => b.isDefault) ?? books[0])?.id ?? null; + + const node: ReactNode = ( + <> + {menu && ( + +
{formatAddress(menu.address)}
+ {contacts.available && ( + known ? ( + } label="Edit contact" onClick={() => { setEditing(known); close(); }} /> + ) : ( + } label="Add to contacts" onClick={() => { setEditing(contactFromAddress(menu.address)); close(); }} /> + ) + )} + } label="New message to this address" onClick={() => { openCompose({ to: [menu.address] }); close(); }} /> + + } + label="Copy email address" + onClick={() => { + void navigator.clipboard?.writeText(menu.address.email).then( + () => toast.show("Address copied"), + () => toast.error("Could not copy the address"), + ); + close(); + }} + /> +
+ )} + {editing && ( + setEditing(null)} + onSaved={() => setEditing(null)} + /> + )} + + ); + + return { open, node }; +} + +/** Comma-separated addresses, each of them right-clickable. */ +export function AddressList({ list, onContext, empty = "—" }: { list: EmailAddress[] | null | undefined; onContext: (ev: MouseEvent, a: EmailAddress) => void; empty?: string }) { + if (!list?.length) return <>{empty}; + return ( + <> + {list.map((a, i) => ( + + {i > 0 && ", "} + onContext(ev, a)} title="Right-click for options">{formatAddress(a)} + + ))} + + ); +} diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 6e93d6b..510050a 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -18,6 +18,7 @@ import { toast } from "@/ui/toast"; import type { ListActions } from "./MessageList"; import { InviteCard } from "./InviteCard"; import { VCardCard } from "./VCardCard"; +import { AddressList, useAddressMenu } from "./AddressMenu"; import { useSession } from "@/store/session"; interface Props { @@ -40,6 +41,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog const [allowRemote, setAllowRemote] = useState(false); const [filterOpen, setFilterOpen] = useState(false); const moreMenu = useMenu(); + const addrMenu = useAddressMenu(); const from = e.from?.[0]; const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase()); const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email))); @@ -128,9 +130,9 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
{ if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
-
- {displayName(from)} - {expanded && from && <{from.email}>} +
from && addrMenu.open(ev, from)}> + {displayName(from)} + {expanded && from && <{from.email}>} {isHighPriority && Important} {authFailed && Unverified}
@@ -184,12 +186,12 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog <> {details && (
ev.stopPropagation()}> -
From
{(e.from ?? []).map(formatAddress).join(", ")}
- {e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <>
Sender
{e.sender.map(formatAddress).join(", ")}
: null} - {e.replyTo?.length ? <>
Reply-To
{e.replyTo.map(formatAddress).join(", ")}
: null} -
To
{(e.to ?? []).map(formatAddress).join(", ") || "—"}
- {e.cc?.length ? <>
Cc
{e.cc.map(formatAddress).join(", ")}
: null} - {e.bcc?.length ? <>
Bcc
{e.bcc.map(formatAddress).join(", ")}
: null} +
From
+ {e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <>
Sender
: null} + {e.replyTo?.length ? <>
Reply-To
: null} +
To
+ {e.cc?.length ? <>
Cc
: null} + {e.bcc?.length ? <>
Bcc
: null}
Date
{formatFullDate(e.sentAt ?? e.receivedAt)}
Subject
{e.subject || "(no subject)"}
{e.messageId?.[0] && <>
Message-ID
{e.messageId[0]}
} @@ -220,6 +222,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog )} )} + {addrMenu.node} {filterOpen && setFilterOpen(false)} />} setShowSource(false)} title="Original message" size="xl"> {source === null ?
:
{source}
}