Merge branch 'main' into feat/forward-as-eml
# Conflicts: # web/src/store/compose.ts # web/src/views/mail/MessageView.tsx
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spamReport } from "@/lib/spamScore";
|
||||
|
||||
const sa = (v: string) => spamReport({ "header:X-Spam-Status:asText": v });
|
||||
const rs = (v: string) => spamReport({ "header:X-Spamd-Result:asText": v });
|
||||
|
||||
describe("spamReport, SpamAssassin-shaped headers", () => {
|
||||
it("reads the verdict, score, threshold and tests", () => {
|
||||
const r = sa("Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001, URIBL=2.2] autolearn=no");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.verdict).toBe("spam");
|
||||
expect(r!.score).toBe(6.7);
|
||||
expect(r!.threshold).toBe(5);
|
||||
expect(r!.source).toBe("spamassassin");
|
||||
// Biggest mover first, so the reason it was scored reads off the top.
|
||||
expect(r!.rules.map((x) => x.name)).toEqual(["BAYES_99", "URIBL", "HTML_MESSAGE"]);
|
||||
});
|
||||
|
||||
it("reads a negative score and a clean verdict", () => {
|
||||
const r = sa("No, score=-2.6 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7]");
|
||||
expect(r!.verdict).toBe("clean");
|
||||
expect(r!.score).toBe(-2.6);
|
||||
expect(r!.rules[0]).toEqual({ name: "BAYES_00", score: -1.9 });
|
||||
});
|
||||
|
||||
it("survives a folded header, which is how they arrive", () => {
|
||||
const r = sa("Yes, score=6.7\n\trequired=5.0 tests=[BAYES_99=3.5,\n\tURIBL=2.2]");
|
||||
expect(r!.score).toBe(6.7);
|
||||
expect(r!.rules).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a verdict that states no score, and a score that states no verdict", () => {
|
||||
expect(sa("Yes")!.verdict).toBe("spam");
|
||||
expect(sa("Yes")!.score).toBeNull();
|
||||
const scoreOnly = sa("score=1.2 required=5.0");
|
||||
expect(scoreOnly!.verdict).toBeNull();
|
||||
expect(scoreOnly!.score).toBe(1.2);
|
||||
});
|
||||
|
||||
it("says nothing when there is nothing it understands", () => {
|
||||
expect(sa("")).toBeNull();
|
||||
expect(sa("something else entirely")).toBeNull();
|
||||
expect(spamReport({})).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a malformed test rather than scoring it as zero", () => {
|
||||
const r = sa("Yes, score=3.0 tests=[GOOD=1.0, BROKEN=, =2.0, ALSO_GOOD=2.0]");
|
||||
expect(r!.rules.map((x) => x.name)).toEqual(["ALSO_GOOD", "GOOD"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamReport, Rspamd", () => {
|
||||
it("reads the action, score, threshold and rules with their notes", () => {
|
||||
const r = rs("default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[example.com]; SUBJ_CAPS(2.00)[]");
|
||||
expect(r!.verdict).toBe("clean");
|
||||
expect(r!.score).toBe(1.2);
|
||||
expect(r!.threshold).toBe(15);
|
||||
expect(r!.source).toBe("rspamd");
|
||||
expect(r!.rules[0]).toEqual({ name: "SUBJ_CAPS", score: 2 });
|
||||
expect(r!.rules.find((x) => x.name === "DKIM_ALLOW")?.detail).toBe("example.com");
|
||||
// An empty bracket is not a note.
|
||||
expect(r!.rules[0]!.detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats the acting verdicts as spam and False as clean", () => {
|
||||
expect(rs("default: True [20.00 / 15.00];")!.verdict).toBe("spam");
|
||||
expect(rs("default: reject [20.00 / 15.00];")!.verdict).toBe("spam");
|
||||
expect(rs("default: add_header [16.00 / 15.00];")!.verdict).toBe("spam");
|
||||
expect(rs("default: False [1.00 / 15.00];")!.verdict).toBe("clean");
|
||||
});
|
||||
|
||||
it("declines to call greylisting a verdict about the message", () => {
|
||||
const r = rs("default: greylist [8.00 / 15.00];");
|
||||
expect(r!.verdict).toBeNull();
|
||||
expect(r!.score).toBe(8);
|
||||
});
|
||||
|
||||
it("says nothing for a header it cannot read", () => {
|
||||
expect(rs("")).toBeNull();
|
||||
expect(rs("default: False")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamReport, precedence and fallback", () => {
|
||||
it("prefers the SpamAssassin set, which is what Stalwart's own filter writes", () => {
|
||||
const r = spamReport({
|
||||
"header:X-Spam-Status:asText": "Yes, score=6.7 required=5.0",
|
||||
"header:X-Spamd-Result:asText": "default: False [1.20 / 15.00];",
|
||||
});
|
||||
expect(r!.source).toBe("spamassassin");
|
||||
expect(r!.verdict).toBe("spam");
|
||||
});
|
||||
|
||||
it("falls back to a bare score, with no threshold to read it against", () => {
|
||||
const r = spamReport({ "header:X-Spam-Score:asText": "+4.1" });
|
||||
expect(r!.score).toBe(4.1);
|
||||
expect(r!.threshold).toBeNull();
|
||||
expect(r!.verdict).toBeNull();
|
||||
expect(r!.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not invent a verdict from score against threshold", () => {
|
||||
// Above the threshold, but the filter did not say "Yes" -- so neither do we.
|
||||
const r = sa("score=9.9 required=5.0 tests=[X=9.9]");
|
||||
expect(r!.verdict).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
|
||||
|
||||
describe("navSwipeThreshold", () => {
|
||||
it("asks for more travel than a row swipe does, at every width", () => {
|
||||
// Not because the consequence is bigger -- stepping back undoes it -- but
|
||||
// because this gesture reveals nothing on the way and offers no Undo
|
||||
// after, so the distance is the only chance to not mean it.
|
||||
for (const width of [320, 360, 414, 768, 1024]) {
|
||||
expect(navSwipeThreshold(width)).toBeGreaterThan(swipeThreshold(width));
|
||||
}
|
||||
});
|
||||
|
||||
it("is a share of the width, bounded at both ends", () => {
|
||||
expect(navSwipeThreshold(360)).toBe(108);
|
||||
expect(navSwipeThreshold(200)).toBe(80); // floor
|
||||
expect(navSwipeThreshold(1000)).toBe(180); // ceiling
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeNavDirection", () => {
|
||||
const W = 400; // threshold is 120 at this width
|
||||
|
||||
it("goes forward when the finger drags left, the way pages turn", () => {
|
||||
expect(swipeNavDirection(-200, W)).toBe(1);
|
||||
});
|
||||
|
||||
it("goes back when the finger drags right", () => {
|
||||
expect(swipeNavDirection(200, W)).toBe(-1);
|
||||
});
|
||||
|
||||
it("does nothing short of the threshold, in either direction", () => {
|
||||
expect(swipeNavDirection(-60, W)).toBe(0);
|
||||
expect(swipeNavDirection(60, W)).toBe(0);
|
||||
expect(swipeNavDirection(0, W)).toBe(0);
|
||||
});
|
||||
|
||||
it("fires exactly at the threshold and not a pixel before", () => {
|
||||
const at = navSwipeThreshold(W);
|
||||
expect(swipeNavDirection(-at, W)).toBe(1);
|
||||
expect(swipeNavDirection(-(at - 1), W)).toBe(0);
|
||||
expect(swipeNavDirection(at, W)).toBe(-1);
|
||||
expect(swipeNavDirection(at - 1, W)).toBe(0);
|
||||
});
|
||||
|
||||
it("scales with the width, so a tablet asks for more than a phone", () => {
|
||||
// The same 120px drag commits on a narrow screen and does not on a wide one.
|
||||
expect(swipeNavDirection(-120, 360)).toBe(1);
|
||||
expect(swipeNavDirection(-120, 1024)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the axis lock this shares with the row swipe", () => {
|
||||
it("keeps a mostly-vertical drag as a scroll, which is what the day grid needs", () => {
|
||||
// The day view scrolls through the hours; a scroll misread as a swipe
|
||||
// throws the reader into another day.
|
||||
expect(lockAxis(20, 30)).toBe("y");
|
||||
expect(lockAxis(30, 25)).toBe("y");
|
||||
});
|
||||
|
||||
it("commits to sideways only when it is clearly sideways", () => {
|
||||
expect(lockAxis(40, 10)).toBe("x");
|
||||
});
|
||||
|
||||
it("is undecided until the drag has moved at all", () => {
|
||||
expect(lockAxis(2, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fillPlaceholders, PLACEHOLDER_NAMES, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||
|
||||
const AT = new Date("2026-03-04T15:07:00Z");
|
||||
|
||||
function ctx(over: Partial<PlaceholderContext> = {}): PlaceholderContext {
|
||||
return {
|
||||
to: [{ name: "Ada Lovelace", email: "[email protected]" }],
|
||||
from: { name: "Grace Hopper", email: "[email protected]" },
|
||||
subject: "Quarterly report",
|
||||
now: AT,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fillPlaceholders", () => {
|
||||
it("fills the names it knows", () => {
|
||||
expect(fillPlaceholders("Hi {{recipientFirstName}},", ctx(), { html: true })).toBe("Hi Ada,");
|
||||
expect(fillPlaceholders("{{recipientName}} <{{recipientEmail}}>", ctx(), { html: false })).toBe("Ada Lovelace <[email protected]>");
|
||||
expect(fillPlaceholders("-- {{myName}}", ctx(), { html: true })).toBe("-- Grace Hopper");
|
||||
expect(fillPlaceholders("Re: {{subject}}", ctx(), { html: false })).toBe("Re: Quarterly report");
|
||||
});
|
||||
|
||||
it("tolerates spaces inside the braces but not a different case", () => {
|
||||
expect(fillPlaceholders("{{ myEmail }}", ctx(), { html: false })).toBe("[email protected]");
|
||||
expect(fillPlaceholders("{{MyEmail}}", ctx(), { html: false })).toBe("{{MyEmail}}");
|
||||
});
|
||||
|
||||
it("leaves a placeholder it cannot answer exactly as written", () => {
|
||||
// The case the design is about: a template inserted before the message is
|
||||
// addressed. "Hi ," would be wrong; "Hi {{recipientFirstName}}," is unfinished.
|
||||
const unaddressed = ctx({ to: [] });
|
||||
expect(fillPlaceholders("Hi {{recipientFirstName}},", unaddressed, { html: true })).toBe("Hi {{recipientFirstName}},");
|
||||
expect(fillPlaceholders("{{recipientEmail}}", unaddressed, { html: false })).toBe("{{recipientEmail}}");
|
||||
expect(fillPlaceholders("{{myName}}", ctx({ from: null }), { html: false })).toBe("{{myName}}");
|
||||
});
|
||||
|
||||
it("leaves a name it does not know alone rather than eating it", () => {
|
||||
expect(fillPlaceholders("{{nonsense}} {{}} {{ }}", ctx(), { html: true })).toBe("{{nonsense}} {{}} {{ }}");
|
||||
});
|
||||
|
||||
it("falls back to the local part when a recipient has no name", () => {
|
||||
const c = ctx({ to: [{ name: null, email: "[email protected]" }] });
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toBe("ada.lovelace");
|
||||
expect(fillPlaceholders("{{recipientFirstName}}", c, { html: false })).toBe("ada.lovelace");
|
||||
});
|
||||
|
||||
it("escapes a substituted value on the way into HTML, and not into a subject", () => {
|
||||
const c = ctx({ to: [{ name: 'Ada <script>alert("x")</script>', email: "[email protected]" }] });
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("<script>");
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).toContain("<script>");
|
||||
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toContain("<script>");
|
||||
});
|
||||
|
||||
it("repeats a placeholder as many times as it appears", () => {
|
||||
expect(fillPlaceholders("{{recipientFirstName}} {{recipientFirstName}}", ctx(), { html: true })).toBe("Ada Ada");
|
||||
});
|
||||
|
||||
it("answers date and time from the injected clock", () => {
|
||||
const date = fillPlaceholders("{{date}}", ctx(), { html: false });
|
||||
const time = fillPlaceholders("{{time}}", ctx(), { html: false });
|
||||
expect(date).not.toBe("{{date}}");
|
||||
expect(date).toMatch(/2026/);
|
||||
expect(time).not.toBe("{{time}}");
|
||||
expect(time).toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("names every resolver in the list Settings shows", () => {
|
||||
expect(PLACEHOLDER_NAMES).toEqual([
|
||||
"recipientName",
|
||||
"recipientFirstName",
|
||||
"recipientEmail",
|
||||
"myName",
|
||||
"myEmail",
|
||||
"subject",
|
||||
"date",
|
||||
"time",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user