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:
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/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([]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* the joining is Intl's rather than a hardcoded " and ".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeRule as describeSieve } from "../sieve";
|
||||
import { describeRule as describeSieve } from "../sieve/sieve";
|
||||
import { describeRule as describeRecurrence, weekdayOptions } from "../calendar/recurrence";
|
||||
import { setUiLanguageForFormatting } from "../datetime";
|
||||
import { setCatalog } from "../i18n";
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
|
||||
/**
|
||||
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||
* gets wrong if you take it at face value.
|
||||
*
|
||||
* `readEntries` answers with *up to* some number of entries and signals the end
|
||||
* of a directory with an empty array, so a single call quietly loses everything
|
||||
* past the first batch — a real folder of a few hundred files would upload the
|
||||
* first hundred and look like it had finished. And a directory tree that cycles
|
||||
* has to stop somewhere the tab is still alive.
|
||||
*/
|
||||
|
||||
const file = (name: string) => new File([name], name);
|
||||
|
||||
/** A directory whose contents arrive a batch at a time, as a real one does. */
|
||||
const dir = (name: string, children: unknown[], batch = 2) => {
|
||||
let at = 0;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
createReader: () => ({
|
||||
readEntries: (cb: (e: never[]) => void) => {
|
||||
const slice = children.slice(at, at + batch);
|
||||
at += slice.length;
|
||||
cb(slice as never[]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const leaf = (name: string) => ({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
file: (cb: (f: File) => void) => cb(file(name)),
|
||||
});
|
||||
|
||||
describe("walking a dropped folder", () => {
|
||||
it("reads a directory across as many batches as it takes", async () => {
|
||||
// Five children, two per readEntries call: a single read would find two.
|
||||
const plan = await planUpload([dir("docs", ["a", "b", "c", "d", "e"].map(leaf))] as never[]);
|
||||
expect(plan.map((p) => p.file.name)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
expect(plan.every((p) => p.path.join("/") === "docs")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the folder each file came from", async () => {
|
||||
const plan = await planUpload([dir("outer", [leaf("top"), dir("inner", [leaf("deep")])])] as never[]);
|
||||
expect(plan.map((p) => [p.path.join("/"), p.file.name])).toEqual([
|
||||
["outer", "top"],
|
||||
["outer/inner", "deep"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts a loose file at the drop itself", async () => {
|
||||
const plan = await planUpload([leaf("loose")] as never[]);
|
||||
expect(plan).toEqual([expect.objectContaining({ path: [] })]);
|
||||
});
|
||||
|
||||
it("stops rather than following a cycle for ever", async () => {
|
||||
const loop: Record<string, unknown> = {};
|
||||
Object.assign(loop, dir("loop", []));
|
||||
(loop as { createReader: () => unknown }).createReader = () => ({
|
||||
readEntries: (cb: (e: unknown[]) => void) => cb([loop]),
|
||||
});
|
||||
// Terminating at all is the assertion; the caps decide where. Both are set
|
||||
// low so the test does not have to read twenty thousand phantom entries.
|
||||
const plan = await planUpload([loop] as never[], { maxDepth: 4, maxEntries: 50 });
|
||||
expect(plan).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folders a plan needs", () => {
|
||||
it("lists parents before their children", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a", "b", "c"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"], ["a", "b"], ["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("names each folder once, however many files are in it", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("asks for nothing when everything lands at the drop", () => {
|
||||
expect(foldersNeeded([{ file: file("x"), path: [] }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotting a folder in the drop", () => {
|
||||
it("is true when any entry is a directory", () => {
|
||||
expect(hasDirectory([leaf("a"), dir("d", [])] as never[])).toBe(true);
|
||||
expect(hasDirectory([leaf("a")] as never[])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emlFilename, sanitizeFilename } from "@/lib/emlName";
|
||||
|
||||
describe("emlFilename", () => {
|
||||
it("keeps an ordinary subject, with spaces as underscores", () => {
|
||||
expect(emlFilename("Quarterly report")).toBe("Quarterly_report.eml");
|
||||
});
|
||||
|
||||
it("keeps letters from any script, which the ASCII rule threw away", () => {
|
||||
// The whole point: none of these may come out as a row of underscores.
|
||||
expect(emlFilename("Квартальный отчёт")).toBe("Квартальный_отчёт.eml");
|
||||
expect(emlFilename("四半期報告")).toBe("四半期報告.eml");
|
||||
expect(emlFilename("Rapport trimestriel été")).toBe("Rapport_trimestriel_été.eml");
|
||||
});
|
||||
|
||||
it("keeps the punctuation that is fine in a filename", () => {
|
||||
expect(emlFilename("Re- budget (v3) [final]")).toBe("Re-_budget_(v3)_[final].eml");
|
||||
});
|
||||
|
||||
it("drops path separators and the characters Windows reserves", () => {
|
||||
expect(emlFilename("a/b\\c:d*e?f\"g<h>i|j")).toBe("abcdefghij.eml");
|
||||
});
|
||||
|
||||
it("drops control characters", () => {
|
||||
expect(emlFilename("a\u0007b\u0000c")).toBe("abc.eml");
|
||||
expect(emlFilename("a\u007fb")).toBe("ab.eml");
|
||||
});
|
||||
|
||||
it("falls back when there is no subject, or nothing survives", () => {
|
||||
expect(emlFilename("")).toBe("message.eml");
|
||||
expect(emlFilename(null)).toBe("message.eml");
|
||||
expect(emlFilename(undefined)).toBe("message.eml");
|
||||
expect(emlFilename("///")).toBe("message.eml");
|
||||
expect(emlFilename(" ")).toBe("message.eml");
|
||||
});
|
||||
|
||||
it("does not end in a dot or a space, which Windows refuses", () => {
|
||||
expect(emlFilename("Report.")).toBe("Report.eml");
|
||||
expect(emlFilename("Report ")).toBe("Report.eml");
|
||||
expect(emlFilename("...Report...")).toBe("Report.eml");
|
||||
});
|
||||
|
||||
it("does not start with a dot, which would hide the file on Unix", () => {
|
||||
expect(emlFilename(".hidden")).toBe("hidden.eml");
|
||||
});
|
||||
|
||||
it("caps the length so it survives a filesystem limit", () => {
|
||||
const name = emlFilename("x".repeat(500));
|
||||
expect(name).toBe(`${"x".repeat(80)}.eml`);
|
||||
});
|
||||
|
||||
it("exposes the stem on its own", () => {
|
||||
expect(sanitizeFilename("Quarterly report")).toBe("Quarterly_report");
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canEmpty, emptyLabel } from "@/lib/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");
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMAIL_BASE_CSS, LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
|
||||
|
||||
describe("sanitizeEmailHtml", () => {
|
||||
it("removes scripts and event handlers", () => {
|
||||
const r = sanitizeEmailHtml('<div onclick="x()">hi<script>alert(1)</script><iframe src="https://evil"></iframe></div>');
|
||||
expect(r.html).not.toContain("script");
|
||||
expect(r.html).not.toContain("onclick");
|
||||
expect(r.html).not.toContain("iframe");
|
||||
});
|
||||
it("blocks remote images until allowed and maps cid", () => {
|
||||
const src = '<img src="https://t.example/p.gif"><img src="cid:logo@x"><div style="background:url(https://t.example/b.png)">x</div>';
|
||||
const blocked = sanitizeEmailHtml(src, { cidMap: { "logo@x": "/api/blob/a/b/logo.png" } });
|
||||
expect(blocked.remoteCount).toBe(2);
|
||||
expect(blocked.html).toContain('data-ihm-blocked="1"');
|
||||
expect(blocked.html).toContain("/api/blob/a/b/logo.png");
|
||||
expect(blocked.html).not.toMatch(/src="https:\/\/t\.example/);
|
||||
expect(blocked.html).not.toContain("url(https://t.example");
|
||||
const allowed = sanitizeEmailHtml(src, { allowRemote: true, proxyRemote: true });
|
||||
expect(allowed.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fp.gif");
|
||||
});
|
||||
it("forces links to open in new tabs", () => {
|
||||
const r = sanitizeEmailHtml('<a href="https://x.io">x</a>');
|
||||
expect(r.html).toContain('target="_blank"');
|
||||
expect(r.html).toContain("noopener");
|
||||
});
|
||||
it("strips javascript: urls", () => {
|
||||
const r = sanitizeEmailHtml('<a href="javascript:alert(1)">x</a>');
|
||||
expect(r.html).not.toContain("javascript:");
|
||||
});
|
||||
it("editor sanitizer keeps basic formatting", () => {
|
||||
expect(sanitizeEditorHtml("<b>x</b><script>1</script>")).toBe("<b>x</b>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("htmlDeclaresColors", () => {
|
||||
it("is false for mail that brings no colors", () => {
|
||||
expect(htmlDeclaresColors("<p>Hi there</p>")).toBe(false);
|
||||
expect(htmlDeclaresColors("<div><b>bold</b> and <i>italic</i></div>", "font-family:Arial")).toBe(false);
|
||||
expect(htmlDeclaresColors('<a href="https://x.io/?color=red">link</a>')).toBe(false);
|
||||
expect(htmlDeclaresColors('<div style="border-color: red">x</div>')).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when the message paints itself", () => {
|
||||
expect(htmlDeclaresColors('<td bgcolor="#ffffff">x</td>')).toBe(true);
|
||||
expect(htmlDeclaresColors('<font color="red">x</font>')).toBe(true);
|
||||
expect(htmlDeclaresColors('<div style="color:#333">x</div>')).toBe(true);
|
||||
expect(htmlDeclaresColors('<div style="background-color:#fff">x</div>')).toBe(true);
|
||||
expect(htmlDeclaresColors("<style>p { color: red }</style><p>x</p>")).toBe(true);
|
||||
expect(htmlDeclaresColors("<p>plain</p>", "background:#eee")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Forcing the theme onto mail that styles itself — issue #290.
|
||||
*
|
||||
* The switch above it leaves nearly all HTML mail alone, because one color
|
||||
* anywhere opts a message out. What this half has to get right is telling a
|
||||
* sheet the design sits on from a surface painted on top of it: neutralize the
|
||||
* first and the white card goes away, keep the second and a button keeps a
|
||||
* label you can still read.
|
||||
*/
|
||||
describe("relativeLuminance", () => {
|
||||
it("reads the forms mail actually uses", () => {
|
||||
expect(relativeLuminance("#ffffff")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("#FFF")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("#000000")).toBeCloseTo(0, 5);
|
||||
expect(relativeLuminance("white")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("rgb(255, 255, 255)")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("has nothing to say about a color it cannot read", () => {
|
||||
// Not a failure: the caller treats null as "no deliberate surface", which
|
||||
// is the safe way round — an unreadable color must not keep a white sheet.
|
||||
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
|
||||
expect(relativeLuminance("var(--brand)")).toBeNull();
|
||||
expect(relativeLuminance("")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a fully transparent color as painting nothing", () => {
|
||||
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
|
||||
expect(relativeLuminance("transparent")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts a white wrapper above the threshold and a call to action below it", () => {
|
||||
expect(relativeLuminance("#ffffff")!).toBeGreaterThanOrEqual(LIGHT_SURFACE_LUMINANCE);
|
||||
expect(relativeLuminance("#1155CC")!).toBeLessThan(LIGHT_SURFACE_LUMINANCE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markKeptSurfaces", () => {
|
||||
const frag = (html: string) => {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = html;
|
||||
return d;
|
||||
};
|
||||
|
||||
/*
|
||||
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
|
||||
* that reads the marks, and #310 was a bug in that half rather than in the
|
||||
* marking. So these assert what the reader actually sees: does the
|
||||
* neutralizer hit this element? The selector is lifted out of the stylesheet
|
||||
* rather than copied, so a test cannot quietly drift from the rule it checks.
|
||||
*/
|
||||
const NEUTRALIZER = (() => {
|
||||
const m = EMAIL_BASE_CSS.match(
|
||||
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
|
||||
);
|
||||
if (!m) throw new Error("could not find the neutralizer rule in EMAIL_BASE_CSS");
|
||||
return m[1]!.trim();
|
||||
})();
|
||||
|
||||
/** True when the theme is forced onto this element rather than leaving it alone. */
|
||||
const neutralized = (el: Element) => el.matches(NEUTRALIZER);
|
||||
|
||||
it("keeps a colored button and drops the white sheet around it", () => {
|
||||
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
|
||||
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
|
||||
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
|
||||
expect(markKeptSurfaces(d)).toBe(1);
|
||||
expect(d.querySelector("table")!.hasAttribute("data-ihm-keep")).toBe(false);
|
||||
expect(d.querySelector("td")!.hasAttribute("data-ihm-keep")).toBe(true);
|
||||
// The label is not a painted surface itself. It is marked as sitting on
|
||||
// one, which is what stops white-on-blue turning unreadable.
|
||||
expect(d.querySelector("a")!.hasAttribute("data-ihm-keep")).toBe(false);
|
||||
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
|
||||
});
|
||||
|
||||
it("neutralizes a light panel nested inside a dark painted card", () => {
|
||||
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
|
||||
// are dark enough to be marked, with light content tables inside them.
|
||||
// Those tables used to inherit the card's exemption and render as beige
|
||||
// sheets in an otherwise themed message.
|
||||
const d = frag(
|
||||
'<div style="background-color:#e7e5e2">' +
|
||||
'<div style="background-color:#2b2b2b">' +
|
||||
'<table style="background-color:#e7e5e2"><tr><td>copy</td></tr></table>' +
|
||||
'</div>' +
|
||||
'</div>',
|
||||
);
|
||||
expect(markKeptSurfaces(d)).toBe(1);
|
||||
|
||||
const divs = Array.from(d.querySelectorAll("div"));
|
||||
const surround = divs[0]!;
|
||||
const card = divs[1]!;
|
||||
const nested = d.querySelector("table")!;
|
||||
|
||||
// The page surround is a sheet and always was.
|
||||
expect(surround.hasAttribute("data-ihm-keep")).toBe(false);
|
||||
// The card is paint and stays paint.
|
||||
expect(card.hasAttribute("data-ihm-keep")).toBe(true);
|
||||
// The fix, stated the way the reader experiences it: the nested sheet is
|
||||
// themed, and so is the copy inside it. Before #310 both were exempt for
|
||||
// being descendants of the card.
|
||||
expect(neutralized(nested)).toBe(true);
|
||||
expect(neutralized(d.querySelector("td")!)).toBe(true);
|
||||
// The card itself is still left alone, and the page surround still goes.
|
||||
expect(neutralized(card)).toBe(false);
|
||||
expect(neutralized(surround)).toBe(true);
|
||||
});
|
||||
|
||||
it("still keeps a button that sits inside a nested light panel", () => {
|
||||
// Paint resumes below a sheet, however deep it is: the fix must not cost
|
||||
// a call to action its label just because a sheet came between it and the
|
||||
// card it is on.
|
||||
const d = frag(
|
||||
'<div style="background-color:#2b2b2b">' +
|
||||
'<table style="background-color:#ffffff"><tr>' +
|
||||
'<td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td>' +
|
||||
'</tr></table>' +
|
||||
'</div>',
|
||||
);
|
||||
expect(markKeptSurfaces(d)).toBe(2);
|
||||
expect(neutralized(d.querySelector("table")!)).toBe(true);
|
||||
expect(neutralized(d.querySelector("td")!)).toBe(false);
|
||||
// The label keeps its white, which is the thing #294 bought and this must
|
||||
// not spend.
|
||||
expect(neutralized(d.querySelector("a")!)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves no light panel exempt across the whole reported specimen", () => {
|
||||
// #310 as reported: a dark campaign with no bgcolor attributes, 21 light
|
||||
// panels, 14 of them nested inside dark 600px cards. Those fourteen were
|
||||
// the ones rendering as beige sheets.
|
||||
let cards = "";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
cards +=
|
||||
'<div style="background-color:#2b2b2b">' +
|
||||
'<table style="background-color:#e7e5e2"><tr><td>copy</td></tr></table>' +
|
||||
'<table style="background-color:#e7e5e2"><tr><td>more</td></tr></table>' +
|
||||
"</div>";
|
||||
}
|
||||
let loose = "";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
loose += '<table style="background-color:#e7e5e2"><tr><td>loose</td></tr></table>';
|
||||
}
|
||||
const d = frag('<div style="background-color:#e7e5e2">' + cards + loose + "</div>");
|
||||
|
||||
const panels = Array.from(d.querySelectorAll<HTMLElement>("table"));
|
||||
expect(panels.length).toBe(21);
|
||||
|
||||
expect(markKeptSurfaces(d)).toBe(7);
|
||||
expect(panels.filter((p) => !neutralized(p))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads an inline background as well as the attribute", () => {
|
||||
const d = frag('<div style="background-color:#111827">dark</div><div style="background:#f8f8ff">sheet</div>');
|
||||
expect(markKeptSurfaces(d)).toBe(1);
|
||||
expect(d.querySelectorAll("[data-ihm-keep]").length).toBe(1);
|
||||
expect((d.querySelector("[data-ihm-keep]") as HTMLElement).textContent).toBe("dark");
|
||||
});
|
||||
|
||||
it("marks nothing in mail that paints no backgrounds", () => {
|
||||
const d = frag('<p style="color:#333">text</p><a href="https://x.io">link</a>');
|
||||
expect(markKeptSurfaces(d)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the sender's own markup alone, so the switch is reversible", () => {
|
||||
const d = frag('<table><tr><td bgcolor="#1155CC" style="color:#fff">Buy</td></tr></table>');
|
||||
markKeptSurfaces(d);
|
||||
const td = d.querySelector("td")!;
|
||||
expect(td.getAttribute("bgcolor")).toBe("#1155CC");
|
||||
expect(td.style.color).toBe("rgb(255, 255, 255)");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A shadow root scopes selectors, not layout. Mail CSS saying `position:fixed`
|
||||
* is still positioned against the viewport, so a sender could paint over the
|
||||
* whole application — a ready-made phishing surface inside our own origin.
|
||||
*
|
||||
* The control that actually stops it is layout containment on an ancestor of
|
||||
* the shadow host, which mail CSS has no selector for; that lives in app.css
|
||||
* and is asserted at the bottom of this file, because jsdom does no layout and
|
||||
* cannot prove it here. These cover the second line of defense.
|
||||
*/
|
||||
describe("mail CSS cannot climb out of its card", () => {
|
||||
const render = (html: string) => sanitizeEmailHtml(html).html;
|
||||
|
||||
it("turns fixed and sticky positioning into static", () => {
|
||||
const out = render(`<div><style>.x{position:fixed;inset:0;z-index:2147483647}</style><p class="x">hi</p></div>`);
|
||||
expect(out).toContain("position:static");
|
||||
expect(out).not.toMatch(/position\s*:\s*fixed/i);
|
||||
});
|
||||
|
||||
it("does so in style attributes too, however they are spaced", () => {
|
||||
expect(render(`<p style="position: FIXED; color:red">x</p>`)).not.toMatch(/position\s*:\s*fixed/i);
|
||||
expect(render(`<p style="position:sticky;top:0">x</p>`)).not.toMatch(/position\s*:\s*sticky/i);
|
||||
});
|
||||
|
||||
it("defangs :host, which is how mail CSS would reach the host element", () => {
|
||||
const out = render(`<div><style>:host{contain:none!important;position:fixed!important}</style><p>x</p></div>`);
|
||||
expect(out).not.toContain(":host");
|
||||
expect(out).not.toMatch(/position\s*:\s*fixed/i);
|
||||
});
|
||||
|
||||
it("leaves ordinary positioning alone", () => {
|
||||
const out = render(`<div><style>.a{position:relative}.b{position:absolute;top:2px}</style><p>x</p></div>`);
|
||||
expect(out).toContain("position:relative");
|
||||
expect(out).toContain("position:absolute");
|
||||
});
|
||||
|
||||
it("still rewrites url() while hardening", () => {
|
||||
const out = sanitizeEmailHtml(`<div><style>.x{position:fixed;background:url(https://tracker.example/p.gif)}</style><p>x</p></div>`, { allowRemote: true, proxyRemote: true }).html;
|
||||
expect(out).toContain("position:static");
|
||||
expect(out).toContain("/api/image?url=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the containment that mail CSS cannot override", () => {
|
||||
it("is still applied to the message body container", async () => {
|
||||
// jsdom does no layout, so this asserts the control is present rather than
|
||||
// that it works; the behavior was verified in a real browser. Without it,
|
||||
// a message can cover the viewport regardless of what the sanitizer does.
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
// vitest serves modules over http, so import.meta.url is not a file URL.
|
||||
const css = await readFile(join(process.cwd(), "src/styles/app.css"), "utf8");
|
||||
const rule = /\.message-body\s*\{[^}]*\}/.exec(css)?.[0] ?? "";
|
||||
expect(rule).toMatch(/contain\s*:\s*layout/);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasHtmlAlternative } from "../html";
|
||||
import { hasHtmlAlternative } from "../text/html";
|
||||
|
||||
/*
|
||||
* The rule: `htmlBody` is derived, so its presence proves nothing. Only the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isTextEntry, keyboard } from "@/lib/keyboard";
|
||||
import { isTextEntry, keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* Shortcuts after a click on a checkbox (#260).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { comboOf, keyboard } from "@/lib/keyboard";
|
||||
import { comboOf, keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* A "keydown" that carries no key. Chrome's password autofill dispatches one
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* Two-key sequences against the single keys they start with.
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rowClick, type RowClick } from "@/lib/listSelection";
|
||||
|
||||
const IDS = ["a", "b", "c", "d", "e"];
|
||||
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
|
||||
rowClick({
|
||||
rowId: "c", ids: IDS, anchor: null, selected: {},
|
||||
modifiers: { shift: false, ctrl: false }, isMobile: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("a plain click", () => {
|
||||
it("opens the message rather than selecting it", () => {
|
||||
expect(click()).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("opens it even when another message is already open", () => {
|
||||
expect(click({ anchor: "a" })).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("goes on selecting on a touchscreen once a selection exists", () => {
|
||||
// There is no modifier to hold on a phone, and opening a message in the
|
||||
// middle of picking several is almost never what the tap meant.
|
||||
expect(click({ isMobile: true, selected: { a: true } })).toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("still opens on a touchscreen when nothing is selected", () => {
|
||||
expect(click({ isMobile: true })).toEqual({ kind: "open" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctrl-clicking", () => {
|
||||
it("takes the message that was already current with it", () => {
|
||||
// Issue #186: this used to select only the row clicked, leaving the open
|
||||
// message highlighted but unticked, so actions applied to one of two.
|
||||
expect(click({ anchor: "a", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["a", "c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("toggles one row once there is a selection, and leaves the rest alone", () => {
|
||||
expect(click({ anchor: "a", selected: { a: true, c: true }, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: false, moveAnchor: true });
|
||||
expect(click({ anchor: "a", selected: { a: true }, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("selects just the row when there is nothing current to bring along", () => {
|
||||
expect(click({ anchor: null, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("does not bring along a row that has scrolled out of the list", () => {
|
||||
// The anchor can name a message from a folder that is no longer shown.
|
||||
expect(click({ anchor: "gone", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("does not pair a row with itself", () => {
|
||||
expect(click({ rowId: "a", anchor: "a", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["a"], on: true, moveAnchor: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift-clicking", () => {
|
||||
it("takes the whole run, including the row it started from", () => {
|
||||
expect(click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } }))
|
||||
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
|
||||
});
|
||||
|
||||
it("works the same way backwards", () => {
|
||||
expect(click({ rowId: "b", anchor: "d", modifiers: { shift: true, ctrl: false } }))
|
||||
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
|
||||
});
|
||||
|
||||
it("leaves the anchor where it is, so the range grows from one place", () => {
|
||||
const first = click({ rowId: "c", anchor: "a", modifiers: { shift: true, ctrl: false } });
|
||||
expect(first).toMatchObject({ moveAnchor: false });
|
||||
// Extending again still starts at "a" rather than at "c".
|
||||
expect(click({ rowId: "e", anchor: "a", modifiers: { shift: true, ctrl: false } }))
|
||||
.toMatchObject({ ids: ["a", "b", "c", "d", "e"] });
|
||||
});
|
||||
|
||||
it("falls back to opening when there is nothing to extend from", () => {
|
||||
expect(click({ anchor: null, modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("falls back when the anchor is no longer in the list", () => {
|
||||
expect(click({ anchor: "gone", modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two rules agree with each other", () => {
|
||||
it("both include the row the selection started from", () => {
|
||||
// The bug was that only one of them did. Whatever else changes, a modifier
|
||||
// click that begins a selection has to contain the anchor.
|
||||
const withCtrl = click({ rowId: "d", anchor: "b", modifiers: { shift: false, ctrl: true } });
|
||||
const withShift = click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } });
|
||||
for (const result of [withCtrl, withShift]) {
|
||||
expect(result.kind, JSON.stringify(result)).toBe("select");
|
||||
expect((result as { ids: string[] }).ids).toContain("b");
|
||||
expect((result as { ids: string[] }).ids).toContain("d");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/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");
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isUnknownMailbox } from "@/lib/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);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
||||
|
||||
describe("isMarkdown", () => {
|
||||
it("takes the type when there is one", () => {
|
||||
expect(isMarkdown("text/markdown", "a")).toBe(true);
|
||||
expect(isMarkdown("text/x-markdown; charset=utf-8", "a")).toBe(true);
|
||||
expect(isMarkdown("text/plain", "notes.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the name, which is the usual case for an upload", () => {
|
||||
expect(isMarkdown("application/octet-stream", "README.md")).toBe(true);
|
||||
expect(isMarkdown("application/octet-stream", "NOTES.MARKDOWN")).toBe(true);
|
||||
expect(isMarkdown(null, "changelog.mkd")).toBe(true);
|
||||
expect(isMarkdown(null, "readme.txt")).toBe(false);
|
||||
expect(isMarkdown(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
it("renders the ordinary things", () => {
|
||||
const html = renderMarkdown("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain("<strong>bold</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
expect(html).toContain("<li>one</li>");
|
||||
});
|
||||
|
||||
it("renders GitHub tables and fenced code", () => {
|
||||
const html = renderMarkdown("| a | b |\n| - | - |\n| 1 | 2 |\n\n```js\nconst x = 1;\n```\n");
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain("<pre>");
|
||||
});
|
||||
|
||||
/*
|
||||
* Markdown passes raw HTML through by design, and the file came from
|
||||
* somewhere else -- an upload, or a share from another account. Every one of
|
||||
* these renders as a script tag without a sanitizer.
|
||||
*/
|
||||
it("takes out anything that would execute", () => {
|
||||
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toContain("onerror");
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
it("does not keep a javascript: link", () => {
|
||||
const html = renderMarkdown("[click](javascript:alert(1))");
|
||||
expect(html).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("shows an image as a link instead of fetching it", () => {
|
||||
// A remote image in a file is a tracking pixel by another name; this app
|
||||
// blocks those in mail and does not undo that here.
|
||||
const html = renderMarkdown("");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain('class="md-img"');
|
||||
expect(html).toContain("a diagram");
|
||||
expect(html).toContain("https://tracker.example/px.png");
|
||||
});
|
||||
|
||||
it("keeps a relative image visible even though it cannot resolve", () => {
|
||||
const html = renderMarkdown("");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("local");
|
||||
// Nothing to link to, so it is text rather than a dead link.
|
||||
expect(html).not.toContain('href="./diagram.png"');
|
||||
});
|
||||
|
||||
it("sends links out of the app safely", () => {
|
||||
const html = renderMarkdown("[docs](https://docs.ihasmail.org)");
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
expect(html).toContain('target="_blank"');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
||||
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
|
||||
|
||||
/**
|
||||
* The handoff, from the tab's side. The worker's half cannot be exercised here
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { newRule, reorderRules, rulesToSieve, sieveToRules, testToSieve, sieveString, upsertRule, type SieveRule } from "../sieve";
|
||||
|
||||
describe("sieve codec", () => {
|
||||
it("escapes strings", () => {
|
||||
expect(sieveString('a "quoted" \\ value')).toBe('"a \\"quoted\\" \\\\ value"');
|
||||
});
|
||||
it("generates tests", () => {
|
||||
expect(testToSieve({ type: "header", header: "subject", op: "contains", value: "hi" })).toBe('header :contains "subject" "hi"');
|
||||
expect(testToSieve({ type: "header", header: "x-foo", op: "notexists", value: "" })).toBe('not exists "x-foo"');
|
||||
expect(testToSieve({ type: "address", header: "from", part: "domain", op: "is", value: "example.com" })).toBe('address :domain :is "from" "example.com"');
|
||||
expect(testToSieve({ type: "size", op: "over", value: 2048 })).toBe("size :over 2048");
|
||||
});
|
||||
it("round-trips rules through a script", () => {
|
||||
const rules = [
|
||||
newRule({ id: "r1", name: "Newsletters", tests: [{ type: "header", header: "list-id", op: "exists", value: "" }], actions: [{ type: "fileinto", mailbox: "Newsletters" }, { type: "markread" }, { type: "stop" }] }),
|
||||
newRule({ id: "r2", name: "Big", enabled: false, join: "anyof", tests: [{ type: "size", op: "over", value: 5_000_000 }], actions: [{ type: "addflag", flag: "big" }] }),
|
||||
];
|
||||
const script = rulesToSieve(rules);
|
||||
expect(script).toContain('require ["fileinto", "imap4flags"];');
|
||||
expect(script).toContain('if exists "list-id"');
|
||||
expect(script).toContain('fileinto "Newsletters";');
|
||||
expect(script).toContain('addflag "\\\\Seen";');
|
||||
expect(script).toContain("# (disabled) Big");
|
||||
expect(sieveToRules(script)).toEqual(rules);
|
||||
});
|
||||
it("keeps an edited rule in its place and appends a new one", () => {
|
||||
const rules = ["r1", "r2", "r3"].map((id) => newRule({ id, name: id }));
|
||||
const renamed = { ...rules[1]!, name: "Renamed" };
|
||||
expect(upsertRule(rules, renamed).map((r) => r.id)).toEqual(["r1", "r2", "r3"]);
|
||||
expect(upsertRule(rules, renamed)[1]!.name).toBe("Renamed");
|
||||
expect(upsertRule(rules, newRule({ id: "r4" })).map((r) => r.id)).toEqual(["r1", "r2", "r3", "r4"]);
|
||||
expect(rules.map((r) => r.name)).toEqual(["r1", "r2", "r3"]);
|
||||
});
|
||||
it("reports hand-written scripts as raw", () => {
|
||||
expect(sieveToRules('require ["fileinto"];\nif true { keep; }')).toBeNull();
|
||||
expect(sieveToRules("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reordering rules", () => {
|
||||
const ids = (rs: SieveRule[]) => rs.map((r) => r.id);
|
||||
const list = ["a", "b", "c", "d"].map((id) => newRule({ id }));
|
||||
|
||||
it("drops a rule above or below the card it was dropped on", () => {
|
||||
expect(ids(reorderRules(list, "a", "c", false))).toEqual(["b", "a", "c", "d"]);
|
||||
expect(ids(reorderRules(list, "a", "c", true))).toEqual(["b", "c", "a", "d"]);
|
||||
expect(ids(reorderRules(list, "d", "a", false))).toEqual(["d", "a", "b", "c"]);
|
||||
expect(ids(reorderRules(list, "b", "d", true))).toEqual(["a", "c", "d", "b"]);
|
||||
});
|
||||
it("leaves the list alone when the drop goes nowhere", () => {
|
||||
expect(reorderRules(list, "a", "a", true)).toBe(list);
|
||||
expect(reorderRules(list, "a", "zz", true)).toBe(list);
|
||||
expect(reorderRules(list, "zz", "a", true)).toBe(list);
|
||||
expect(ids(list)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateRule, evaluateTest } from "../sieveApply";
|
||||
import type { Email } from "@/jmap/types";
|
||||
import type { SieveRule } from "../sieve";
|
||||
|
||||
const email = {
|
||||
id: "e1", blobId: "b", threadId: "t", mailboxIds: { inbox: true }, keywords: {}, size: 5000, receivedAt: "2026-01-01T00:00:00Z",
|
||||
from: [{ name: "Ada Lovelace", email: "[email protected]" }], to: [{ name: null, email: "[email protected]" }], subject: "Invoice #42 is ready", preview: "Please find attached",
|
||||
"header:List-Id:asText": "<dev.lists.example.org>",
|
||||
} as unknown as Email;
|
||||
|
||||
describe("sieve client-side evaluation", () => {
|
||||
it("evaluates header/address/size/body tests", () => {
|
||||
expect(evaluateTest(email, { type: "header", header: "from", op: "contains", value: "ada@" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "subject", op: "matches", value: "invoice*ready" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "subject", op: "regex", value: "^Invoice #\\d+" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "list-id", op: "exists", value: "" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "x-none", op: "notexists", value: "" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "address", header: "from", part: "domain", op: "is", value: "example.org" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "address", header: "from", part: "localpart", op: "is", value: "ada" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "size", op: "over", value: 1000 })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "size", op: "under", value: 1000 })).toBe(false);
|
||||
expect(evaluateTest(email, { type: "body", op: "contains", value: "attached" }, "Please find attached the file")).toBe(true);
|
||||
});
|
||||
it("combines with allof/anyof", () => {
|
||||
const base: SieveRule = { id: "r", name: "r", enabled: true, join: "allof", tests: [{ type: "header", header: "from", op: "contains", value: "ada" }, { type: "header", header: "subject", op: "contains", value: "nope" }], actions: [] };
|
||||
expect(evaluateRule(email, base)).toBe(false);
|
||||
expect(evaluateRule(email, { ...base, join: "anyof" })).toBe(true);
|
||||
expect(evaluateRule(email, { ...base, tests: [{ type: "true" }] })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { retargetRules, detachFolders } from "../sieveFolders";
|
||||
import { newRule, type SieveRule } from "../sieve";
|
||||
|
||||
/**
|
||||
* Rules name their destination folder by path, because that is what Sieve
|
||||
* needs. Rename the folder and the path is a lie: mail stops being filed and
|
||||
* nothing says so. These keep the rules following the folder.
|
||||
*/
|
||||
const fileinto = (mailbox: string, mailboxId?: string, extra: SieveRule["actions"] = []): SieveRule["actions"] =>
|
||||
[{ type: "fileinto", mailbox, ...(mailboxId ? { mailboxId } : {}) }, ...extra];
|
||||
|
||||
const rule = (name: string, actions: SieveRule["actions"]) => newRule({ id: name, name, actions });
|
||||
|
||||
describe("retargetRules", () => {
|
||||
it("follows a folder that was renamed, matching on the id", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1"))];
|
||||
const out = retargetRules(rules, [{ id: "mb1", path: "Newsletters", newPath: "Reading" }]);
|
||||
expect(out.changed).toBe(1);
|
||||
expect(out.rules[0]!.actions[0]).toMatchObject({ mailbox: "Reading", mailboxId: "mb1" });
|
||||
});
|
||||
|
||||
it("follows a folder for older rules that only know the path", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters"))];
|
||||
const out = retargetRules(rules, [{ id: "mb1", path: "newsletters", newPath: "Reading" }]);
|
||||
expect(out.changed).toBe(1);
|
||||
// The id is recorded on the way past, so the next rename needs no guessing.
|
||||
expect(out.rules[0]!.actions[0]).toMatchObject({ mailbox: "Reading", mailboxId: "mb1" });
|
||||
});
|
||||
|
||||
it("follows a child whose parent was renamed", () => {
|
||||
const rules = [rule("inv", fileinto("Work/Invoices", "mb2"))];
|
||||
const out = retargetRules(rules, [
|
||||
{ id: "mb1", path: "Work", newPath: "Clients" },
|
||||
{ id: "mb2", path: "Work/Invoices", newPath: "Clients/Invoices" },
|
||||
]);
|
||||
expect(out.rules[0]!.actions[0]).toMatchObject({ mailbox: "Clients/Invoices" });
|
||||
});
|
||||
|
||||
it("leaves everything alone when nothing actually moved", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1"))];
|
||||
const out = retargetRules(rules, [{ id: "mb1", path: "Newsletters", newPath: "Newsletters" }]);
|
||||
expect(out.changed).toBe(0);
|
||||
expect(out.rules).toBe(rules); // same array, so the caller can skip saving
|
||||
});
|
||||
|
||||
it("does not touch rules aimed somewhere else", () => {
|
||||
const rules = [rule("other", fileinto("Archive", "mb9"))];
|
||||
expect(retargetRules(rules, [{ id: "mb1", path: "Newsletters", newPath: "Reading" }]).changed).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the rule's other actions", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1", [{ type: "markread" }, { type: "stop" }]))];
|
||||
const out = retargetRules(rules, [{ id: "mb1", path: "Newsletters", newPath: "Reading" }]);
|
||||
expect(out.rules[0]!.actions.map((a) => a.type)).toEqual(["fileinto", "markread", "stop"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detachFolders", () => {
|
||||
it("removes only the filing action, leaving the rest of the rule doing its job", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1", [{ type: "markread" }, { type: "stop" }]))];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
|
||||
expect(out.removed).toEqual([]);
|
||||
expect(out.edited).toHaveLength(1);
|
||||
expect(out.rules[0]!.actions.map((a) => a.type)).toEqual(["markread", "stop"]);
|
||||
});
|
||||
|
||||
it("removes the rule when filing was all it did", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters", "mb1")), rule("keep", fileinto("Archive", "mb9"))];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
|
||||
expect(out.removed.map((r) => r.name)).toEqual(["news"]);
|
||||
expect(out.rules.map((r) => r.name)).toEqual(["keep"]);
|
||||
});
|
||||
|
||||
it("handles a deleted folder's children too", () => {
|
||||
const rules = [
|
||||
rule("a", fileinto("Work", "mb1")),
|
||||
rule("b", fileinto("Work/Invoices", "mb2", [{ type: "flag" }])),
|
||||
];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Work" }, { id: "mb2", path: "Work/Invoices" }]);
|
||||
expect(out.removed.map((r) => r.name)).toEqual(["a"]);
|
||||
expect(out.rules.map((r) => r.name)).toEqual(["b"]);
|
||||
expect(out.rules[0]!.actions.map((a) => a.type)).toEqual(["flag"]);
|
||||
});
|
||||
|
||||
it("still finds the rule when only the path matches", () => {
|
||||
const rules = [rule("news", fileinto("Newsletters"))];
|
||||
expect(detachFolders(rules, [{ id: "mb1", path: "NEWSLETTERS" }]).removed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a second filing action aimed somewhere that still exists", () => {
|
||||
const rules = [rule("both", [
|
||||
{ type: "fileinto", mailbox: "Newsletters", mailboxId: "mb1" },
|
||||
{ type: "fileinto", mailbox: "Archive", mailboxId: "mb9", copy: true },
|
||||
])];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
|
||||
expect(out.removed).toEqual([]);
|
||||
expect(out.rules[0]!.actions).toEqual([{ type: "fileinto", mailbox: "Archive", mailboxId: "mb9", copy: true }]);
|
||||
});
|
||||
|
||||
it("leaves the list untouched when nothing matches", () => {
|
||||
const rules = [rule("keep", fileinto("Archive", "mb9"))];
|
||||
const out = detachFolders(rules, [{ id: "mb1", path: "Newsletters" }]);
|
||||
expect(out.rules).toBe(rules);
|
||||
expect(out.edited).toEqual([]);
|
||||
expect(out.removed).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { reloadIfServerRebuilt, makeConnectionWatcher, startBuildWatch } from "@/lib/staleBuild";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
function healthReplies(body: unknown, ok = true) {
|
||||
return vi.fn().mockResolvedValue({ ok, json: async () => body } as unknown as Response);
|
||||
}
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, reload },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("reloadIfServerRebuilt", () => {
|
||||
it("reloads when the server reports a different build", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: `${APP_VERSION}-newer` }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("leaves the page alone when the versions match", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads once per version, not once per 401", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears the guard once the versions agree again", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
await reloadIfServerRebuilt();
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
|
||||
await reloadIfServerRebuilt();
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not reload when the server cannot be reached", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload on a bad response or a missing version", async () => {
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }, false));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
vi.stubGlobal("fetch", healthReplies({ ok: true }));
|
||||
expect(await reloadIfServerRebuilt()).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("noticing without being asked", () => {
|
||||
it("checks when the push stream drops, but not before it has connected", async () => {
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onState = makeConnectionWatcher();
|
||||
|
||||
// never connected: a disconnect is not news
|
||||
onState("connecting");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
onState("connected");
|
||||
onState("connecting");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks the server once when several things notice at the same moment", async () => {
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await Promise.all([reloadIfServerRebuilt(), reloadIfServerRebuilt(), reloadIfServerRebuilt()]);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the poll is what the guarantee rests on", () => {
|
||||
it("checks on its own while the tab is visible, with nobody touching it", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = healthReplies({ ok: true, version: "9.9.9" });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
|
||||
|
||||
startBuildWatch();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("leaves a hidden tab alone until it is looked at", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let visibility = "hidden";
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
|
||||
|
||||
startBuildWatch();
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
visibility = "visible";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/swFacts";
|
||||
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||
import { setCatalog } from "@/lib/i18n";
|
||||
import { catalog as de } from "@/locales/de";
|
||||
|
||||
/**
|
||||
* The briefing is the only thing standing between a notification action and a
|
||||
* button labeled in a language the reader does not use — the worker is plain
|
||||
* JavaScript outside the bundle and cannot reach a catalog.
|
||||
*
|
||||
* It is also the only place the archive mailbox is named, and getting that
|
||||
* wrong does not fail visibly: a message would be filed somewhere, just not
|
||||
* where Archive means.
|
||||
*/
|
||||
|
||||
function fakeCaches() {
|
||||
const store = new Map<string, string>();
|
||||
const cache = {
|
||||
put: vi.fn(async (key: string, res: Response) => void store.set(key, await res.text())),
|
||||
match: vi.fn(async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined)),
|
||||
delete: vi.fn(async () => true),
|
||||
};
|
||||
// Only the worker's own cache: a briefing put anywhere else is one the
|
||||
// worker will never read.
|
||||
const other = { put: vi.fn(), match: vi.fn(), delete: vi.fn() };
|
||||
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : other)) });
|
||||
return { store, cache };
|
||||
}
|
||||
|
||||
const written = (store: Map<string, string>) => JSON.parse(store.get(FACTS_KEY)!) as WorkerFacts;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setCatalog("en", { strings: {}, plurals: {} });
|
||||
});
|
||||
|
||||
describe("the worker's briefing", () => {
|
||||
it("names the account and the archive mailbox", async () => {
|
||||
const { store } = fakeCaches();
|
||||
await publishWorkerFacts("a1", "mb-archive");
|
||||
const facts = written(store);
|
||||
expect(facts.accountId).toBe("a1");
|
||||
expect(facts.archiveId).toBe("mb-archive");
|
||||
});
|
||||
|
||||
it("carries the worker's text in the language the tab is in", async () => {
|
||||
// The worker has no catalog. Everything it will say has to be said here
|
||||
// first, or a German reader gets English buttons on their lock screen.
|
||||
setCatalog("de", de);
|
||||
const { store } = fakeCaches();
|
||||
await publishWorkerFacts("a1", "mb-archive");
|
||||
const facts = written(store);
|
||||
expect(facts.strings.archive).toBe("Archivieren");
|
||||
expect(facts.strings.markRead).toBe("Als gelesen markieren");
|
||||
expect(facts.strings.newMail).toBe("Neue E-Mail");
|
||||
expect(facts.strings.noSubject).toBe("(kein Betreff)");
|
||||
expect(facts.strings.failed).not.toBe("");
|
||||
});
|
||||
|
||||
it("says so when there is no archive folder, rather than inventing one", async () => {
|
||||
// The worker draws no Archive button on a null. An account without an
|
||||
// archive is not a reason to file mail somewhere else.
|
||||
const { store } = fakeCaches();
|
||||
await publishWorkerFacts("a1", null);
|
||||
expect(written(store).archiveId).toBeNull();
|
||||
});
|
||||
|
||||
it("writes nothing before there is an account", async () => {
|
||||
const { cache } = fakeCaches();
|
||||
await publishWorkerFacts(null, null);
|
||||
expect(cache.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw where the browser has no cache storage", async () => {
|
||||
vi.stubGlobal("caches", undefined);
|
||||
await expect(publishWorkerFacts("a1", "mb-archive")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries every string the worker looks up", async () => {
|
||||
// The worker reads these by name and shows `undefined` for a missing one,
|
||||
// which is the kind of thing that only appears on somebody's lock screen.
|
||||
const { store } = fakeCaches();
|
||||
await publishWorkerFacts("a1", "mb-archive");
|
||||
const facts = written(store);
|
||||
for (const k of ["newMail", "newMessage", "noSubject", "archive", "markRead", "failed"] as const) {
|
||||
expect(facts.strings[k], `missing ${k}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
|
||||
|
||||
/**
|
||||
* A swipe names what it is about to do on a colored strip the reader sees for
|
||||
* about a third of a second before letting go. These check that the name is
|
||||
* true in the folder it is being read in — which is the whole reason the
|
||||
* descriptor exists rather than a fixed label per setting.
|
||||
*/
|
||||
|
||||
const inbox = { role: "inbox", unread: false, starred: false };
|
||||
|
||||
describe("describeSwipe", () => {
|
||||
it("offers nothing for a direction turned off", () => {
|
||||
expect(describeSwipe("none", inbox)).toBe(null);
|
||||
});
|
||||
|
||||
it("refuses to archive out of the archive", () => {
|
||||
expect(describeSwipe("archive", { ...inbox, role: "archive" })).toBe(null);
|
||||
expect(describeSwipe("archive", inbox)).toMatchObject({ label: "Archive", removes: true });
|
||||
});
|
||||
|
||||
it("says out loud that a delete from Deleted Items is permanent", () => {
|
||||
expect(describeSwipe("delete", inbox)?.label).toBe("Delete");
|
||||
expect(describeSwipe("delete", { ...inbox, role: "trash" })?.label).toBe("Delete forever");
|
||||
});
|
||||
|
||||
it("turns the spam action around inside the junk folder", () => {
|
||||
expect(describeSwipe("spam", inbox)).toMatchObject({ label: "Report spam", icon: "spam" });
|
||||
expect(describeSwipe("spam", { ...inbox, role: "junk" })).toMatchObject({ label: "Not spam", icon: "not-spam" });
|
||||
});
|
||||
|
||||
it("has no opinion on whether your own mail is spam", () => {
|
||||
expect(describeSwipe("spam", { ...inbox, role: "drafts" })).toBe(null);
|
||||
expect(describeSwipe("spam", { ...inbox, role: "sent" })).toBe(null);
|
||||
});
|
||||
|
||||
it("names the state a toggle is about to set, and carries it", () => {
|
||||
expect(describeSwipe("read", { ...inbox, unread: true })).toMatchObject({ label: "Mark as read", icon: "read", on: true });
|
||||
expect(describeSwipe("read", { ...inbox, unread: false })).toMatchObject({ label: "Mark as unread", icon: "unread", on: false });
|
||||
expect(describeSwipe("star", { ...inbox, starred: false })).toMatchObject({ label: "Add star", on: true });
|
||||
expect(describeSwipe("star", { ...inbox, starred: true })).toMatchObject({ label: "Remove star", on: false });
|
||||
});
|
||||
|
||||
it("brings a row home for the actions that open something instead", () => {
|
||||
// The row has to be back under the finger before the folder picker covers
|
||||
// the list, or it is still hanging half-open when the picker closes again.
|
||||
expect(describeSwipe("move", inbox)).toMatchObject({ label: "Move to…", removes: false });
|
||||
for (const action of ["archive", "delete", "spam"] as const) {
|
||||
expect(describeSwipe(action, inbox)?.removes).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("can describe everything the settings picker offers", () => {
|
||||
// A choice the picker offers and the list cannot describe is a direction
|
||||
// that silently does nothing — the one failure nobody would report.
|
||||
for (const { value } of SWIPE_CHOICES) {
|
||||
const d = describeSwipe(value as SwipeAction, inbox);
|
||||
if (value === "none") expect(d).toBe(null);
|
||||
else expect(d?.label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/input/touch";
|
||||
|
||||
describe("navSwipeThreshold", () => {
|
||||
it("asks for more travel than a row swipe does, at every width", () => {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { htmlToText, quoteText, replySubject, textToHtml } from "../text";
|
||||
|
||||
describe("text helpers", () => {
|
||||
it("linkifies and escapes", () => {
|
||||
const html = textToHtml("see <https://x.io/a?b=1> now");
|
||||
expect(html).toContain("<");
|
||||
expect(html).toContain('<a href="https://x.io/a?b=1"');
|
||||
});
|
||||
it("colors quote levels", () => {
|
||||
expect(textToHtml("> hi\n>> there")).toContain('class="q1"');
|
||||
expect(textToHtml("> hi\n>> there")).toContain('class="q2"');
|
||||
});
|
||||
it("converts html to text", () => {
|
||||
const t = htmlToText("<p>Hello <b>world</b></p><ul><li>one</li><li>two</li></ul><blockquote>q</blockquote><a href='https://a.b'>link</a>");
|
||||
expect(t).toContain("Hello world");
|
||||
expect(t).toContain("- one");
|
||||
expect(t).toContain("> q");
|
||||
expect(t).toContain("link <https://a.b>");
|
||||
});
|
||||
it("quotes and subjects", () => {
|
||||
expect(quoteText("a\n> b")).toBe("> a\n>> b");
|
||||
expect(replySubject("Re: Hi", "Re")).toBe("Re: Hi");
|
||||
expect(replySubject("Fwd: Hi", "Re")).toBe("Re: Hi");
|
||||
expect(replySubject("Hi", "Fwd")).toBe("Fwd: Hi");
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AXIS_SLOP, PULL_MAX, PULL_TRIGGER, lockAxis, pullDistance, swipeOffset, swipeThreshold } from "../touch";
|
||||
|
||||
/**
|
||||
* The arithmetic behind the touch gestures, checked without a touchscreen.
|
||||
*
|
||||
* These are the numbers that decide whether a finger meant to scroll the list
|
||||
* or to act on a message, and getting them wrong is not a crash — it is an app
|
||||
* that deletes mail when someone tried to scroll past it. Worth pinning down.
|
||||
*/
|
||||
|
||||
describe("lockAxis", () => {
|
||||
it("stays undecided until the finger has committed", () => {
|
||||
expect(lockAxis(0, 0)).toBe(null);
|
||||
expect(lockAxis(AXIS_SLOP - 1, AXIS_SLOP - 1)).toBe(null);
|
||||
});
|
||||
|
||||
it("reads a clearly sideways drag as a swipe", () => {
|
||||
expect(lockAxis(40, 4)).toBe("x");
|
||||
expect(lockAxis(-40, 4)).toBe("x");
|
||||
});
|
||||
|
||||
it("gives a diagonal to the scroller, not the swipe", () => {
|
||||
// 45 degrees is more sideways than not, and is still a scroll: someone
|
||||
// flicking down a list does not travel straight down the glass.
|
||||
expect(lockAxis(30, 30)).toBe("y");
|
||||
expect(lockAxis(30, 25)).toBe("y");
|
||||
});
|
||||
|
||||
it("counts distance on either axis toward committing", () => {
|
||||
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
|
||||
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeThreshold", () => {
|
||||
it("scales with the row but never off either end", () => {
|
||||
expect(swipeThreshold(300)).toBeCloseTo(84); // a phone: a share of the row
|
||||
expect(swipeThreshold(160)).toBe(56); // a narrow row: a fixed floor
|
||||
expect(swipeThreshold(2000)).toBe(96); // a tablet: not the whole reach
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeOffset", () => {
|
||||
const width = 360;
|
||||
const limit = swipeThreshold(width);
|
||||
|
||||
it("follows the finger exactly until the action would fire", () => {
|
||||
expect(swipeOffset(20, width)).toBe(20);
|
||||
expect(swipeOffset(-20, width)).toBe(-20);
|
||||
expect(swipeOffset(limit, width)).toBe(limit);
|
||||
});
|
||||
|
||||
it("resists past the threshold, in both directions", () => {
|
||||
const over = swipeOffset(limit + 100, width);
|
||||
expect(over).toBeGreaterThan(limit);
|
||||
expect(over).toBeLessThan(limit + 100);
|
||||
expect(swipeOffset(-(limit + 100), width)).toBeCloseTo(-over);
|
||||
});
|
||||
|
||||
it("never travels further than the row is wide", () => {
|
||||
// Past the row's own width there is nothing left to reveal, so a hard
|
||||
// flick stops there rather than accumulating travel with nowhere to show.
|
||||
expect(Math.abs(swipeOffset(2000, width))).toBe(width);
|
||||
expect(Math.abs(swipeOffset(-2000, width))).toBe(width);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullDistance", () => {
|
||||
it("ignores an upward drag", () => {
|
||||
expect(pullDistance(0)).toBe(0);
|
||||
expect(pullDistance(-50)).toBe(0);
|
||||
});
|
||||
|
||||
it("asks for a deliberate pull, not the overscroll at the top of a list", () => {
|
||||
expect(pullDistance(40)).toBeLessThan(PULL_TRIGGER);
|
||||
expect(pullDistance(60)).toBeLessThan(PULL_TRIGGER);
|
||||
expect(pullDistance(140)).toBeGreaterThanOrEqual(PULL_TRIGGER);
|
||||
});
|
||||
|
||||
it("stops coming down however hard it is pulled", () => {
|
||||
expect(pullDistance(400)).toBe(PULL_MAX);
|
||||
expect(pullDistance(4000)).toBe(PULL_MAX);
|
||||
});
|
||||
});
|
||||
@@ -1,306 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { client } from "@/jmap/client";
|
||||
import {
|
||||
applicationServerKey,
|
||||
decodeApplicationServerKey,
|
||||
encodeKey,
|
||||
findSubscription,
|
||||
needsRenewal,
|
||||
RENEW_WITHIN_MS,
|
||||
subscriptionPayload,
|
||||
pushEnabledHere,
|
||||
setPushEnabledHere,
|
||||
supportsEmailPush,
|
||||
unsubscribeThisDevice,
|
||||
webPushAvailable,
|
||||
type JmapPushSubscription,
|
||||
} from "@/lib/webpush";
|
||||
import { setDeviceTrusted } from "@/lib/storage";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* The key encoding is where this breaks silently. `subscribe()` fails with an
|
||||
* opaque error on a mis-decoded VAPID key, and Stalwart 0.16 had to be fixed to
|
||||
* accept the *unpadded* base64url the W3C Push API produces — so re-padding on
|
||||
* the way out would be sending a shape the server has not been tested against.
|
||||
*
|
||||
* The real key from the live 0.16.19 is used below rather than a made-up one:
|
||||
* its length is what exercises the padding arithmetic.
|
||||
*/
|
||||
const LIVE_KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY";
|
||||
|
||||
function session(caps: Record<string, unknown>): JmapSession {
|
||||
return { capabilities: caps, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
client.session = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("the VAPID key", () => {
|
||||
it("is read from the capability the server publishes", () => {
|
||||
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
||||
expect(applicationServerKey()).toBe(LIVE_KEY);
|
||||
});
|
||||
|
||||
it("is null when the server does not do Web Push, rather than an empty string", () => {
|
||||
client.session = session({ "urn:ietf:params:jmap:core": {} });
|
||||
expect(applicationServerKey()).toBeNull();
|
||||
});
|
||||
|
||||
it("decodes to the 65 bytes of an uncompressed P-256 point", () => {
|
||||
const buf = decodeApplicationServerKey(LIVE_KEY);
|
||||
expect(buf.byteLength).toBe(65);
|
||||
// 0x04 marks an uncompressed EC point; the Push API rejects anything else.
|
||||
expect(new Uint8Array(buf)[0]).toBe(0x04);
|
||||
});
|
||||
|
||||
it("handles base64url without padding, which is how it arrives", () => {
|
||||
expect(LIVE_KEY).not.toContain("=");
|
||||
expect(LIVE_KEY).toMatch(/[-_]/);
|
||||
expect(() => decodeApplicationServerKey(LIVE_KEY)).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns an ArrayBuffer, which is what subscribe() accepts", () => {
|
||||
expect(decodeApplicationServerKey(LIVE_KEY)).toBeInstanceOf(ArrayBuffer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encoding keys for the server", () => {
|
||||
it("produces unpadded base64url, the form Stalwart was fixed to accept", () => {
|
||||
// 5 bytes: a length that would be padded with "===" in standard base64.
|
||||
const buf = new Uint8Array([1, 2, 3, 4, 5]).buffer;
|
||||
const out = encodeKey(buf);
|
||||
expect(out).not.toContain("=");
|
||||
expect(out).not.toContain("+");
|
||||
expect(out).not.toContain("/");
|
||||
});
|
||||
|
||||
it("round-trips through the decoder", () => {
|
||||
const bytes = new Uint8Array([0, 255, 128, 64, 32, 16]);
|
||||
expect(new Uint8Array(decodeApplicationServerKey(encodeKey(bytes.buffer)))).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("gives an empty string rather than throwing on a missing key", () => {
|
||||
expect(encodeKey(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what gets registered", () => {
|
||||
const fakeSub = {
|
||||
endpoint: "https://push.example/abc",
|
||||
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
|
||||
getKey: () => null,
|
||||
} as unknown as PushSubscription;
|
||||
|
||||
it("asks for the message itself when the server supports emailpush", () => {
|
||||
client.session = session({
|
||||
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
|
||||
"urn:ietf:params:jmap:emailpush": {},
|
||||
});
|
||||
const body = subscriptionPayload(fakeSub, "a1") as Record<string, any>;
|
||||
expect(body.url).toBe("https://push.example/abc");
|
||||
expect(body.keys).toEqual({ p256dh: "cGRoLWtleQ", auth: "YXV0aA" });
|
||||
expect(body.emailPush.a1.properties).toContain("subject");
|
||||
expect(body.emailPush.a1.properties).toContain("from");
|
||||
// Order is priority: the server drops from the end when the payload is
|
||||
// too large, so the sender must outrank the preview.
|
||||
const props: string[] = body.emailPush.a1.properties;
|
||||
expect(props.indexOf("from")).toBeLessThan(props.indexOf("preview"));
|
||||
});
|
||||
|
||||
it("omits emailPush entirely when the server does not support it", () => {
|
||||
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
||||
expect(supportsEmailPush()).toBe(false);
|
||||
expect(subscriptionPayload(fakeSub, "a1")).not.toHaveProperty("emailPush");
|
||||
});
|
||||
|
||||
it("omits emailPush when there is no account to scope it to", () => {
|
||||
client.session = session({
|
||||
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
|
||||
"urn:ietf:params:jmap:emailpush": {},
|
||||
});
|
||||
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush");
|
||||
});
|
||||
|
||||
it("subscribes to Email changes only, since EventSource covers an open tab", () => {
|
||||
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
|
||||
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["Email"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("availability", () => {
|
||||
it("is false without a push key, however capable the browser", () => {
|
||||
client.session = session({ "urn:ietf:params:jmap:core": {} });
|
||||
expect(webPushAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the emailPush filter", () => {
|
||||
/**
|
||||
* This is the bug that reached production: `inMailbox: null` read as "the
|
||||
* inbox" and meant nothing to the server, which answered "Invalid filter"
|
||||
* and refused the subscription outright. The original tests checked the
|
||||
* property ordering and never looked at the filter at all.
|
||||
*/
|
||||
const fakeSub = {
|
||||
endpoint: "https://push.example/abc",
|
||||
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
|
||||
getKey: () => null,
|
||||
} as unknown as PushSubscription;
|
||||
|
||||
const withEmailPush = () => {
|
||||
client.session = session({
|
||||
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
|
||||
"urn:ietf:params:jmap:emailpush": {},
|
||||
});
|
||||
};
|
||||
|
||||
it("never sends a condition with a null or undefined value", () => {
|
||||
withEmailPush();
|
||||
for (const inbox of ["mb1", null]) {
|
||||
const body = subscriptionPayload(fakeSub, "a1", inbox) as Record<string, any>;
|
||||
const filter = body.emailPush.a1.filter as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(filter)) {
|
||||
expect(v, `${k} was ${String(v)} with inbox=${String(inbox)}`).not.toBeNull();
|
||||
expect(v, k).not.toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the real mailbox id when it knows one", () => {
|
||||
withEmailPush();
|
||||
const body = subscriptionPayload(fakeSub, "a1", "mbInbox") as Record<string, any>;
|
||||
expect(body.emailPush.a1.filter.inMailbox).toBe("mbInbox");
|
||||
});
|
||||
|
||||
it("leaves inMailbox out entirely when it does not, rather than sending null", () => {
|
||||
withEmailPush();
|
||||
const filter = (subscriptionPayload(fakeSub, "a1", null) as Record<string, any>).emailPush.a1.filter;
|
||||
expect(filter).not.toHaveProperty("inMailbox");
|
||||
// Still narrowed to unread: notifying more widely beats not notifying.
|
||||
expect(filter.notKeyword).toBe("$seen");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Keeping a subscription alive.
|
||||
*
|
||||
* The failure this guards against leaves no trace anywhere: the switch says
|
||||
* background notifications are on, the browser still holds a subscription, and
|
||||
* the server quietly stopped delivering days ago because the registration
|
||||
* expired and nothing renewed it. Nobody reports that as a bug — they report
|
||||
* that push "doesn't really work".
|
||||
*/
|
||||
const sub = (deviceClientId: string, expires: string | null): JmapPushSubscription =>
|
||||
({ id: `i-${deviceClientId}`, deviceClientId, url: "https://push.example/x", expires });
|
||||
|
||||
const MINE = "ihasmail-this-browser";
|
||||
const NOW = Date.parse("2026-09-01T12:00:00Z");
|
||||
const inDays = (n: number) => new Date(NOW + n * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
describe("finding this browser's subscription", () => {
|
||||
it("matches on the device id rather than taking the first one", () => {
|
||||
const subs = [sub("ihasmail-desktop", null), sub(MINE, null), sub("ihasmail-tablet", null)];
|
||||
expect(findSubscription(subs, MINE)?.deviceClientId).toBe(MINE);
|
||||
});
|
||||
|
||||
it("finds nothing when only other devices are registered", () => {
|
||||
// The bug this replaces: any subscription at all counted as this one, so a
|
||||
// phone that had never registered read as already on and stayed silent.
|
||||
expect(findSubscription([sub("ihasmail-desktop", null)], MINE)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsRenewal", () => {
|
||||
it("renews when this browser is not registered at all", () => {
|
||||
expect(needsRenewal([], MINE, NOW)).toBe(true);
|
||||
expect(needsRenewal([sub("ihasmail-desktop", inDays(6))], MINE, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a subscription alone while it has time on it", () => {
|
||||
expect(needsRenewal([sub(MINE, inDays(6))], MINE, NOW)).toBe(false);
|
||||
expect(needsRenewal([sub(MINE, inDays(3))], MINE, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("renews inside the window, so a weekend does not lose it", () => {
|
||||
expect(needsRenewal([sub(MINE, inDays(2))], MINE, NOW)).toBe(true);
|
||||
expect(needsRenewal([sub(MINE, inDays(1))], MINE, NOW)).toBe(true);
|
||||
expect(RENEW_WITHIN_MS).toBeLessThan(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("renews one that has already lapsed", () => {
|
||||
expect(needsRenewal([sub(MINE, inDays(-1))], MINE, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a subscription with no expiry alone", () => {
|
||||
// A server that never expires one has nothing to renew, and rewriting the
|
||||
// registration on every cold start would be a JMAP call for nothing.
|
||||
expect(needsRenewal([sub(MINE, null)], MINE, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("renews rather than trusts an expiry it cannot read", () => {
|
||||
expect(needsRenewal([sub(MINE, "whenever")], MINE, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether push is on *in this browser* is the flag the renewal on app start
|
||||
* keys off, so the two endings that can clear it have to be told apart.
|
||||
*
|
||||
* Signing out clears it, alongside destroying the subscription itself: a
|
||||
* browser left notifying for a mailbox nobody is signed into is somebody
|
||||
* else's mail on a shared machine. A session merely expiring must not, because
|
||||
* that path -- which is what a deploy does to everyone at once -- leaves the
|
||||
* subscription registered and has no session left to remove it with. That half
|
||||
* is enforced by `KEEP_ON_SIGN_OUT` and tested in storage.test.ts.
|
||||
*/
|
||||
describe("remembering that push is on here", () => {
|
||||
let store: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
setDeviceTrusted(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDeviceTrusted(false);
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
|
||||
it("round-trips, and is off until something turns it on", () => {
|
||||
expect(pushEnabledHere()).toBe(false);
|
||||
setPushEnabledHere(true);
|
||||
expect(pushEnabledHere()).toBe(true);
|
||||
setPushEnabledHere(false);
|
||||
expect(pushEnabledHere()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays off on a device nobody said was theirs", () => {
|
||||
// Push is refused there anyway; reading the flag as set would start the
|
||||
// renewal trying on every load for a subscription that cannot exist.
|
||||
setPushEnabledHere(true);
|
||||
setDeviceTrusted(false);
|
||||
expect(pushEnabledHere()).toBe(false);
|
||||
});
|
||||
|
||||
it("is cleared by signing out, even when the server end cannot be reached", () => {
|
||||
setPushEnabledHere(true);
|
||||
vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
|
||||
return unsubscribeThisDevice().then(() => {
|
||||
// The subscription may well survive at the server; this browser must
|
||||
// still stop believing it has push, or renewal would resurrect it.
|
||||
expect(pushEnabledHere()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user