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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Where a message goes when it is archived by date.
|
||||
*
|
||||
* The folders are **numeric and zero-padded** -- `Archive/2026`,
|
||||
* `Archive/2026/09` -- and deliberately not month names. Two reasons, both
|
||||
* about the fact that these are real server-side mailboxes rather than
|
||||
* anything of ihasmail's:
|
||||
*
|
||||
* - Every other client sees them. A folder created as "September" by someone
|
||||
* reading in English stays "September" for the same account read in
|
||||
* Japanese, because the name is stored, not translated. A number reads the
|
||||
* same in every language ihasmail ships.
|
||||
* - They sort. `09` sits between `08` and `10` in any folder list; "September"
|
||||
* sits between "October" and nothing useful.
|
||||
*
|
||||
* The date is read in the reader's own timezone rather than UTC, because it has
|
||||
* to agree with the date shown against the message in the list. A message that
|
||||
* arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
|
||||
* filing it under `09` while the list says August would be the app disagreeing
|
||||
* with itself.
|
||||
*/
|
||||
|
||||
export type ArchiveGranularity = "year" | "month";
|
||||
|
||||
/**
|
||||
* Path segments below the Archive folder. Empty means "no dated subfolder" --
|
||||
* a message whose date cannot be read belongs in Archive itself rather than in
|
||||
* a folder named after a guess.
|
||||
*/
|
||||
export function archiveSegments(when: string | null | undefined, granularity: ArchiveGranularity): string[] {
|
||||
if (!when) return [];
|
||||
const d = new Date(when);
|
||||
if (Number.isNaN(d.getTime())) return [];
|
||||
const year = String(d.getFullYear());
|
||||
if (granularity === "year") return [year];
|
||||
return [year, String(d.getMonth() + 1).padStart(2, "0")];
|
||||
}
|
||||
|
||||
/** The segments as one string, for grouping and for naming the destination. */
|
||||
export function archivePath(segments: string[]): string {
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
export interface ArchiveGroup {
|
||||
segments: string[];
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a selection by where each message is going.
|
||||
*
|
||||
* Archiving by month across a selection spanning two months is two
|
||||
* destinations, not one, so this is the shape the caller needs -- and the
|
||||
* reason the action cannot simply resolve one folder up front. Groups come
|
||||
* back in the order their first message appeared, so the toast that follows
|
||||
* names them in the order the reader was looking at.
|
||||
*/
|
||||
export function groupByArchivePath(
|
||||
entries: Array<{ id: string; receivedAt?: string | null }>,
|
||||
granularity: ArchiveGranularity,
|
||||
): ArchiveGroup[] {
|
||||
const groups = new Map<string, ArchiveGroup>();
|
||||
for (const e of entries) {
|
||||
const segments = archiveSegments(e.receivedAt, granularity);
|
||||
const key = archivePath(segments);
|
||||
const existing = groups.get(key);
|
||||
if (existing) existing.ids.push(e.id);
|
||||
else groups.set(key, { segments, ids: [e.id] });
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* What the spam filter in front of the mailbox already said, read back off the
|
||||
* message.
|
||||
*
|
||||
* Nothing here scores anything. The server has already done that at delivery
|
||||
* and written its working into headers; this only reads them, which is why it
|
||||
* costs nothing and cannot disagree with the filter that actually made the
|
||||
* decision.
|
||||
*
|
||||
* Two formats cover what sits in front of a Stalwart mailbox in practice: the
|
||||
* SpamAssassin-shaped `X-Spam-*` set, which Stalwart's own filter writes, and
|
||||
* Rspamd's `X-Spamd-Result`. Anything else is left unread rather than guessed
|
||||
* at -- a misparsed score shown confidently is worse than no panel.
|
||||
*
|
||||
* The one editorial decision: **a score is not shown without its threshold
|
||||
* where the header states one.** 6.7 is damning against a threshold of 5 and
|
||||
* unremarkable against 15, so the number alone is not something a reader can
|
||||
* act on. Where no threshold is stated, that is said rather than assumed.
|
||||
*/
|
||||
|
||||
export interface SpamRule {
|
||||
/** The rule's own name, as the filter wrote it. */
|
||||
name: string;
|
||||
/** What it contributed. Negative moves the message towards clean. */
|
||||
score: number;
|
||||
/** Rspamd's bracketed note, where there is one. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface SpamReport {
|
||||
/**
|
||||
* What the filter concluded, and only where it said so outright. Left `null`
|
||||
* rather than derived from score against threshold: the filter applies
|
||||
* policy we cannot see, and inventing a verdict it did not state would be
|
||||
* putting words in its mouth.
|
||||
*/
|
||||
verdict: "spam" | "clean" | null;
|
||||
score: number | null;
|
||||
threshold: number | null;
|
||||
/** Biggest movers first; ties keep the order the filter listed them in. */
|
||||
rules: SpamRule[];
|
||||
source: "spamassassin" | "rspamd";
|
||||
}
|
||||
|
||||
/** Headers requested for a full message; kept beside the parser that reads them. */
|
||||
export const SPAM_HEADER_PROPS = [
|
||||
"header:X-Spam-Status:asText",
|
||||
"header:X-Spam-Score:asText",
|
||||
"header:X-Spamd-Result:asText",
|
||||
] as const;
|
||||
|
||||
/** Headers arrive folded, so tabs and newlines are whitespace like any other. */
|
||||
function flatten(v: string | null | undefined): string {
|
||||
return (v ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function num(raw: string | undefined): number | null {
|
||||
if (raw === undefined) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Biggest absolute contribution first: what moved the message, in order. */
|
||||
function byWeight(rules: SpamRule[]): SpamRule[] {
|
||||
return [...rules].sort((a, b) => Math.abs(b.score) - Math.abs(a.score));
|
||||
}
|
||||
|
||||
/**
|
||||
* `Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001]`
|
||||
* The verdict word is the only part guaranteed to be there.
|
||||
*/
|
||||
function parseSpamAssassin(raw: string): SpamReport | null {
|
||||
const s = flatten(raw);
|
||||
if (!s) return null;
|
||||
const verdictWord = /^(yes|no)\b/i.exec(s);
|
||||
const score = num(/\bscore=(-?[\d.]+)/i.exec(s)?.[1]);
|
||||
const threshold = num(/\b(?:required|require)=(-?[\d.]+)/i.exec(s)?.[1]);
|
||||
// Nothing usable: not a header we understand, so say so by returning null
|
||||
// rather than rendering an empty panel.
|
||||
if (!verdictWord && score === null) return null;
|
||||
const rules: SpamRule[] = [];
|
||||
const tests = /\btests=\[([^\]]*)\]/i.exec(s)?.[1];
|
||||
if (tests) {
|
||||
for (const part of tests.split(",")) {
|
||||
const m = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(-?[\d.]+)\s*$/.exec(part);
|
||||
const value = num(m?.[2]);
|
||||
if (m?.[1] && value !== null) rules.push({ name: m[1], score: value });
|
||||
}
|
||||
}
|
||||
return {
|
||||
verdict: verdictWord ? (verdictWord[1]!.toLowerCase() === "yes" ? "spam" : "clean") : null,
|
||||
score,
|
||||
threshold,
|
||||
rules: byWeight(rules),
|
||||
source: "spamassassin",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[]`
|
||||
* The action word before the brackets is the verdict; `False` is not spam.
|
||||
*/
|
||||
function parseRspamd(raw: string): SpamReport | null {
|
||||
const s = flatten(raw);
|
||||
if (!s) return null;
|
||||
const head = /^[^:]*:\s*(\S+)\s*\[\s*(-?[\d.]+)\s*\/\s*(-?[\d.]+)\s*\]/.exec(s);
|
||||
if (!head) return null;
|
||||
const action = head[1]!.toLowerCase();
|
||||
const rules: SpamRule[] = [];
|
||||
// Each rule after the head, `NAME(score)` with an optional bracketed note.
|
||||
for (const m of s.matchAll(/([A-Z][A-Z0-9_]*)\(\s*(-?[\d.]+)\s*\)(?:\[([^\]]*)\])?/g)) {
|
||||
const value = num(m[2]);
|
||||
if (value === null) continue;
|
||||
const detail = m[3]?.trim();
|
||||
rules.push(detail ? { name: m[1]!, score: value, detail } : { name: m[1]!, score: value });
|
||||
}
|
||||
return {
|
||||
// "False" is Rspamd saying the message is not spam; "True", and the named
|
||||
// actions that reject or bin it, are it saying the opposite. Anything else
|
||||
// -- greylisting, for one -- is not a verdict about the message.
|
||||
verdict: action === "false" ? "clean" : action === "true" || action === "reject" || action === "add_header" || action === "rewrite_subject" ? "spam" : null,
|
||||
score: num(head[2]),
|
||||
threshold: num(head[3]),
|
||||
rules: byWeight(rules),
|
||||
source: "rspamd",
|
||||
};
|
||||
}
|
||||
|
||||
type HeaderBag = Partial<Record<(typeof SPAM_HEADER_PROPS)[number], string | null>>;
|
||||
|
||||
/**
|
||||
* Read whichever of the two the message carries, preferring the SpamAssassin
|
||||
* set because it is what Stalwart's own filter writes; a message that has been
|
||||
* through both keeps the nearer verdict.
|
||||
*/
|
||||
export function spamReport(e: HeaderBag): SpamReport | null {
|
||||
const sa = parseSpamAssassin(e["header:X-Spam-Status:asText"] ?? "");
|
||||
if (sa) return sa;
|
||||
const rspamd = parseRspamd(e["header:X-Spamd-Result:asText"] ?? "");
|
||||
if (rspamd) return rspamd;
|
||||
// Last resort: a bare score with nothing to read it against. Worth showing,
|
||||
// because the alternative is hiding the only thing the filter said.
|
||||
const bare = num(flatten(e["header:X-Spam-Score:asText"]).replace(/^\+/, "") || undefined);
|
||||
if (bare === null) return null;
|
||||
return { verdict: null, score: bare, threshold: null, rules: [], source: "spamassassin" };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Placeholders in templates, filled at the moment one is inserted.
|
||||
*
|
||||
* Two rules decide the whole design:
|
||||
*
|
||||
* - **An unresolved placeholder is left exactly as written.** A template
|
||||
* inserted before the message is addressed cannot know who it is going to,
|
||||
* and substituting an empty string there produces "Hi ," -- a greeting that
|
||||
* is wrong rather than unfinished. Leaving `{{recipientName}}` in the body
|
||||
* says which word is still missing, and it can be typed over. It is also
|
||||
* what makes inserting a template early a valid thing to do rather than a
|
||||
* mistake to undo.
|
||||
* - **A name that is not a placeholder is left alone too.** Templates are
|
||||
* written by hand and `{{` is not reserved anywhere else, but a body that
|
||||
* silently ate an unrecognised token would be worse than one that shows it.
|
||||
*
|
||||
* Dates and times go through `datetime.ts` rather than `toLocaleDateString`,
|
||||
* so a template follows the same date order and clock the rest of the app was
|
||||
* told to use.
|
||||
*/
|
||||
import { escapeHtml } from "./text";
|
||||
import { formatDate, formatClock } from "./datetime";
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
|
||||
export interface PlaceholderContext {
|
||||
/** Where the message is addressed, in order; the first is what the singular names refer to. */
|
||||
to: EmailAddress[];
|
||||
/** The identity the draft is sending as. */
|
||||
from: { name?: string | null; email?: string | null } | null;
|
||||
subject: string;
|
||||
/** Injectable so tests do not depend on the clock. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* What each name resolves to, in the order they are shown in Settings.
|
||||
* `null` from a resolver means "cannot be answered yet", which is the case
|
||||
* the rule above is about -- distinct from an empty string, which is an answer.
|
||||
*/
|
||||
const RESOLVERS: Record<string, (c: PlaceholderContext) => string | null> = {
|
||||
recipientName: (c) => personalName(c.to[0]),
|
||||
recipientFirstName: (c) => {
|
||||
const n = personalName(c.to[0]);
|
||||
return n ? (n.split(/\s+/)[0] ?? null) : null;
|
||||
},
|
||||
recipientEmail: (c) => c.to[0]?.email || null,
|
||||
myName: (c) => c.from?.name?.trim() || null,
|
||||
myEmail: (c) => c.from?.email || null,
|
||||
subject: (c) => c.subject || null,
|
||||
date: (c) => formatDate(c.now ?? new Date()),
|
||||
time: (c) => formatClock(c.now ?? new Date()),
|
||||
};
|
||||
|
||||
/** The names, for the list shown under the template editor. */
|
||||
export const PLACEHOLDER_NAMES = Object.keys(RESOLVERS);
|
||||
|
||||
/**
|
||||
* A recipient's human name: what they are called if we know it, otherwise the
|
||||
* local part, which for `firstname.lastname@` is still better than the whole
|
||||
* address in the middle of a sentence. Never the domain.
|
||||
*/
|
||||
function personalName(a: EmailAddress | undefined): string | null {
|
||||
if (!a) return null;
|
||||
const name = a.name?.trim();
|
||||
if (name) return name;
|
||||
const local = (a.email ?? "").split("@")[0] ?? "";
|
||||
return local || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{{ name }}` tolerates the spaces; the name itself is matched exactly,
|
||||
* because `{{Date}}` meaning `{{date}}` would make the list in Settings a
|
||||
* suggestion rather than the set.
|
||||
*/
|
||||
const TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\}/g;
|
||||
|
||||
/**
|
||||
* Fill `input`, escaping substituted values when the destination is HTML.
|
||||
* Escaping happens here rather than at the call site because the values come
|
||||
* from contact cards and typed addresses -- a display name is not trusted
|
||||
* markup, and the body it lands in is inserted as HTML.
|
||||
*/
|
||||
export function fillPlaceholders(input: string, ctx: PlaceholderContext, opts: { html: boolean }): string {
|
||||
return input.replace(TOKEN, (whole, name: string) => {
|
||||
const resolver = RESOLVERS[name];
|
||||
if (!resolver) return whole;
|
||||
const value = resolver(ctx);
|
||||
if (value === null) return whole;
|
||||
return opts.html ? escapeHtml(value) : value;
|
||||
});
|
||||
}
|
||||
@@ -490,3 +490,129 @@ export function useEdgeBack(el: HTMLElement | null, onBack: () => void, enabled:
|
||||
};
|
||||
}, [el, enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a horizontal drag must travel before it moves the calendar to
|
||||
* another day or month.
|
||||
*
|
||||
* Further than a row swipe, and not because the consequence is bigger --
|
||||
* stepping a calendar is undone by stepping back, while a swiped row has
|
||||
* already been archived. It is because this gesture has no way to change its
|
||||
* mind. A row slides open as it goes, so the strip underneath names what is
|
||||
* about to happen and letting go early calls it off, and a toast offers Undo
|
||||
* afterwards. Stepping the calendar shows nothing on the way and offers
|
||||
* nothing after, so the distance is the only chance to not mean it.
|
||||
*/
|
||||
export function navSwipeThreshold(width: number): number {
|
||||
return Math.max(80, Math.min(180, width * 0.3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which way a finished drag sends the view: -1 back, +1 forward, 0 nowhere.
|
||||
*
|
||||
* Dragging left pulls the next period in from the right, which is how paper,
|
||||
* phones and every other calendar behave. (It would need mirroring for a
|
||||
* right-to-left interface; there is not one yet, and the day there is, this is
|
||||
* one of the places that has to know.)
|
||||
*/
|
||||
export function swipeNavDirection(dx: number, width: number): -1 | 0 | 1 {
|
||||
const threshold = navSwipeThreshold(width);
|
||||
if (dx <= -threshold) return 1;
|
||||
if (dx >= threshold) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swipe sideways across a calendar to step it a period at a time.
|
||||
*
|
||||
* Three things it deliberately does not do:
|
||||
*
|
||||
* - **No visual drag.** The row swipe slides the row open because the strip
|
||||
* underneath has to name which of six actions is about to happen. Stepping
|
||||
* a calendar has two outcomes and the direction of the finger already says
|
||||
* which, so there is nothing to reveal -- and translating the grid would
|
||||
* break the sticky day header, since a transform makes a containing block.
|
||||
* The threshold is reported by the vibration motor instead, which is what
|
||||
* the haptics are for: a swipe fires as the finger passes a line it cannot
|
||||
* see.
|
||||
* - **It does not start on an event.** A drag beginning on an event chip is
|
||||
* left alone, so that moving an event by dragging it stays available to be
|
||||
* built without having to be untangled from this first. Which gesture is
|
||||
* meant is decidable at the moment the finger lands, and that is the only
|
||||
* moment it can be decided cleanly.
|
||||
* - **It does not start on the toolbar.** Buttons live there.
|
||||
*
|
||||
* The axis lock is the shared one, so it keeps the same bias towards the
|
||||
* vertical: the day grid scrolls through the hours, and a scroll misread as a
|
||||
* swipe throws the reader into another day.
|
||||
*/
|
||||
export function useSwipeNav(
|
||||
el: HTMLElement | null,
|
||||
opts: { onStep: (n: -1 | 1) => void; enabled: boolean; ignore?: string },
|
||||
) {
|
||||
const step = useRef(opts.onStep);
|
||||
step.current = opts.onStep;
|
||||
const { enabled, ignore } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
if (!el || !enabled) return;
|
||||
let startX: number | null = null;
|
||||
let startY = 0;
|
||||
let axis: Axis = null;
|
||||
let fired = false;
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if (ignore && (t.target as Element | null)?.closest?.(ignore)) return;
|
||||
startX = t.clientX;
|
||||
startY = t.clientY;
|
||||
axis = null;
|
||||
fired = false;
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (startX === null || e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
const dx = t.clientX - startX;
|
||||
const dy = t.clientY - startY;
|
||||
if (!axis) {
|
||||
axis = lockAxis(dx, dy);
|
||||
// Committed to scrolling: stay out of the way for the rest of the drag.
|
||||
if (axis === "y") startX = null;
|
||||
return;
|
||||
}
|
||||
if (axis !== "x") return;
|
||||
// Once sideways, the browser must not also scroll.
|
||||
if (e.cancelable) e.preventDefault();
|
||||
if (!fired && swipeNavDirection(dx, el.clientWidth || window.innerWidth) !== 0) {
|
||||
fired = true;
|
||||
haptic();
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = (e: TouchEvent) => {
|
||||
if (startX === null) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t ? t.clientX - startX : 0;
|
||||
const wasX = axis === "x";
|
||||
startX = null;
|
||||
axis = null;
|
||||
fired = false;
|
||||
if (!wasX) return;
|
||||
const dir = swipeNavDirection(dx, el.clientWidth || window.innerWidth);
|
||||
if (dir !== 0) step.current(dir);
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", onStart, { passive: true });
|
||||
el.addEventListener("touchmove", onMove, { passive: false });
|
||||
el.addEventListener("touchend", onEnd);
|
||||
el.addEventListener("touchcancel", onEnd);
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", onStart);
|
||||
el.removeEventListener("touchmove", onMove);
|
||||
el.removeEventListener("touchend", onEnd);
|
||||
el.removeEventListener("touchcancel", onEnd);
|
||||
};
|
||||
}, [el, enabled, ignore]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user