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}
}