Group six more clusters out of web/src/lib

Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
This commit is contained in:
2026-09-15 23:17:50 -07:00
parent 5cc31037c1
commit bd6a605d61
95 changed files with 104 additions and 104 deletions
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/mailbox/archiveDate";
/**
* The dates below are written as local-time strings on purpose. The segments
* follow the reader's timezone, so a test pinned to UTC instants would pass or
* fail depending on where it ran.
*/
describe("archiveSegments", () => {
it("gives the year, and the zero-padded month", () => {
expect(archiveSegments("2026-09-04T10:00:00", "year")).toEqual(["2026"]);
expect(archiveSegments("2026-09-04T10:00:00", "month")).toEqual(["2026", "09"]);
});
it("zero-pads every month below October, so the folders sort", () => {
expect(archiveSegments("2026-01-15T10:00:00", "month")).toEqual(["2026", "01"]);
expect(archiveSegments("2026-10-15T10:00:00", "month")).toEqual(["2026", "10"]);
expect(archiveSegments("2026-12-15T10:00:00", "month")).toEqual(["2026", "12"]);
});
it("returns nothing to append when the date cannot be read", () => {
// Archive itself, rather than a folder named after a guess.
expect(archiveSegments(null, "month")).toEqual([]);
expect(archiveSegments(undefined, "month")).toEqual([]);
expect(archiveSegments("", "month")).toEqual([]);
expect(archiveSegments("not a date", "month")).toEqual([]);
});
it("joins to a path", () => {
expect(archivePath(["2026", "09"])).toBe("2026/09");
expect(archivePath([])).toBe("");
});
});
describe("groupByArchivePath", () => {
it("keeps one destination for a selection from one month", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-09-28T10:00:00" },
],
"month",
);
expect(groups).toHaveLength(1);
expect(groups[0]!.segments).toEqual(["2026", "09"]);
expect(groups[0]!.ids).toEqual(["a", "b"]);
});
it("splits a selection that spans months, which is the case that matters", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-08-30T10:00:00" },
{ id: "c", receivedAt: "2026-09-01T10:00:00" },
],
"month",
);
expect(groups.map((g) => g.segments)).toEqual([
["2026", "09"],
["2026", "08"],
]);
expect(groups[0]!.ids).toEqual(["a", "c"]);
expect(groups[1]!.ids).toEqual(["b"]);
});
it("collapses the same span back to one group at year granularity", () => {
const entries = [
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-02-28T10:00:00" },
];
expect(groupByArchivePath(entries, "month")).toHaveLength(2);
expect(groupByArchivePath(entries, "year")).toHaveLength(1);
});
it("orders groups by where their first message appeared", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2024-01-04T10:00:00" },
{ id: "b", receivedAt: "2026-01-04T10:00:00" },
],
"year",
);
expect(groups.map((g) => archivePath(g.segments))).toEqual(["2024", "2026"]);
});
it("gathers the undatable ones into their own group, bound for Archive itself", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: null },
{ id: "c", receivedAt: "bad" },
],
"month",
);
expect(groups).toHaveLength(2);
expect(groups[1]!.segments).toEqual([]);
expect(groups[1]!.ids).toEqual(["b", "c"]);
});
it("has nothing to do with an empty selection", () => {
expect(groupByArchivePath([], "month")).toEqual([]);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { canEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
import type { MailboxRole } from "@/jmap/types";
/**
* Emptying destroys everything in a folder in one action, with no undo and no
* trip through Deleted Items. Which folders may be emptied is therefore a
* safety property, not a presentation one — the store enforces it too, and
* these pin the half the menus decide.
*/
describe("which folders may be emptied", () => {
it("allows exactly Deleted Items and Junk Mail", () => {
expect(canEmpty("trash")).toBe(true);
expect(canEmpty("junk")).toBe(true);
});
it("refuses folders holding mail someone meant to keep", () => {
const keep: MailboxRole[] = ["inbox", "archive", "sent", "drafts", "all", "flagged", "important", "subscribed"];
for (const role of keep) expect(canEmpty(role), String(role)).toBe(false);
});
it("refuses a plain folder, which has no role at all", () => {
expect(canEmpty(null)).toBe(false);
expect(canEmpty(undefined)).toBe(false);
});
});
describe("what the action is called", () => {
it("says what it does to spam, rather than naming the folder", () => {
// "Delete all spam" is what this is called everywhere else; "Empty Junk
// Mail" would be accurate and still leave people hunting for it.
expect(emptyLabel({ name: "Junk Mail", role: "junk" })).toBe("Delete all spam");
expect(emptyLabel({ name: "Spam", role: "junk" })).toBe("Delete all spam");
});
it("names the folder for Deleted Items, whatever the server calls it", () => {
expect(emptyLabel({ name: "Deleted Items", role: "trash" })).toBe("Empty Deleted Items");
expect(emptyLabel({ name: "Trash", role: "trash" })).toBe("Empty Trash");
});
});
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { canDropFolder, canMoveFolderTo, descendantIds, folderColor, movable } from "../folderMove";
import type { Id, Mailbox } from "@/jmap/types";
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox =>
({ id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} as Mailbox["myRights"] });
/** root ── Work ── Clients ── EU
* └─ Archive (role)
* └─ Inbox (role) */
const tree: Record<Id, Mailbox> = Object.fromEntries([
mb("inbox", "Inbox", null, "inbox"),
mb("arch", "Archive", null, "archive"),
mb("work", "Work", null),
mb("clients", "Clients", "work"),
mb("eu", "EU", "clients"),
mb("news", "Newsletters", null),
].map((m) => [m.id, m]));
describe("movable", () => {
it("refuses folders the server gave a role", () => {
expect(movable(tree.inbox!)).toBe(false);
expect(movable(tree.arch!)).toBe(false);
expect(movable(tree.work!)).toBe(true);
});
});
describe("descendantIds", () => {
it("finds the whole subtree, not just the children", () => {
expect([...descendantIds(tree, "work")].sort()).toEqual(["clients", "eu"]);
expect([...descendantIds(tree, "eu")]).toEqual([]);
});
});
describe("canDropFolder", () => {
it("allows a plain move into another folder", () => {
expect(canDropFolder(tree, "news", "work")).toBe(true);
expect(canDropFolder(tree, "eu", "news")).toBe(true);
});
it("allows a move into a role folder, which may hold subfolders", () => {
expect(canDropFolder(tree, "news", "arch")).toBe(true);
});
it("refuses to move a folder into itself or its own subtree", () => {
expect(canDropFolder(tree, "work", "work")).toBe(false);
expect(canDropFolder(tree, "work", "clients")).toBe(false);
expect(canDropFolder(tree, "work", "eu")).toBe(false); // grandchild, not just child
});
it("refuses a move to the parent it already has", () => {
expect(canDropFolder(tree, "clients", "work")).toBe(false);
});
it("refuses to move a role folder anywhere", () => {
expect(canDropFolder(tree, "inbox", "work")).toBe(false);
expect(canDropFolder(tree, "arch", null)).toBe(false);
});
it("handles the root: allowed from a parent, refused when already there", () => {
expect(canDropFolder(tree, "eu", null)).toBe(true);
expect(canDropFolder(tree, "news", null)).toBe(false);
});
it("refuses a target that does not exist", () => {
expect(canDropFolder(tree, "news", "gone")).toBe(false);
expect(canDropFolder(tree, "gone", "work")).toBe(false);
});
});
describe("canMoveFolderTo", () => {
const rights = (r: Partial<Mailbox["myRights"]>) => ({ mayRename: true, mayCreateChild: true, ...r }) as Mailbox["myRights"];
const owned: Record<Id, Mailbox> = Object.fromEntries(Object.values(tree).map((m) => [m.id, { ...m, myRights: rights({}) }]));
it("agrees with a drop when every right is granted", () => {
expect(canMoveFolderTo(owned, "news", "work")).toBe(true);
expect(canMoveFolderTo(owned, "eu", null)).toBe(true);
expect(canMoveFolderTo(owned, "work", "eu")).toBe(false);
expect(canMoveFolderTo(owned, "news", null)).toBe(false);
});
it("refuses a folder the user may not rename, top level included", () => {
const locked = { ...owned, eu: { ...owned.eu!, myRights: rights({ mayRename: false }) } };
expect(canMoveFolderTo(locked, "eu", "news")).toBe(false);
expect(canMoveFolderTo(locked, "eu", null)).toBe(false);
});
it("refuses a destination that may not hold new subfolders", () => {
const closed = { ...owned, work: { ...owned.work!, myRights: rights({ mayCreateChild: false }) } };
expect(canMoveFolderTo(closed, "news", "work")).toBe(false);
expect(canMoveFolderTo(closed, "eu", null)).toBe(true);
});
});
describe("folderColor", () => {
it("returns the color chosen for that folder, and null for the rest", () => {
const colors = { work: "#7c3aed" };
expect(folderColor(colors, "work")).toBe("#7c3aed");
expect(folderColor(colors, "news")).toBeNull();
expect(folderColor({}, "work")).toBeNull();
});
it("is keyed by id, so a renamed folder keeps its color", () => {
// The id is stable across a rename; the name and path are not.
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/mailbox/labelTree";
import type { Label } from "@/store/settings";
const L = (keyword: string, over: Partial<Label> = {}): Label => ({ keyword, name: keyword, color: "#000", ...over });
const flat = (labels: Label[], counts: Record<string, number> = {}) =>
visibleLabels(labelTree(labels, counts)).map((n) => `${" ".repeat(n.depth)}${n.label.keyword}`);
describe("labelTree", () => {
it("nests a label under its parent and indents it", () => {
const roots = labelTree([L("work"), L("work_urgent", { parent: "work" })]);
expect(roots).toHaveLength(1);
expect(roots[0]!.label.keyword).toBe("work");
expect(roots[0]!.children[0]!.label.keyword).toBe("work_urgent");
expect(roots[0]!.children[0]!.depth).toBe(1);
});
it("nests three deep", () => {
expect(flat([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" })])).toEqual(["a", " b", " c"]);
});
it("puts a label back at the top when its parent no longer exists", () => {
// Settings sync between devices; a parent can be deleted on one while
// another still points at it. Dropping the child would lose it for good.
expect(flat([L("orphan", { parent: "gone" })])).toEqual(["orphan"]);
});
it("survives a cycle rather than hanging", () => {
const out = flat([L("a", { parent: "b" }), L("b", { parent: "a" })]);
expect(out).toHaveLength(2);
expect(out.map((s) => s.trim()).sort()).toEqual(["a", "b"]);
});
it("survives a label parented to itself", () => {
expect(flat([L("a", { parent: "a" })])).toEqual(["a"]);
});
it("carries each label's own unread count, not its children's", () => {
const roots = labelTree([L("a"), L("b", { parent: "a" })], { a: 2, b: 5 });
expect(roots[0]!.unread).toBe(2);
expect(roots[0]!.children[0]!.unread).toBe(5);
});
});
describe("visibleLabels", () => {
it("draws everything set to always", () => {
expect(flat([L("a"), L("b")])).toEqual(["a", "b"]);
});
it("never draws a hidden label", () => {
expect(flat([L("a"), L("b", { visibility: "hidden" })], { b: 9 })).toEqual(["a"]);
});
it("draws an unread-only label just while it has unread mail", () => {
const labels = [L("a", { visibility: "unread" })];
expect(flat(labels, { a: 0 })).toEqual([]);
expect(flat(labels, { a: 1 })).toEqual(["a"]);
});
it("keeps a parent that would otherwise be dropped, when a child survives", () => {
// A child cannot be drawn under a parent that is not there, and promoting
// it would silently rearrange the tree. The parent comes back as a
// container instead.
const labels = [L("work", { visibility: "unread" }), L("work_urgent", { parent: "work" })];
expect(flat(labels, { work: 0 })).toEqual(["work", " work_urgent"]);
});
it("keeps a hidden parent too, when a child survives", () => {
const labels = [L("work", { visibility: "hidden" }), L("work_urgent", { parent: "work" })];
expect(flat(labels, {})).toEqual(["work", " work_urgent"]);
});
it("drops a whole branch when nothing in it survives", () => {
const labels = [
L("work", { visibility: "unread" }),
L("work_urgent", { parent: "work", visibility: "unread" }),
L("other"),
];
expect(flat(labels, { work: 0, work_urgent: 0 })).toEqual(["other"]);
});
it("keeps a grandparent when only a grandchild survives", () => {
const labels = [
L("a", { visibility: "hidden" }),
L("b", { parent: "a", visibility: "hidden" }),
L("c", { parent: "b" }),
];
expect(flat(labels, {})).toEqual(["a", " b", " c"]);
});
it("treats a label with no visibility set as always, so old settings parse unchanged", () => {
const l = L("a");
expect(l.visibility).toBeUndefined();
expect(flat([l], {})).toEqual(["a"]);
});
});
describe("descendantKeywords", () => {
it("names everything below a label, so the parent picker cannot offer a cycle", () => {
const roots = labelTree([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" }), L("d")]);
expect([...descendantKeywords(roots, "a")].sort()).toEqual(["b", "c"]);
expect([...descendantKeywords(roots, "d")]).toEqual([]);
});
it("says nothing about a label that is not there", () => {
expect([...descendantKeywords(labelTree([L("a")]), "missing")]).toEqual([]);
});
});
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "vitest";
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
import { setCatalog, type Catalog } from "@/lib/i18n";
import type { Mailbox } from "@/jmap/types";
/**
* Stalwart names the standard folders once, at account creation, and never
* renames them — so a German reader on an English-provisioned account would
* otherwise see "Deleted Items" in an otherwise German app. The role is what
* lets ihasmail say "Papierkorb" without writing anything to the server.
*/
const de: Catalog = {
strings: { Inbox: "Posteingang", "Deleted Items": "Papierkorb", Drafts: "Entwürfe" },
plurals: {},
};
const mb = (id: string, name: string, role: string | null = null, parentId: string | null = null) =>
({ id, name, role, parentId } as unknown as Mailbox);
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
describe("mailboxDisplayName", () => {
it("is the server's name until a catalog says otherwise", () => {
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
});
it("follows the interface language for a folder carrying a role", () => {
setCatalog("de", de);
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Papierkorb");
expect(mailboxDisplayName(mb("2", "Inbox", "inbox"))).toBe("Posteingang");
});
it("leaves a folder somebody made alone", () => {
// "Newsletters" is their word. Translating it would name a folder they
// never created, and it would not match what any other client shows.
setCatalog("de", de);
expect(mailboxDisplayName(mb("3", "Newsletters"))).toBe("Newsletters");
expect(mailboxDisplayName(mb("4", "Work", "subscribed"))).toBe("Work");
});
it("survives a missing mailbox rather than printing undefined", () => {
expect(mailboxDisplayName(null)).toBe("");
expect(mailboxDisplayName(undefined)).toBe("");
});
});
describe("isLocalizedName", () => {
it("tells an editor when the name on screen is not the server's", () => {
// A rename box prefilled with "Papierkorb" would rename the folder to that
// the moment somebody pressed Save — a real change made by accident.
expect(isLocalizedName(mb("1", "Deleted Items", "trash"))).toBe(true);
expect(isLocalizedName(mb("2", "Newsletters"))).toBe(false);
expect(isLocalizedName(mb("3", "Work", "subscribed"))).toBe(false);
});
});
describe("mailboxDisplayPath", () => {
it("localizes each part that has a role and leaves the rest", () => {
setCatalog("de", de);
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
});
it("stops rather than looping on a parent cycle", () => {
// A malformed tree from the server must not hang the folder picker.
const all: Record<string, Mailbox> = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") };
expect(mailboxDisplayPath(all.a!, all)).toBe("B / A");
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { isUnknownMailbox } from "@/lib/mailbox/mailboxRoute";
import type { Mailbox } from "@/jmap/types";
/**
* Issue #111: a folder id the account does not have rendered the ordinary
* empty state — "Nothing here. This folder is empty" — which is a claim about
* a folder that is not there. A stale link read as a folder that had emptied
* itself rather than one that was gone.
*
* The interesting case is not the unknown id. It is `loaded`: the folder list
* arrives after the first paint, so for a moment *every* id is unknown,
* including the right one. A version without that gate sends the reader to
* their inbox from the folder they asked for, on every cold load, and looks
* exactly like a flaky link.
*/
const boxes = (...ids: string[]): Record<string, Mailbox> =>
Object.fromEntries(ids.map((id) => [id, { id, name: id } as Mailbox]));
describe("spotting a folder the account does not have", () => {
it("is unknown when the list is loaded and does not contain it", () => {
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: boxes("a", "b"), loaded: true })).toBe(true);
});
it("is not unknown when the list contains it", () => {
expect(isUnknownMailbox({ mailboxId: "a", mailboxes: boxes("a", "b"), loaded: true })).toBe(false);
});
});
describe("what it refuses to call unknown", () => {
it("says nothing before the folder list has arrived", () => {
// The whole point. Every id is unknown at this moment, the real one too.
expect(isUnknownMailbox({ mailboxId: "a", mailboxes: {}, loaded: false })).toBe(false);
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: {}, loaded: false })).toBe(false);
});
it("says nothing when there is no folder in the address", () => {
// /mail has its own redirect to the inbox; this must not race it.
expect(isUnknownMailbox({ mailboxId: undefined, mailboxes: boxes("a"), loaded: true })).toBe(false);
});
it("says nothing on a search, which has no folder to be wrong about", () => {
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: boxes("a"), loaded: true, search: true })).toBe(false);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Where a message goes when it is archived by date.
*
* The folders are **numeric and zero-padded** -- `Archive/2026`,
* `Archive/2026/09` -- and deliberately not month names. Two reasons, both
* about the fact that these are real server-side mailboxes rather than
* anything of ihasmail's:
*
* - Every other client sees them. A folder created as "September" by someone
* reading in English stays "September" for the same account read in
* Japanese, because the name is stored, not translated. A number reads the
* same in every language ihasmail ships.
* - They sort. `09` sits between `08` and `10` in any folder list; "September"
* sits between "October" and nothing useful.
*
* The date is read in the reader's own timezone rather than UTC, because it has
* to agree with the date shown against the message in the list. A message that
* arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
* filing it under `09` while the list says August would be the app disagreeing
* with itself.
*/
export type ArchiveGranularity = "year" | "month";
/**
* Path segments below the Archive folder. Empty means "no dated subfolder" --
* a message whose date cannot be read belongs in Archive itself rather than in
* a folder named after a guess.
*/
export function archiveSegments(when: string | null | undefined, granularity: ArchiveGranularity): string[] {
if (!when) return [];
const d = new Date(when);
if (Number.isNaN(d.getTime())) return [];
const year = String(d.getFullYear());
if (granularity === "year") return [year];
return [year, String(d.getMonth() + 1).padStart(2, "0")];
}
/** The segments as one string, for grouping and for naming the destination. */
export function archivePath(segments: string[]): string {
return segments.join("/");
}
export interface ArchiveGroup {
segments: string[];
ids: string[];
}
/**
* Split a selection by where each message is going.
*
* Archiving by month across a selection spanning two months is two
* destinations, not one, so this is the shape the caller needs -- and the
* reason the action cannot simply resolve one folder up front. Groups come
* back in the order their first message appeared, so the toast that follows
* names them in the order the reader was looking at.
*/
export function groupByArchivePath(
entries: Array<{ id: string; receivedAt?: string | null }>,
granularity: ArchiveGranularity,
): ArchiveGroup[] {
const groups = new Map<string, ArchiveGroup>();
for (const e of entries) {
const segments = archiveSegments(e.receivedAt, granularity);
const key = archivePath(segments);
const existing = groups.get(key);
if (existing) existing.ids.push(e.id);
else groups.set(key, { segments, ids: [e.id] });
}
return [...groups.values()];
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Emptying a folder, and asking first.
*
* There are three ways in — the folder's right-click menu, the list's own
* menu, and the banner across the top of Junk Mail — and they must not drift
* apart in what they warn about. A folder can only be emptied when it is one
* whose whole purpose is holding things you did not want: Deleted Items, or
* Junk Mail.
*
* The wording differs between them for a reason. Emptying Deleted Items is
* what anyone expects it to do. Emptying Junk Mail is the surprising one: the
* messages do not travel to Deleted Items on the way out, so there is no
* second chance to change your mind, and the dialog says so rather than
* leaving it to be discovered.
*/
import { confirmDialog } from "@/ui/dialog";
import { useMail } from "@/store/mail";
import type { Id, MailboxRole } from "@/jmap/types";
export interface EmptyTarget {
id: Id;
name: string;
role: MailboxRole;
totalEmails: number;
}
/** Whether this folder is one that may be emptied at all. */
export function canEmpty(role: MailboxRole | undefined | null): boolean {
return role === "trash" || role === "junk";
}
const plural = (n: number) => `${n.toLocaleString()} message${n === 1 ? "" : "s"}`;
/** What the button or menu item is called, in the folder's own terms. */
export function emptyLabel(target: Pick<EmptyTarget, "name" | "role">): string {
return target.role === "junk" ? "Delete all spam" : `Empty ${target.name}`;
}
/** Ask, then empty. Resolves once the emptying has been attempted, or declined. */
export async function confirmAndEmpty(target: EmptyTarget): Promise<void> {
if (!canEmpty(target.role)) return;
const junk = target.role === "junk";
const ok = await confirmDialog({
title: junk ? `Delete all spam in “${target.name}”?` : `Empty “${target.name}”?`,
message: junk
? `All ${plural(target.totalEmails)} will be deleted permanently. They do not go to Deleted Items first, so this cannot be undone.`
: `All ${plural(target.totalEmails)} will be permanently deleted.`,
confirmLabel: junk ? "Delete all spam" : "Empty folder",
danger: true,
});
if (ok) await useMail.getState().emptyMailbox(target.id);
}
+72
View File
@@ -0,0 +1,72 @@
import type { Id, Mailbox } from "@/jmap/types";
/**
* A folder can be moved unless the server gave it a role. Inbox, Sent, Trash and
* the rest are structural, and the server refuses to reparent them anyway --
* better not to offer the drag at all.
*/
export function movable(m: Mailbox): boolean {
return !m.role || m.role === "subscribed";
}
/**
* Every node beneath this one, so a node cannot be dropped inside itself.
*
* Written against `{ id, parentId }` rather than `Mailbox` because file nodes
* form the same shape of tree and need the same answer -- see `canDropFileNode`.
*/
export function descendantIds<T extends { id: Id; parentId: Id | null }>(tree: Record<Id, T>, id: Id): Set<Id> {
const out = new Set<Id>();
const all = Object.values(tree);
let frontier = new Set<Id>([id]);
// Depth is bounded by the server's own depth limit; the guard is only here so
// a cycle in the data cannot spin forever.
for (let depth = 0; depth < 20 && frontier.size; depth++) {
const next = new Set<Id>();
for (const m of all) {
if (m.parentId && frontier.has(m.parentId) && !out.has(m.id)) {
out.add(m.id);
next.add(m.id);
}
}
frontier = next;
}
return out;
}
/**
* Whether `draggedId` may be dropped on `targetId`, where null means the root.
*
* Four ways it cannot: the folder is not movable at all, it is being dropped on
* itself, into its own subtree — which would orphan the branch — or onto the
* parent it already has, which would be a no-op dressed up as a move.
*/
export function canDropFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id | null): boolean {
const dragged = mailboxes[draggedId];
if (!dragged || !movable(dragged)) return false;
if (targetId === null) return dragged.parentId != null;
if (targetId === draggedId) return false;
if (dragged.parentId === targetId) return false;
if (!mailboxes[targetId]) return false;
return !descendantIds(mailboxes, draggedId).has(targetId);
}
/**
* Whether `id` may be moved under `targetId` (null: the top level) from the
* folder picker.
*
* Stricter than a drop, because the picker lists every folder at once rather
* than letting the server refuse one drag: moving needs `mayRename` on the
* folder itself -- RFC 8621 folds reparenting into that right -- and
* `mayCreateChild` on the destination. Both only ever say no on shared mail.
*/
export function canMoveFolderTo(mailboxes: Record<Id, Mailbox>, id: Id, targetId: Id | null): boolean {
if (!mailboxes[id]?.myRights.mayRename) return false;
if (!canDropFolder(mailboxes, id, targetId)) return false;
return targetId === null || Boolean(mailboxes[targetId]?.myRights.mayCreateChild);
}
/** The color chosen for a folder, if any. Ids are used, so a rename keeps it. */
export function folderColor(colors: Record<string, string>, id: Id): string | null {
return colors[id] ?? null;
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Labels arranged into a tree, and which of them the sidebar draws.
*
* **Nesting is display only.** The keywords stay flat on the message, which is
* what keeps them readable by every other client — moving a label under
* another rewrites nothing in the mailbox, and a client that knows nothing
* about ihasmail still sees the same keywords it always did.
*/
import type { Label, LabelVisibility } from "@/store/settings";
export interface LabelNode {
label: Label;
/** 0 at the top; only ever used to indent. */
depth: number;
children: LabelNode[];
/** Unread messages carrying this keyword. Its own, not its children's. */
unread: number;
}
const visibilityOf = (l: Label): LabelVisibility => l.visibility ?? "always";
/**
* Build the tree.
*
* Two malformed shapes have to survive, because settings sync between devices
* and a label can be deleted on one while another is still pointing at it:
*
* - **A parent that no longer exists** puts its child back at the top level
* rather than dropping it. A label that vanishes from the sidebar because
* something else was deleted is a label the reader cannot get back.
* - **A cycle** — a under b, b under a — is broken by treating the first
* label that closes the loop as a root. Nothing is lost and nothing hangs.
*/
export function labelTree(labels: Label[], counts: Record<string, number> = {}): LabelNode[] {
const byKeyword = new Map<string, Label>();
for (const l of labels) byKeyword.set(l.keyword, l);
/** Whether following `parent` from here reaches a real root without looping. */
const rooted = (l: Label): boolean => {
const seen = new Set<string>([l.keyword]);
let cur = l.parent ? byKeyword.get(l.parent) : undefined;
while (cur) {
if (seen.has(cur.keyword)) return false;
seen.add(cur.keyword);
cur = cur.parent ? byKeyword.get(cur.parent) : undefined;
}
return true;
};
const nodes = new Map<string, LabelNode>();
for (const l of labels) nodes.set(l.keyword, { label: l, depth: 0, children: [], unread: counts[l.keyword] ?? 0 });
const roots: LabelNode[] = [];
for (const l of labels) {
const node = nodes.get(l.keyword)!;
const parent = l.parent && l.parent !== l.keyword && rooted(l) ? nodes.get(l.parent) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
const setDepth = (n: LabelNode, depth: number) => {
n.depth = depth;
for (const c of n.children) setDepth(c, depth + 1);
};
for (const r of roots) setDepth(r, 0);
return roots;
}
/**
* The nodes the sidebar draws, flattened in the order they appear.
*
* `hidden` removes a label outright. `unread` shows it only while it has
* unread mail — which is the point of it: a label you filed something under
* two years ago should not take up a row for ever.
*
* **A label kept by the rule keeps its ancestors, whatever they said.** A
* child cannot be drawn under a parent that is not there; the alternative is
* promoting it to the top level, which silently rearranges the tree at the
* moment the reader is least able to explain why. The parent comes back as a
* container, and its own count still says whether it has anything of its own.
*/
export function visibleLabels(roots: LabelNode[]): LabelNode[] {
const keep = new Set<LabelNode>();
const walk = (n: LabelNode): boolean => {
// Depth-first: a node's fate depends on its descendants, not the reverse.
let keptChild = false;
for (const c of n.children) keptChild = walk(c) || keptChild;
const v = visibilityOf(n.label);
const self = v === "always" || (v === "unread" && n.unread > 0);
if (self || keptChild) {
keep.add(n);
return true;
}
return false;
};
for (const r of roots) walk(r);
const out: LabelNode[] = [];
const emit = (n: LabelNode) => {
if (!keep.has(n)) return;
out.push(n);
for (const c of n.children) emit(c);
};
for (const r of roots) emit(r);
return out;
}
/**
* Keywords that would become unreachable if `keyword` were re-parented under
* `candidate` — used to keep the parent picker from offering a cycle.
*/
export function descendantKeywords(roots: LabelNode[], keyword: string): Set<string> {
const out = new Set<string>();
const find = (n: LabelNode): LabelNode | null => {
if (n.label.keyword === keyword) return n;
for (const c of n.children) {
const hit = find(c);
if (hit) return hit;
}
return null;
};
let node: LabelNode | null = null;
for (const r of roots) {
node = find(r);
if (node) break;
}
if (!node) return out;
const collect = (n: LabelNode) => {
for (const c of n.children) {
out.add(c.label.keyword);
collect(c);
}
};
collect(node);
return out;
}
+79
View File
@@ -0,0 +1,79 @@
import { tc } from "@/lib/i18n";
import type { Mailbox } from "@/jmap/types";
/**
* What to call a folder on screen.
*
* Stalwart names the standard folders once, when the account is created, in
* whatever language the server was set up in — and never renames them
* afterwards, because the name is stored data every other client has mapped.
* So a German reader on an English-provisioned account sees "Deleted Items"
* in an otherwise German app, and there is nothing the server can be asked to
* do about it: the account locale exists in `x:AccountSettings`, but writing it
* needs `sysAccountSettingsSet`, which the built-in user role does not carry.
*
* The role is the way out. JMAP tags the standard folders — `inbox`, `trash`,
* `drafts` and the rest — and ihasmail already trusts the role rather than the
* name everywhere it matters, so the display name can follow the interface
* language without anything being written to the server.
*
* Only the roles. A folder somebody made and called "Newsletters" keeps that
* name, because those are their words and translating them would be inventing
* a folder they never made.
*
* The cost, and it is real: another client on the same account still shows
* "Deleted Items", because that is what the folder is called. Within ihasmail
* this stays consistent — everything that names a folder goes through here,
* including the "moved to …" toast, which exists precisely so that message
* does not name somewhere the reader cannot find.
*/
/*
* Every one of these is translated in the "folder" context, including the
* unambiguous ones. Two of them genuinely need it -- "Archive" is also the
* button that archives, "Important" is also a priority tag, and German wants a
* different word for each -- and applying it to only those two would leave the
* next person to notice which. A context on all of them is one rule.
*/
const ROLE_NAMES: Record<string, () => string> = {
inbox: () => tc("folder", "Inbox"),
archive: () => tc("folder", "Archive"),
drafts: () => tc("folder", "Drafts"),
sent: () => tc("folder", "Sent"),
trash: () => tc("folder", "Deleted Items"),
junk: () => tc("folder", "Junk Mail"),
important: () => tc("folder", "Important"),
all: () => tc("folder", "All mail"),
};
/** The folder's name as the reader should see it. */
export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string {
if (!mailbox) return "";
const localized = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
return localized ? localized() : mailbox.name;
}
/**
* Whether this folder's displayed name is ihasmail's rather than the server's.
*
* Anything that *edits* the name has to know: a rename dialog prefilled with
* "Papierkorb" would rename the folder to that on the server the moment
* somebody pressed Save, which is a real change made by accident to a folder
* they were only looking at. Renaming a role folder is refused anyway, but
* relying on that would be relying on a rule enforced somewhere else.
*/
export function isLocalizedName(mailbox: { role?: string | null } | null | undefined): boolean {
return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES);
}
/** A path of folder names, for a picker that shows where a folder sits. */
export function mailboxDisplayPath(mailbox: Mailbox, all: Record<string, Mailbox>): string {
const parts: string[] = [];
let cur: Mailbox | undefined = mailbox;
const seen = new Set<string>();
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
parts.unshift(mailboxDisplayName(cur));
cur = cur.parentId ? all[cur.parentId] : undefined;
}
return parts.join(" / ");
}
+27
View File
@@ -0,0 +1,27 @@
import type { Id, Mailbox } from "@/jmap/types";
/**
* Whether the folder in the address is one this account does not have.
*
* Rendering it as an empty folder was the bug (#111): "Nothing here. This
* folder is empty" is a claim about a folder that is not there, so a stale link
* read as a folder that had emptied itself rather than one that was gone.
*
* The condition that matters is `loaded`. The folder list arrives after the
* first paint, so for a moment every id is unknown -- including the right one.
* Without that gate this answers true on every cold load and sends the reader
* to their inbox from the folder they asked for, which is a worse bug than the
* one it fixes and would look exactly like a flaky link.
*/
export function isUnknownMailbox(args: {
mailboxId: Id | undefined;
mailboxes: Record<Id, Mailbox>;
loaded: boolean;
search?: boolean;
}): boolean {
const { mailboxId, mailboxes, loaded, search } = args;
if (search) return false;
if (!mailboxId) return false;
if (!loaded) return false;
return !mailboxes[mailboxId];
}