Merge pull request #82 from LINUXexpert-org/hide-identities

Hide identities from the compose picker
This commit is contained in:
LINUXexpert.org
2026-08-26 15:44:55 -07:00
committed by GitHub
6 changed files with 152 additions and 2 deletions
+1
View File
@@ -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 `<input type="date">` 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 - **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 `<input type="date">` 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 - **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 - **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 - 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 - **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
@@ -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);
});
});
+36
View File
@@ -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<T extends Pick<Identity, "id">>(
identities: T[],
hidden: readonly string[],
keep: Array<string | null | undefined> = [],
): 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<string | null | undefined>): boolean {
return keep.some((k) => k === id);
}
+14
View File
@@ -88,6 +88,19 @@ export interface Settings {
eventCategories: Array<{ name: string; color: string }>; eventCategories: Array<{ name: string; color: string }>;
/** Default sending identity per account (JMAP has no such flag). */ /** Default sending identity per account (JMAP has no such flag). */
defaultIdentityByAccount: Record<string, string>; defaultIdentityByAccount: Record<string, string>;
/**
* 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 * 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 * 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" }, { name: "Family", color: "#9333ea" },
], ],
defaultIdentityByAccount: {}, defaultIdentityByAccount: {},
hiddenIdentities: [],
lastDarkTheme: "ihasmail", lastDarkTheme: "ihasmail",
}; };
+15 -1
View File
@@ -3,6 +3,7 @@ import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, More
import { useCompose, type Draft } from "@/store/compose"; import { useCompose, type Draft } from "@/store/compose";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { visibleIdentities } from "@/lib/identityVisibility";
import { RecipientInput } from "./RecipientInput"; import { RecipientInput } from "./RecipientInput";
import { RichEditor, type RichEditorHandle } from "./RichEditor"; import { RichEditor, type RichEditorHandle } from "./RichEditor";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; 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 setIdentity = useCompose((s) => s.setIdentity);
const insertTemplate = useCompose((s) => s.insertTemplate); const insertTemplate = useCompose((s) => s.insertTemplate);
const focus = useCompose((s) => s.focus); 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 settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update); const updateSettings = useSettings((s) => s.update);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -118,6 +122,16 @@ export function Composer({ draft }: { draft: Draft }) {
if (files.length) addFiles(key, files); 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 ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message"); 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" : ""; const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
+26 -1
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from "react"; 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 { useSettings } from "@/store/settings";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import type { Identity } from "@/jmap/types"; import type { Identity } from "@/jmap/types";
import { isAlwaysVisible } from "@/lib/identityVisibility";
import { Dialog, confirmDialog } from "@/ui/dialog"; import { Dialog, confirmDialog } from "@/ui/dialog";
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor"; import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
@@ -19,6 +20,10 @@ export function IdentitiesSettings() {
const setDefault = useMail((s) => s.setDefaultIdentity); const setDefault = useMail((s) => s.setDefaultIdentity);
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id; const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
const [editing, setEditing] = useState<Partial<Identity> | null>(null); const [editing, setEditing] = useState<Partial<Identity> | 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(() => { useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -34,16 +39,36 @@ export function IdentitiesSettings() {
{i.id !== defaultId && ( {i.id !== defaultId && (
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button> <button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
)} )}
{/*
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.
*/}
<button
className="btn btn-sm btn-ghost"
disabled={isAlwaysVisible(i.id, [defaultId])}
title={isAlwaysVisible(i.id, [defaultId]) ? "The default identity is always offered when composing" : hidden.includes(i.id) ? "Show this in the compose picker" : "Hide this from the compose picker"}
onClick={(e) => { e.stopPropagation(); toggleHidden(i.id); }}
>
{hidden.includes(i.id) ? <><Eye size={14} /> Show when composing</> : <><EyeOff size={14} /> Hide when composing</>}
</button>
{i.mayDelete && ( {i.mayDelete && (
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button> <button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
)} )}
</div> </div>
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>Not offered when composing. It still receives mail, and you can still send from it by showing it again.</div>}
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>} {(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null} {i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
</div> </div>
))} ))}
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button> <button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p> <p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
{hidden.length > 0 && (
<p className="hint">
{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.
</p>
)}
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />} {editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
</div> </div>
); );