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:
2026-09-01 22:31:42 -07:00
19 changed files with 1289 additions and 6 deletions
+3
View File
@@ -6,3 +6,6 @@ dist/
server/data/
.vite/
coverage/
# Worktrees used by parallel agents; never part of a commit.
.claude/worktrees/
+52 -1
View File
@@ -110,6 +110,21 @@ every drag.
- **Hold a folder** in the drawer for the menu its ⋮ button opens.
- **Pull the message list** down to refresh it.
- **Drag in from the left edge** of a conversation to go back to the list.
- **Swipe the calendar sideways** in day or month view to step to the next
period or back — dragging left pulls the next one in from the right, the way
paper and every phone do it. Week and agenda scroll through a range rather
than turning to the next one, so a sideways flick would not obviously mean
anything there and does nothing. A drag that begins on an event is left
alone, which keeps dragging an event to move it available to be built later
without having to be untangled from this first.
It asks for a longer drag than a row swipe does, and not because the
consequence is bigger — stepping back undoes it, 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 will
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.
The toolbar's refresh button and the thread's back arrow both stay. A gesture
with no visible control is one only the people who already know about it can
@@ -150,6 +165,21 @@ Archive, delete, spam, star, mark read/unread, move and label all offer **Undo**
in the toast that follows, and the undo restores the previous state rather than
guessing at an inverse.
**Archive by date** files into `Archive/<year>` or `Archive/<year>/<month>`,
creating the folders as needed and reusing them after that — including ones
made by hand or by another client. The names are numeric and zero-padded
(`2026`, `2026/09`) rather than month names, because these are real server-side
mailboxes: every other client sees them, a folder created as "September" by
someone reading in English stays "September" for the same account read in
Japanese, and `09` sorts between `08` and `10` where a name does not. The date
is read in the reader's own timezone, so it agrees with the date shown against
the message in the list.
A selection spanning two months is two destinations, not one, and both are
written; the menu names the folder where there is a single answer and describes
the rule where there is not, and the toast afterwards says how many folders it
touched. One Undo puts the whole selection back wherever it came from.
`Delete` moves to the bin. **Empty** destroys, and is offered only on Deleted
Items and Junk Mail — enforced where the action happens, not merely hidden in
the menu. Emptying Junk destroys rather than moving to the bin, because routing
@@ -244,6 +274,18 @@ same query string — so what it builds can be read, edited and learned from.
becoming a row of underscores.
- **Unsubscribe** where the message carries `List-Unsubscribe`.
- **Sender details** expand to the full From/To/Cc/Reply-To with addresses.
- **What the spam filter said** sits in those details, read back off the
message rather than scored here: the verdict, the score, the threshold it was
measured against, and the rules that moved it, largest mover first and signed
so which way each pushed is visible. Both the SpamAssassin-shaped `X-Spam-*`
set that Stalwart's own filter writes and Rspamd's `X-Spamd-Result` are read;
anything else is left alone rather than guessed at. Two things it will not
do: a score is always given the threshold it was measured against, because
6.7 is damning against 5 and unremarkable against 15 and the number alone is
not something a reader can act on — where no threshold was stated, it says
so; and where the filter recorded no verdict, none is invented from the score,
since the filter applies policy ihasmail cannot see. Mail that arrived without
these headers shows nothing.
- **Message body theming** is off by default — sender HTML is left exactly as it
was designed, on a light card. One setting lets mail that brings no colours of
its own follow the app's theme instead.
@@ -322,7 +364,16 @@ minimisable and maximisable; full-screen on mobile.
identity. Signature images live in Files too and are turned into inline
`cid:` parts when the message is sent.
- **Templates**: named subject + body, inserted into any draft, managed in
Settings.
Settings. Both carry **placeholders**`{{recipientName}}`,
`{{recipientFirstName}}`, `{{recipientEmail}}`, `{{myName}}`, `{{myEmail}}`,
`{{subject}}`, `{{date}}` and `{{time}}` — filled at the moment the template
is inserted, so what they came to is visible and editable before anything is
sent rather than changing under the message afterwards. Dates and times
follow the same format settings as the rest of the app. A placeholder that
cannot be answered yet — a recipient's name on a draft nobody has addressed —
is **left in the body exactly as written**, because substituting an empty
string there produces "Hi ,", a greeting that is wrong rather than one that
is visibly unfinished. A name that is not a placeholder is left alone too.
- **Attachments** by picking or dragging onto the composer, with progress per
file and the size limit the server states (`MAX_UPLOAD_BYTES`, 50 MB by
default). A pasted image is inserted inline instead, and pasted HTML is
+8
View File
@@ -128,6 +128,14 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
// Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so
// delivered mail carries it and mail this account wrote does not.
"header:X-Spam-Status:asText":
o.mailbox === "junk"
? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no"
: o.mailbox === "inbox"
? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]"
: null,
};
emails.push(e);
return e;
+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([]);
});
});
+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()];
}
+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([]);
});
});
+12 -2
View File
@@ -12,6 +12,7 @@ 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;
@@ -601,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.
*
+15
View File
@@ -1380,3 +1380,18 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
.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; }
+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>
+20 -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);
@@ -479,6 +496,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<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 />
+50
View File
@@ -11,6 +11,7 @@ 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";
@@ -113,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);
@@ -269,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>
)}
@@ -556,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) {
@@ -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>
);
}