From 133036a6c5249402f19a534ed8f38265b074e333 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 26 Aug 2026 15:36:35 -0700 Subject: [PATCH] Hide identities from the compose picker An account using a unique address per service, on a server with an alias domain, ends up with every local part twice over and a From picker nobody can use -- while only ever sending from a handful (#73). Identities can now be hidden from that picker, from Identities & signatures. Hiding is presentation only: the identity still exists, still receives, and stays listed and editable, the way an unsubscribed folder is still a folder. That framing is mbunkus's own, and it is the right one -- this is a UI preference, not a change to the account. Three things it refuses to do, because a sender picker with nothing usable in it is worse than a cluttered one: - it will not hide the identity a draft is already using, which would leave the select with no matching option and move the From line under the writer - it will not hide the default, which is what a new draft starts on; the button is disabled there and says why - if every identity is somehow hidden -- reachable only through settings sync, since the UI will not do it -- they are all offered again The setting syncs, so the picker looks the same on every device, which follows from DEVICE_KEYS being a list of exceptions rather than a list of what travels. Verified against the mock with four identities and one hidden: the picker offers the other three, the hidden address is gone from composing, the default's hide button is disabled, and the row says the identity still receives. --- README.md | 1 + .../lib/__tests__/identityVisibility.test.ts | 60 +++++++++++++++++++ web/src/lib/identityVisibility.ts | 36 +++++++++++ web/src/store/settings.ts | 14 +++++ web/src/views/compose/Composer.tsx | 16 ++++- web/src/views/settings/IdentitiesSettings.tsx | 27 ++++++++- 6 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/__tests__/identityVisibility.test.ts create mode 100644 web/src/lib/identityVisibility.ts diff --git a/README.md b/README.md index 116b7ea..f3626d3 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ seconds of downtime with nothing lost. - **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse - **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Built on the `x:AccountPassword` / `x:AppPassword` registry objects - **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance +- **Hide identities from the compose picker** — an account with alias domains can have every address twice over while only a handful are ever sent from, which makes the From picker unusable. Hiding is presentation only: the identity still exists, still receives, and stays listed and editable in Settings, the way an unsubscribed folder is still a folder. The identity a draft is already using and the default can never be hidden, and hiding every one of them offers them all again — a sender picker with nothing in it is worse than a cluttered one - Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings - **Settings follow the account, not the browser**: they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later diff --git a/web/src/lib/__tests__/identityVisibility.test.ts b/web/src/lib/__tests__/identityVisibility.test.ts new file mode 100644 index 0000000..2eb9dca --- /dev/null +++ b/web/src/lib/__tests__/identityVisibility.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { isAlwaysVisible, visibleIdentities } from "@/lib/identityVisibility"; + +/** + * Issue #73: a unique address per service, on a server with an alias domain, + * gives every local part twice and a compose picker nobody can use — while only + * a handful are ever sent from. + * + * The interesting cases are not the hiding. They are the three refusals, all of + * which exist because a sender picker with nothing usable in it is worse than a + * cluttered one. + */ + +const ids = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `i${i + 1}`, email: `a${i + 1}@example.com` })); + +describe("hiding identities from the picker", () => { + it("removes the hidden ones", () => { + expect(visibleIdentities(ids(4), ["i2", "i4"]).map((i) => i.id)).toEqual(["i1", "i3"]); + }); + + it("changes nothing when none are hidden", () => { + const all = ids(3); + expect(visibleIdentities(all, [])).toBe(all); + }); +}); + +describe("what it refuses to hide", () => { + it("keeps the identity the draft is already using", () => { + // Otherwise the select has no matching option and the From line moves + // under the writer. + expect(visibleIdentities(ids(3), ["i2"], ["i2"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]); + }); + + it("keeps the default, which a new draft starts on", () => { + expect(visibleIdentities(ids(3), ["i1", "i3"], [null, "i1"]).map((i) => i.id)).toEqual(["i1", "i2"]); + }); + + it("shows everything rather than nothing when all are hidden", () => { + const all = ids(3); + expect(visibleIdentities(all, ["i1", "i2", "i3"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]); + }); + + it("ignores an id for an identity that no longer exists", () => { + // A deleted identity leaves its id behind in the setting; it must not + // silently hide anything else or empty the list. + expect(visibleIdentities(ids(2), ["gone"]).map((i) => i.id)).toEqual(["i1", "i2"]); + }); + + it("tolerates nulls among the ids to keep", () => { + expect(visibleIdentities(ids(2), ["i1"], [null, undefined]).map((i) => i.id)).toEqual(["i2"]); + }); +}); + +describe("what the settings row may offer", () => { + it("refuses to offer hiding for an always-visible identity", () => { + expect(isAlwaysVisible("i1", ["i1"])).toBe(true); + expect(isAlwaysVisible("i2", ["i1"])).toBe(false); + expect(isAlwaysVisible("i2", [null, undefined])).toBe(false); + }); +}); diff --git a/web/src/lib/identityVisibility.ts b/web/src/lib/identityVisibility.ts new file mode 100644 index 0000000..845da29 --- /dev/null +++ b/web/src/lib/identityVisibility.ts @@ -0,0 +1,36 @@ +/** + * Which identities the compose picker offers. + * + * Someone using a unique address per service, on a server with an alias domain, + * ends up with every local part twice and a picker they cannot use — while only + * ever sending from a handful (#73). Hiding is presentation only: the identity + * still exists, still receives, and is still listed in Settings, the same way an + * unsubscribed folder is still a folder. + * + * Three things it will not do, because a sender picker that cannot offer a + * sender is worse than a cluttered one: + * + * - hide the identity a draft is already using, which would leave the select + * with no matching option and reset the From line under the writer + * - hide the default identity, which is what a new draft starts on + * - hide everything; if every identity is hidden it shows them all instead + */ +import type { Identity } from "@/jmap/types"; + +export function visibleIdentities>( + identities: T[], + hidden: readonly string[], + keep: Array = [], +): T[] { + if (!hidden.length) return identities; + const hide = new Set(hidden); + for (const k of keep) if (k) hide.delete(k); + const shown = identities.filter((i) => !hide.has(i.id)); + // Everything hidden: show the lot rather than an empty picker. + return shown.length ? shown : identities; +} + +/** Whether hiding this one would be refused, so the UI can say so. */ +export function isAlwaysVisible(id: string, keep: Array): boolean { + return keep.some((k) => k === id); +} diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index a10c409..d4fcb06 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -88,6 +88,19 @@ export interface Settings { eventCategories: Array<{ name: string; color: string }>; /** Default sending identity per account (JMAP has no such flag). */ defaultIdentityByAccount: Record; + /** + * Identities kept out of the compose picker, by id. + * + * An account with alias domains can have every address twice over while only + * a handful are ever sent from, which makes the picker useless (#73). This + * hides them from the picker only — the identity still exists on the server, + * still receives, and is still listed and editable in Settings, exactly as an + * unsubscribed folder still exists. + * + * A flat list rather than keyed by account: identity ids are unique, and an + * id belonging to another account simply never matches. + */ + hiddenIdentities: string[]; /** * The theme the top-bar toggle goes back to from light. Remembered rather * than assumed, so flipping to light and back returns you to the theme you @@ -160,6 +173,7 @@ export const DEFAULT_SETTINGS: Settings = { { name: "Family", color: "#9333ea" }, ], defaultIdentityByAccount: {}, + hiddenIdentities: [], lastDarkTheme: "ihasmail", }; diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index c038a02..4126d9d 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, More import { useCompose, type Draft } from "@/store/compose"; import { useMail } from "@/store/mail"; import { useSettings } from "@/store/settings"; +import { visibleIdentities } from "@/lib/identityVisibility"; import { RecipientInput } from "./RecipientInput"; import { RichEditor, type RichEditorHandle } from "./RichEditor"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; @@ -28,7 +29,10 @@ export function Composer({ draft }: { draft: Draft }) { const setIdentity = useCompose((s) => s.setIdentity); const insertTemplate = useCompose((s) => s.insertTemplate); const focus = useCompose((s) => s.focus); - const identities = useMail((s) => s.identities); + const allIdentities = useMail((s) => s.identities); + const mailAccountId = useMail((s) => s.accountId); + const hiddenIdentities = useSettings((s) => s.settings.hiddenIdentities); + const defaultIdentityId = useSettings((s) => (mailAccountId ? s.settings.defaultIdentityByAccount[mailAccountId] : undefined)); const settings = useSettings((s) => s.settings); const updateSettings = useSettings((s) => s.update); const isMobile = useIsMobile(); @@ -118,6 +122,16 @@ export function Composer({ draft }: { draft: Draft }) { if (files.length) addFiles(key, files); }; + /* + * The picker offers the visible identities, plus two that can never be + * hidden from it: the one this draft is already using, and the default a new + * draft starts on. Hiding either would leave the select with no matching + * option and silently move the From line. See lib/identityVisibility. + */ + const identities = useMemo( + () => visibleIdentities(allIdentities, hiddenIdentities, [d.identityId, defaultIdentityId]), + [allIdentities, hiddenIdentities, d.identityId, defaultIdentityId], + ); const ident = identities.find((i) => i.id === d.identityId) ?? identities[0]; const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message"); const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : ""; diff --git a/web/src/views/settings/IdentitiesSettings.tsx b/web/src/views/settings/IdentitiesSettings.tsx index 1defef3..978cf38 100644 --- a/web/src/views/settings/IdentitiesSettings.tsx +++ b/web/src/views/settings/IdentitiesSettings.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from "react"; -import { Plus, Trash2, Star } from "lucide-react"; +import { Plus, Trash2, Star, Eye, EyeOff } from "lucide-react"; import { useSettings } from "@/store/settings"; import { useMail } from "@/store/mail"; import type { Identity } from "@/jmap/types"; +import { isAlwaysVisible } from "@/lib/identityVisibility"; import { Dialog, confirmDialog } from "@/ui/dialog"; import { RichEditor, type RichEditorHandle } from "../compose/RichEditor"; import { toast } from "@/ui/toast"; @@ -19,6 +20,10 @@ export function IdentitiesSettings() { const setDefault = useMail((s) => s.setDefaultIdentity); const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id; const [editing, setEditing] = useState | null>(null); + const hidden = useSettings((s) => s.settings.hiddenIdentities); + const updateSettings = useSettings((s) => s.update); + const toggleHidden = (id: string) => + updateSettings({ hiddenIdentities: hidden.includes(id) ? hidden.filter((x) => x !== id) : [...hidden, id] }); useEffect(() => { void load(); }, [load]); @@ -34,16 +39,36 @@ export function IdentitiesSettings() { {i.id !== defaultId && ( )} + {/* + Hiding is presentation only -- the identity still exists and still + receives, like an unsubscribed folder. The default cannot be + hidden, because it is what a new draft starts on. + */} + {i.mayDelete && ( )} + {hidden.includes(i.id) &&
Not offered when composing. It still receives mail, and you can still send from it by showing it again.
} {(i.htmlSignature || i.textSignature) &&
{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}
} {i.replyTo?.length ?
Reply-To: {formatAddressList(i.replyTo)}
: null} ))}

New identities must use an address this account is allowed to send from (aliases configured on the server).

+ {hidden.length > 0 && ( +

+ {hidden.length} {hidden.length === 1 ? "identity is" : "identities are"} hidden from the compose picker. Hiding every one of them would leave nothing to + choose from, so in that case they are all offered again. +

+ )} {editing && setEditing(null)} />} );