Merge pull request #11 from LINUXexpert-org/contact-from-message

Add contacts by right-clicking anyone named in a message
This commit is contained in:
LINUXexpert.org
2026-08-23 15:19:19 -07:00
committed by GitHub
7 changed files with 179 additions and 10 deletions
+1
View File
@@ -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
+36
View File
@@ -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 = "[email protected]") =>
nameParts(contactFromAddress({ name, email }) as ContactCard);
describe("contactFromAddress", () => {
it("keeps the address as the preferred email", () => {
const card = contactFromAddress({ name: "Ada Lovelace", email: "[email protected]" });
const emails = Object.values(card.emails ?? {});
expect(emails).toHaveLength(1);
expect(emails[0]).toMatchObject({ address: "[email protected]", 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: "[email protected]", email: "[email protected]" }).name).toBeUndefined();
expect(contactFromAddress({ name: null, email: "[email protected]" }).name).toBeUndefined();
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
});
});
+31
View File
@@ -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
* "[email protected]" helps nobody.
*/
export function contactFromAddress(addr: EmailAddress): Partial<ContactCard> {
const card: Partial<ContactCard> = {
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]! };
}
+4
View File
@@ -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; }
+2 -1
View File
@@ -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);
+93
View File
@@ -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<ReturnType<typeof contactFromAddress> | 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 && (
<Popover anchor={menu.anchor} onClose={close} width={230} ariaLabel={`Actions for ${menu.address.email}`}>
<div className="menu-title truncate">{formatAddress(menu.address)}</div>
{contacts.available && (
known ? (
<MenuItem icon={<Pencil size={16} />} label="Edit contact" onClick={() => { setEditing(known); close(); }} />
) : (
<MenuItem icon={<UserPlus size={16} />} label="Add to contacts" onClick={() => { setEditing(contactFromAddress(menu.address)); close(); }} />
)
)}
<MenuItem icon={<Mail size={16} />} label="New message to this address" onClick={() => { openCompose({ to: [menu.address] }); close(); }} />
<MenuSep />
<MenuItem
icon={<Copy size={16} />}
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();
}}
/>
</Popover>
)}
{editing && (
<ContactEditor
card={editing}
defaultBookId={defaultBookId}
onClose={() => 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) => (
<span key={`${a.email}-${i}`}>
{i > 0 && ", "}
<span className="addr" onContextMenu={(ev) => onContext(ev, a)} title="Right-click for options">{formatAddress(a)}</span>
</span>
))}
</>
);
}
+12 -9
View File
@@ -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
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
<Avatar who={from ?? null} />
<div className="who">
<div className="from">
<span>{displayName(from)}</span>
{expanded && from && <span className="email">&lt;{from.email}&gt;</span>}
<div className="from" onContextMenu={(ev) => from && addrMenu.open(ev, from)}>
<span className="addr">{displayName(from)}</span>
{expanded && from && <span className="email addr">&lt;{from.email}&gt;</span>}
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
</div>
@@ -184,12 +186,12 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
<>
{details && (
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
<dt>From</dt><dd>{(e.from ?? []).map(formatAddress).join(", ")}</dd>
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd>{e.sender.map(formatAddress).join(", ")}</dd></> : null}
{e.replyTo?.length ? <><dt>Reply-To</dt><dd>{e.replyTo.map(formatAddress).join(", ")}</dd></> : null}
<dt>To</dt><dd>{(e.to ?? []).map(formatAddress).join(", ") || "—"}</dd>
{e.cc?.length ? <><dt>Cc</dt><dd>{e.cc.map(formatAddress).join(", ")}</dd></> : null}
{e.bcc?.length ? <><dt>Bcc</dt><dd>{e.bcc.map(formatAddress).join(", ")}</dd></> : null}
<dt>From</dt><dd><AddressList list={e.from} onContext={addrMenu.open} /></dd>
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd><AddressList list={e.sender} onContext={addrMenu.open} /></dd></> : null}
{e.replyTo?.length ? <><dt>Reply-To</dt><dd><AddressList list={e.replyTo} onContext={addrMenu.open} /></dd></> : null}
<dt>To</dt><dd><AddressList list={e.to} onContext={addrMenu.open} /></dd>
{e.cc?.length ? <><dt>Cc</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
{e.bcc?.length ? <><dt>Bcc</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
@@ -220,6 +222,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
)}
</>
)}
{addrMenu.node}
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}