Merge branch 'main' into feat/privacy-safety-settings

# Conflicts:
#	web/src/styles/app.css
This commit is contained in:
2026-09-01 22:34:36 -07:00
22 changed files with 1537 additions and 10 deletions
+103
View File
@@ -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([]);
});
});
+55
View File
@@ -0,0 +1,55 @@
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");
});
});
+107
View File
@@ -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();
});
});
+68
View File
@@ -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("&lt;script&gt;");
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",
]);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Where a message goes when it is archived by date.
*
* The folders are **numeric and zero-padded** -- `Archive/2026`,
* `Archive/2026/09` -- and deliberately not month names. Two reasons, both
* about the fact that these are real server-side mailboxes rather than
* anything of ihasmail's:
*
* - Every other client sees them. A folder created as "September" by someone
* reading in English stays "September" for the same account read in
* Japanese, because the name is stored, not translated. A number reads the
* same in every language ihasmail ships.
* - They sort. `09` sits between `08` and `10` in any folder list; "September"
* sits between "October" and nothing useful.
*
* The date is read in the reader's own timezone rather than UTC, because it has
* to agree with the date shown against the message in the list. A message that
* arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
* filing it under `09` while the list says August would be the app disagreeing
* with itself.
*/
export type ArchiveGranularity = "year" | "month";
/**
* Path segments below the Archive folder. Empty means "no dated subfolder" --
* a message whose date cannot be read belongs in Archive itself rather than in
* a folder named after a guess.
*/
export function archiveSegments(when: string | null | undefined, granularity: ArchiveGranularity): string[] {
if (!when) return [];
const d = new Date(when);
if (Number.isNaN(d.getTime())) return [];
const year = String(d.getFullYear());
if (granularity === "year") return [year];
return [year, String(d.getMonth() + 1).padStart(2, "0")];
}
/** The segments as one string, for grouping and for naming the destination. */
export function archivePath(segments: string[]): string {
return segments.join("/");
}
export interface ArchiveGroup {
segments: string[];
ids: string[];
}
/**
* Split a selection by where each message is going.
*
* Archiving by month across a selection spanning two months is two
* destinations, not one, so this is the shape the caller needs -- and the
* reason the action cannot simply resolve one folder up front. Groups come
* back in the order their first message appeared, so the toast that follows
* names them in the order the reader was looking at.
*/
export function groupByArchivePath(
entries: Array<{ id: string; receivedAt?: string | null }>,
granularity: ArchiveGranularity,
): ArchiveGroup[] {
const groups = new Map<string, ArchiveGroup>();
for (const e of entries) {
const segments = archiveSegments(e.receivedAt, granularity);
const key = archivePath(segments);
const existing = groups.get(key);
if (existing) existing.ids.push(e.id);
else groups.set(key, { segments, ids: [e.id] });
}
return [...groups.values()];
}
+50
View File
@@ -0,0 +1,50 @@
/**
* A filename for a message saved or attached as `.eml`.
*
* The rule this replaces was `subject.replace(/[^\w.-]+/g, "_")`, and `\w`
* without the `u` flag is ASCII: every character of a Russian, Japanese or
* Chinese subject failed the class, so those messages downloaded as a row of
* underscores. ihasmail ships in nine languages besides English, so the
* subjects it handled worst were most of the world's.
*
* What is actually unsafe in a filename is a much shorter list than "not
* ASCII": the path separators, the characters Windows reserves, and the
* control range. Everything else is a letter to somebody.
*
* The test is written by code point rather than as a character class because
* the escaping in one of those is its own small trap, and this says plainly
* what it means.
*/
/** Reserved on Windows, or a path separator. */
const RESERVED = '<>:"/\\|?*';
function unsafe(ch: string): boolean {
const c = ch.codePointAt(0) ?? 0;
// C0 controls, and DEL.
if (c < 0x20 || c === 0x7f) return true;
return RESERVED.includes(ch);
}
/**
* Long enough to stay recognisable, short enough to survive a 255-*byte* limit
* once a CJK subject is three bytes a character.
*/
const MAX = 80;
/** The stem only, so a caller can put another extension on it. */
export function sanitizeFilename(subject: string | null | undefined): string {
const kept = [...(subject ?? "")].filter((ch) => !unsafe(ch)).join("");
return kept
// Whitespace becomes an underscore rather than being kept: it is what the
// previous rule did, and it saves a quoting question in a shell later.
.replace(/\s+/g, "_")
.slice(0, MAX)
// Windows refuses a name ending in a dot or a space, and a leading dot
// hides the file on Unix. Neither is worth inheriting from a subject.
.replace(/^[.\s_]+|[.\s_]+$/g, "");
}
export function emlFilename(subject: string | null | undefined): string {
return `${sanitizeFilename(subject) || "message"}.eml`;
}
+146
View File
@@ -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" };
}
+91
View File
@@ -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;
});
}
+126
View File
@@ -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]);
}
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useMail } from "@/store/mail";
import { useToasts } from "@/ui/toast";
import type { JmapSession, Mailbox } from "@/jmap/types";
/**
* Archiving into a dated subfolder. The parts worth testing are the ones that
* touch the server: the folders get created once and reused after that, and a
* selection spanning two months becomes two moves rather than one.
*/
const ARCHIVE = "mbArchive";
interface Created {
name: string;
parentId: string | null;
}
/** A server that holds a mailbox tree and records what was created and moved. */
function server(initial: Array<Partial<Mailbox> & { id: string; name: string }> = []) {
const boxes = new Map<string, Partial<Mailbox> & { id: string; name: string }>();
boxes.set(ARCHIVE, { id: ARCHIVE, role: "archive", name: "Archive", parentId: null });
for (const b of initial) boxes.set(b.id, b);
const created: Created[] = [];
const moves: Array<{ id: string; to: string }> = [];
let counter = 0;
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "Mailbox/set" && args.create) {
const spec = (args.create as Record<string, { name: string; parentId: string | null }>).n!;
const newId = `mb-new-${++counter}`;
created.push({ name: spec.name, parentId: spec.parentId });
boxes.set(newId, { id: newId, name: spec.name, parentId: spec.parentId, role: null });
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { n: { id: newId } }, notCreated: {} }, id];
}
if (name === "Mailbox/get") {
return [name, { accountId: "a1", state: "1", list: [...boxes.values()], notFound: [] }, id];
}
if (name === "Email/set" && args.update) {
for (const [emailId, patch] of Object.entries(args.update as Record<string, { mailboxIds?: Record<string, boolean> }>)) {
const to = Object.keys(patch.mailboxIds ?? {})[0];
if (to) moves.push({ id: emailId, to });
}
return [name, { accountId: "a1", oldState: "1", newState: "2", updated: {}, notUpdated: {} }, id];
}
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return { created, moves, boxes };
}
const messages = () => useToasts.getState().toasts.map((t) => t.message);
/** Two messages from September, one from August, all local time. */
function seed() {
useMail.setState({
emails: {
e1: { id: "e1", receivedAt: "2026-09-04T10:00:00", mailboxIds: { mbInbox: true } },
e2: { id: "e2", receivedAt: "2026-09-28T10:00:00", mailboxIds: { mbInbox: true } },
e3: { id: "e3", receivedAt: "2026-08-30T10:00:00", mailboxIds: { mbInbox: true } },
} as never,
});
}
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {} },
accounts: {},
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useMail.setState({
accountId: "a1",
mailboxes: { [ARCHIVE]: { id: ARCHIVE, role: "archive", name: "Archive", parentId: null } } as never,
list: null,
emails: {},
selected: {},
});
useToasts.setState({ toasts: [] });
seed();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("archiveByDate", () => {
it("creates the year folder under Archive and files into it", async () => {
const s = server();
await useMail.getState().archiveByDate(["e1"], "year");
expect(s.created).toEqual([{ name: "2026", parentId: ARCHIVE }]);
expect(s.moves).toEqual([{ id: "e1", to: "mb-new-1" }]);
});
it("creates year then month, nesting the month inside the year", async () => {
const s = server();
await useMail.getState().archiveByDate(["e1"], "month");
expect(s.created).toEqual([
{ name: "2026", parentId: ARCHIVE },
{ name: "09", parentId: "mb-new-1" },
]);
expect(s.moves).toEqual([{ id: "e1", to: "mb-new-2" }]);
});
it("reuses a folder that already exists rather than making a second one", async () => {
const s = server([
{ id: "mb2026", name: "2026", parentId: ARCHIVE, role: null },
{ id: "mb09", name: "09", parentId: "mb2026", role: null },
]);
useMail.setState({
mailboxes: {
[ARCHIVE]: { id: ARCHIVE, role: "archive", name: "Archive", parentId: null },
mb2026: { id: "mb2026", name: "2026", parentId: ARCHIVE },
mb09: { id: "mb09", name: "09", parentId: "mb2026" },
} as never,
});
await useMail.getState().archiveByDate(["e1"], "month");
expect(s.created).toEqual([]);
expect(s.moves).toEqual([{ id: "e1", to: "mb09" }]);
});
it("splits a selection spanning two months into two destinations", async () => {
const s = server();
await useMail.getState().archiveByDate(["e1", "e2", "e3"], "month");
expect(s.created).toEqual([
{ name: "2026", parentId: ARCHIVE },
{ name: "09", parentId: "mb-new-1" },
// August reuses the 2026 folder made a moment ago, and adds 08 beside 09.
{ name: "08", parentId: "mb-new-1" },
]);
expect(s.moves).toEqual([
{ id: "e1", to: "mb-new-2" },
{ id: "e2", to: "mb-new-2" },
{ id: "e3", to: "mb-new-3" },
]);
});
it("keeps the same selection to one folder at year granularity", async () => {
const s = server();
await useMail.getState().archiveByDate(["e1", "e2", "e3"], "year");
expect(s.created).toEqual([{ name: "2026", parentId: ARCHIVE }]);
expect(new Set(s.moves.map((m) => m.to))).toEqual(new Set(["mb-new-1"]));
});
it("files a message with no readable date into Archive itself", async () => {
const s = server();
useMail.setState({ emails: { e9: { id: "e9", receivedAt: null, mailboxIds: {} } } as never });
await useMail.getState().archiveByDate(["e9"], "month");
expect(s.created).toEqual([]);
expect(s.moves).toEqual([{ id: "e9", to: ARCHIVE }]);
});
it("raises one toast naming the folder, not one per group", async () => {
server();
await useMail.getState().archiveByDate(["e1"], "month");
expect(messages()).toEqual(["Conversation moved to Archive/2026/09"]);
});
it("says how many folders when the selection split, rather than naming one", async () => {
server();
await useMail.getState().archiveByDate(["e1", "e2", "e3"], "month");
expect(messages()).toHaveLength(1);
expect(messages()[0]).toContain("2 folders");
});
it("does nothing at all without an Archive folder", async () => {
const s = server();
useMail.setState({ mailboxes: {} as never });
await useMail.getState().archiveByDate(["e1"], "month");
expect(s.created).toEqual([]);
expect(s.moves).toEqual([]);
expect(messages()[0]).toContain("No Archive folder");
});
it("has nothing to do with an empty selection", async () => {
const s = server();
await useMail.getState().archiveByDate([], "month");
expect(s.created).toEqual([]);
expect(s.moves).toEqual([]);
});
});
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import type { Email } from "@/jmap/types";
/**
* Forwarding a message whole rather than quoted. The point of the
* implementation is that it costs no upload: a message's own `blobId` is its
* RFC822 blob and already lives in this account, so the attachment references
* it directly.
*/
function email(over: Partial<Email> = {}): Email {
return {
id: "e1",
blobId: "b-raw-1",
threadId: "t1",
mailboxIds: { mb1: true },
keywords: {},
size: 40 * 1024 * 1024,
receivedAt: "2026-03-04T10:00:00Z",
sentAt: "2026-03-04T10:00:00Z",
subject: "Quarterly report",
from: [{ name: "Ada Lovelace", email: "[email protected]" }],
to: [{ name: null, email: "[email protected]" }],
...over,
} as Email;
}
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({
accountId: "a1",
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
});
});
const draftFor = (key: string) => useCompose.getState().drafts.find((d) => d.key === key)!;
describe("forwardAsAttachment", () => {
it("attaches the message itself, by reference, with no upload", () => {
const key = useCompose.getState().forwardAsAttachment(email());
const d = draftFor(key);
expect(d.attachments).toHaveLength(1);
const a = d.attachments[0]!;
expect(a.type).toBe("message/rfc822");
// The message's own blob, carried straight across: nothing was uploaded,
// and the attachment is complete the moment the composer opens.
expect(a.blobId).toBe("b-raw-1");
expect(a.progress).toBe(100);
expect(a.error).toBeNull();
});
it("names the attachment from the subject", () => {
expect(draftFor(useCompose.getState().forwardAsAttachment(email())).attachments[0]!.name).toBe("Quarterly_report.eml");
});
it("names it from a subject in any script, not a row of underscores", () => {
const key = useCompose.getState().forwardAsAttachment(email({ subject: "四半期報告" }));
expect(draftFor(key).attachments[0]!.name).toBe("四半期報告.eml");
});
it("falls back to a name when there is no subject", () => {
const key = useCompose.getState().forwardAsAttachment(email({ subject: null }));
expect(draftFor(key).attachments[0]!.name).toBe("message.eml");
});
it("prefixes the subject once, and does not double it on a forward of a forward", () => {
expect(draftFor(useCompose.getState().forwardAsAttachment(email())).subject).toBe("Fwd: Quarterly report");
const again = useCompose.getState().forwardAsAttachment(email({ subject: "Fwd: Quarterly report" }));
expect(draftFor(again).subject).toBe("Fwd: Quarterly report");
});
it("marks the original forwarded, and starts no reply thread", () => {
const d = draftFor(useCompose.getState().forwardAsAttachment(email()));
expect(d.relatedEmailId).toBe("e1");
expect(d.relatedKeyword).toBe("$forwarded");
// A forward is not a reply: it must not join the original's thread.
expect(d.inReplyTo).toBeNull();
expect(d.references).toBeNull();
});
it("addresses nobody, since a forward chooses its own recipient", () => {
const d = draftFor(useCompose.getState().forwardAsAttachment(email()));
expect(d.to).toEqual([]);
expect(d.cc).toEqual([]);
});
it("does not quote the message into the body as well as attaching it", () => {
const d = draftFor(useCompose.getState().forwardAsAttachment(email()));
expect(d.html).not.toContain("Forwarded message");
expect(d.text).not.toContain("Forwarded message");
});
});
+38 -2
View File
@@ -11,6 +11,8 @@ import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n";
import { settings } from "./settings";
import { emlFilename } from "@/lib/emlName";
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
export interface ComposeAttachment {
id: string;
@@ -84,6 +86,8 @@ interface ComposeState {
/** Open a message again as a mail that has not been sent yet. */
composeAsNew(email: Email): Promise<string>;
reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise<string>;
/** Forward the message whole, as an attachment, rather than quoted into a new one. */
forwardAsAttachment(email: Email): string;
update(key: string, patch: Partial<Draft>): void;
close(key: string, opts?: { discard?: boolean }): Promise<void>;
focus(key: string): void;
@@ -381,6 +385,29 @@ export const useCompose = create<ComposeState>((set, get) => ({
return d.key;
},
forwardAsAttachment(email) {
const accountId = useMail.getState().accountId;
const key = get().open({
subject: replySubject(email.subject, "Fwd"),
relatedEmailId: email.id,
relatedKeyword: "$forwarded",
replyMode: "forward",
});
// A message's own blobId *is* its RFC822 blob, and it already lives in this
// account -- so this goes through the same path as attach-from-Files and
// uploads nothing at all, however large the message.
//
// It inherits that path's size check as well, which is measured against
// `maxSizeUpload` even though nothing is being uploaded. That is worth
// knowing rather than working around here: the check belongs to
// `addFromFiles` and applies to every by-reference attachment, so if it is
// wrong it is wrong in one place and should be fixed there.
if (accountId) {
void get().addFromFiles(key, [{ accountId, name: emlFilename(email.subject), type: "message/rfc822", size: email.size, blobId: email.blobId }]);
}
return key;
},
update(key, patch) {
set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, ...patch, dirty: patch.dirty ?? (d.dirty || isContentPatch(patch)) } : d)) }));
if (isContentPatch(patch)) scheduleAutosave(key, get);
@@ -575,8 +602,17 @@ export const useCompose = create<ComposeState>((set, get) => ({
insertTemplate(key, html, subject) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(html)}</div>${d.html}`, text: `${htmlToText(html)}\n${d.text}` };
if (subject && !d.subject) patch.subject = subject;
// Placeholders are filled against the draft as it stands right now, which
// is why this happens on insert rather than on send: what the template is
// filled with is visible and editable afterwards, instead of changing
// under the message between writing it and sending it.
const ident = d.identityId ? useMail.getState().identities.find((i) => i.id === d.identityId) : undefined;
const ctx: PlaceholderContext = { to: d.to, from: ident ? { name: ident.name, email: ident.email } : null, subject: d.subject };
// The body is filled once as HTML and the plain-text side derived from the
// result, so the two cannot disagree about what a placeholder came to.
const filled = fillPlaceholders(html, ctx, { html: true });
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(filled)}</div>${d.html}`, text: `${htmlToText(filled)}\n${d.text}` };
if (subject && !d.subject) patch.subject = fillPlaceholders(subject, ctx, { html: false });
get().update(key, patch);
},
}));
+86
View File
@@ -1,5 +1,7 @@
import { create } from "zustand";
import type { FolderRef } from "@/lib/sieveFolders";
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
import type {
Comparator,
@@ -89,6 +91,7 @@ export const FULL_PROPS = [
"header:Auto-Submitted:asText",
"header:Precedence:asText",
"header:Authentication-Results:asText",
...SPAM_HEADER_PROPS,
];
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
@@ -155,6 +158,8 @@ export interface MailState {
trash(ids: Id[]): Promise<void>;
destroy(ids: Id[]): Promise<void>;
archive(ids: Id[]): Promise<void>;
/** Archive into a dated subfolder of Archive, creating the folders as needed. */
archiveByDate(ids: Id[], granularity: ArchiveGranularity): Promise<void>;
spam(ids: Id[], isSpam: boolean): Promise<void>;
emptyMailbox(mailboxId: Id): Promise<void>;
/** Mark every unread message in a mailbox read; optionally its subfolders too. */
@@ -575,6 +580,66 @@ export const useMail = create<MailState>((set, get) => ({
await get().move(ids, archiveId, { label: "Archive" });
},
async archiveByDate(ids, granularity) {
const accountId = get().accountId;
const archiveId = get().roleId("archive") ?? get().roleId("all");
if (!accountId || !ids.length) return;
if (!archiveId) {
toast.error(t("No Archive folder found. Create one named “Archive” first."));
return;
}
const { emails } = get();
const groups = groupByArchivePath(ids.map((id) => ({ id, receivedAt: emails[id]?.receivedAt })), granularity);
// Where everything came from, captured before anything moves, so one Undo
// can put back a selection that went to several folders.
const prev: Record<Id, Record<Id, boolean>> = {};
for (const id of ids) prev[id] = emails[id]?.mailboxIds ?? {};
const moved: string[] = [];
try {
for (const group of groups) {
const target = await ensureFolderPath(get, archiveId, group.segments);
// Silent: each group would otherwise raise its own toast with its own
// Undo, and undoing one third of a move is not what anybody meant.
await get().move(group.ids, target, { silent: true });
moved.push(group.segments.length ? `Archive/${archivePath(group.segments)}` : "Archive");
}
} catch (err) {
toast.error(t("Archive failed: {error}", { error: (err as Error).message }));
void get().getEmails(ids);
void get().refreshList();
return;
}
// One message naming every destination, because a selection that split
// across months should say so rather than claiming a single folder.
const where = moved.length === 1 ? moved[0]! : t("{count} folders", { count: String(moved.length) });
toast.show(
ids.length === 1
? t("Conversation moved to {folder}", { folder: where })
: t("{count} conversations moved to {folder}", { count: String(ids.length), folder: where }),
{
action: {
label: "Undo",
onClick: async () => {
const undo: Record<Id, Record<string, unknown>> = {};
for (const id of ids) undo[id] = { mailboxIds: prev[id] };
await setEmails(accountId, undo);
set((st) => {
const next = { ...st.emails };
for (const id of ids) if (next[id]) next[id] = { ...next[id]!, mailboxIds: prev[id]! };
return { emails: next };
});
void get().refreshList();
void get().loadMailboxes();
},
},
},
);
void get().loadMailboxes();
},
async spam(ids, isSpam) {
const { roleId } = get();
const target = isSpam ? roleId("junk") : roleId("inbox");
@@ -1081,6 +1146,27 @@ export function mailboxIcon(role: MailboxRole): string {
export const ROLE_ORDER: Record<string, number> = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 };
/**
* Resolve `parentId/segments...` to a mailbox id, creating what is missing.
*
* Reuses a folder that is already there rather than making a second one beside
* it, so archiving by month twice in the same month files into the same place
* -- including a folder somebody made by hand, or one another client made
* first, which is the usual way `Archive/2026` already exists.
*
* Sequential on purpose: each level is the next level's parent, and
* `createMailbox` reloads the tree, so the lookup for `09` can see the `2026`
* that was just created.
*/
async function ensureFolderPath(state: () => MailState, parentId: Id, segments: string[]): Promise<Id> {
let current = parentId;
for (const name of segments) {
const existing = Object.values(state().mailboxes).find((m) => m.parentId === current && m.name === name);
current = existing ? existing.id : await state().createMailbox(name, current);
}
return current;
}
/**
* A folder and everything under it, with the paths they have right now.
*
+21 -2
View File
@@ -666,8 +666,12 @@ a.menu-item:hover { color: var(--fg); }
.attachment .att-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent-soft-fg); flex: 0 0 auto; overflow: hidden; }
.attachment .att-icon img { width: 100%; height: 100%; object-fit: cover; }
.attachment .att-text { flex: 1; min-width: 0; }
.attachment .att-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .92em; }
.attachment .att-size { color: var(--fg-muted); font-size: .8em; }
/* Both are spans, and `overflow`/`text-overflow` do nothing on an inline
element -- so the name never truncated and the size ran on after it on the
same line. Only long names showed it, which is every .eml named from a
subject. */
.attachment .att-name { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .92em; }
.attachment .att-size { display: block; color: var(--fg-muted); font-size: .8em; }
.attachment .att-actions { display: none; gap: 0; }
.attachment:hover .att-actions { display: flex; }
.attachment:hover .att-size { display: none; }
@@ -1377,5 +1381,20 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.composer-field label .link-btn:hover { color: var(--accent); }
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
/* The spam filter's own working, in the message details. */
.spam-summary { display: flex; flex-direction: column; gap: 6px; }
/* Capped, so a rule with no note does not fling its score to the far side of a
wide reading pane. */
.spam-rules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-width: 30rem; }
.spam-rules li { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: baseline; }
.spam-weight { font-family: var(--font-mono); font-size: 12px; text-align: right; color: var(--fg-muted); }
.spam-weight.bad { color: var(--danger); }
.spam-weight.good { color: var(--success); }
/* The placeholder reference under a template's body. */
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
.placeholder-row { display: contents; }
.placeholder-list code { font-family: var(--font-mono); font-size: 12.5px; background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 4px; padding: .1em .4em; white-space: nowrap; }
/* The senders whose remote images load without asking, in Privacy & safety. */
.trusted-senders { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
+22 -2
View File
@@ -4,9 +4,10 @@ import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-rea
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
import { useSwipeNav } from "@/lib/touch";
import { formatMonthYear, formatTime } from "@/lib/format";
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
import { Empty, useIsMobile } from "@/ui/misc";
import { Empty, useIsMobile, useIsTouch } from "@/ui/misc";
import { keyboard } from "@/lib/keyboard";
import { EventPopover } from "./EventPopover";
import { EventEditor, type EditorInit } from "./EventEditor";
@@ -90,6 +91,25 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
else go(view, addDays(anchor, 30 * n));
};
/*
* Swipe sideways to step the calendar, on a touchscreen only and only in the
* two views where a period is a page: day and month. Week and agenda scroll
* through a range rather than turning to the next one, so there is nothing a
* sideways flick would obviously mean.
*
* The buttons in the toolbar stay, and so does n/p. A gesture with no
* visible control is one only the people who already know about it can use.
*/
const [mainEl, setMainEl] = useState<HTMLDivElement | null>(null);
const isTouch = useIsTouch();
useSwipeNav(mainEl, {
enabled: isTouch && (effectiveView === "day" || effectiveView === "month"),
onStep: (n) => step(n),
// Buttons live in the toolbar; an event is where a future drag-to-move
// gesture has to start, so this one keeps out of both.
ignore: ".cal-toolbar, .ev-chip, .ev-block, .agenda-ev",
});
const openNew = useCallback(
(start?: Date, end?: Date, allDay = false) => {
const s = start ?? roundToNext(new Date(), 30);
@@ -147,7 +167,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
};
return (
<div className="cal-main">
<div className="cal-main" ref={setMainEl}>
<div className="cal-toolbar">
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("Today")}</button>
<button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>
+21 -1
View File
@@ -1,11 +1,12 @@
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Archive, ArrowLeft, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
import { useLocation } from "wouter";
import { useMail, type ListState } from "@/store/mail";
import { dateTimeKey, useSettings } from "@/store/settings";
import type { Email, Id } from "@/jmap/types";
import { formatListDate } from "@/lib/format";
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
import { displayName, shortName } from "@/lib/address";
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
@@ -224,6 +225,22 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
);
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
/*
* Name the destination where there is only one, so the menu says where the
* mail is actually going rather than describing the rule. A selection that
* spans months has no single answer, and claiming one would be worse than
* naming the rule -- so that case falls back to it.
*/
const archiveDateLabel = useCallback(
(granularity: ArchiveGranularity) => {
const groups = groupByArchivePath(ctxTargets.map((id) => ({ id, receivedAt: emails[id]?.receivedAt })), granularity);
const only = groups.length === 1 ? groups[0]! : null;
if (only?.segments.length) return t("Archive to {folder}", { folder: archivePath(only.segments) });
return granularity === "year" ? t("Archive by year") : t("Archive by month");
},
[ctxTargets, emails],
);
const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
@@ -475,9 +492,12 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
<MenuItem icon={<Reply size={16} />} label={t("Reply")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
<MenuItem icon={<Forward size={16} />} label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
<MenuItem icon={<Paperclip size={16} />} label={t("Forward as attachment")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) useCompose.getState().forwardAsAttachment(e); }} />
<MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} />
<MenuSep />
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
<MenuItem icon={<CalendarRange size={16} />} label={archiveDateLabel("year")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "year")} />
<MenuItem icon={<CalendarDays size={16} />} label={archiveDateLabel("month")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "month")} />
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
<MenuSep />
+55 -1
View File
@@ -10,6 +10,8 @@ import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client";
import { emlFilename } from "@/lib/emlName";
import { spamReport, type SpamReport } from "@/lib/spamScore";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
@@ -112,6 +114,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const isHighPriority = /^[12]/.test(e["header:X-Priority:asText"] ?? "") || /high/i.test(e["header:Importance:asText"] ?? "");
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
const spam = useMemo(() => spamReport(e), [e]);
const openSource = async () => {
setShowSource(true);
@@ -126,7 +129,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const downloadEml = () => {
const a = document.createElement("a");
a.href = client.downloadUrl(accountId, e.blobId, `${(e.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822");
a.href = client.downloadUrl(accountId, e.blobId, emlFilename(e.subject), "message/rfc822");
a.download = "";
a.click();
};
@@ -229,6 +232,9 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<MenuItem icon={<Reply size={16} />} label={translate("Reply")} onClick={() => void reply(e, "reply")} />
<MenuItem icon={<ReplyAll size={16} />} label={translate("Reply all")} onClick={() => void reply(e, "replyAll")} />
<MenuItem icon={<Forward size={16} />} label={translate("Forward")} onClick={() => void reply(e, "forward")} />
{/* The same message rather than a quotation of it: headers, attachments
and all, for passing one on to be looked at rather than read. */}
<MenuItem icon={<Paperclip size={16} />} label={translate("Forward as attachment")} onClick={() => useCompose.getState().forwardAsAttachment(e)} />
{/* Sends the same mail again rather than passing it on, so it sits with
the other three rather than down among the read-only actions. */}
<MenuItem icon={<MailPlus size={16} />} label={translate("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(e)} />
@@ -265,6 +271,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{e.messageId?.[0] && <><dt>{translate("Message-ID")}</dt><dd className="mono small">{e.messageId[0]}</dd></>}
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
{spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>}
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
</dl>
)}
@@ -552,6 +559,53 @@ function TextBody({ text }: { text: string }) {
);
}
/* ---------- Spam ---------- */
/**
* What the filter said, not what we think of it. The verdict line only claims
* as much as the header did: where the filter stated one, it is shown; where
* it only left a score, the score is shown on its own rather than being turned
* into a verdict here.
*
* A score is always given its threshold where the header carried one, because
* the number is unreadable without it -- 6.7 is damning against 5 and
* unremarkable against 15. Where none was stated, that is said.
*/
function SpamSummary({ report }: { report: SpamReport }) {
const { verdict, score, threshold, rules } = report;
return (
<div className="spam-summary">
<div>
{verdict === "spam" && <strong>{translate("Marked as spam")}</strong>}
{verdict === "clean" && <strong>{translate("Not spam")}</strong>}
{verdict === null && <strong>{translate("No verdict recorded")}</strong>}
{score !== null && (
<span className="hint">
{" — "}
{threshold !== null
? translate("scored {score} against a threshold of {threshold}", { score: String(score), threshold: String(threshold) })
: translate("scored {score}, with no threshold stated", { score: String(score) })}
</span>
)}
</div>
{rules.length > 0 && (
<ul className="spam-rules">
{rules.map((r, i) => (
<li key={`${r.name}-${i}`}>
<span className="mono small">{r.name}</span>
{r.detail && <span className="hint truncate">{r.detail}</span>}
{/* Signed, because which way a rule pushed is the whole point. */}
<span className={`spam-weight ${r.score > 0 ? "bad" : r.score < 0 ? "good" : ""}`}>
{r.score > 0 ? `+${r.score}` : String(r.score)}
</span>
</li>
))}
</ul>
)}
</div>
);
}
/* ---------- Attachments ---------- */
export function attachmentIcon(type: string, name?: string | null) {
+2 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MailPlus, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download } from "lucide-react";
import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MailPlus, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download , Paperclip} from "lucide-react";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
import { useCompose } from "@/store/compose";
@@ -302,6 +302,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
<span className="spacer" />
<button className="icon-btn" onClick={replyMore.open} aria-label={t("More ways to send this")}><MoreVertical size={18} /></button>
<Popover anchor={replyMore.anchor} onClose={replyMore.close} align="end" width={220}>
<MenuItem icon={<Paperclip size={16} />} label={t("Forward as attachment")} onClick={() => useCompose.getState().forwardAsAttachment(last)} />
<MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(last)} />
</Popover>
</div>
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
import { RichEditor } from "../compose/RichEditor";
import { htmlToText } from "@/lib/text";
import { t as translate } from "@/lib/i18n";
import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders";
export function TemplatesSettings() {
const templates = useSettings((s) => s.settings.templates);
@@ -37,8 +38,48 @@ export function TemplatesSettings() {
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder={translate("Template text…")} />
</div>
</div>
<PlaceholderReference />
</Dialog>
)}
</div>
);
}
/**
* What each placeholder answers, shown under the body so the names are
* discoverable without documentation. Rendered from `PLACEHOLDER_NAMES` rather
* than from this map, so a name added to the lib and forgotten here appears
* with no description instead of quietly not appearing at all.
*/
function placeholderHint(name: string): string {
switch (name) {
case "recipientName": return translate("Who the message is addressed to");
case "recipientFirstName": return translate("Their first name alone");
case "recipientEmail": return translate("Their address");
case "myName": return translate("The name on the identity you are sending as");
case "myEmail": return translate("That identity's address");
case "subject": return translate("The subject already on the message");
case "date": return translate("Today, in your date format");
case "time": return translate("Now, on your clock");
default: return "";
}
}
function PlaceholderReference() {
return (
<div className="field">
<label>{translate("Placeholders")}</label>
<div className="placeholder-list">
{PLACEHOLDER_NAMES.map((name) => (
<div key={name} className="placeholder-row">
<code>{`{{${name}}}`}</code>
<span className="hint">{placeholderHint(name)}</span>
</div>
))}
</div>
<p className="hint">
{translate("Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.")}
</p>
</div>
);
}