diff --git a/FEATURES.md b/FEATURES.md index 40c12bb..79d19db 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -150,6 +150,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/` or `Archive//`, +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 diff --git a/web/src/lib/__tests__/archiveDate.test.ts b/web/src/lib/__tests__/archiveDate.test.ts new file mode 100644 index 0000000..ee9304c --- /dev/null +++ b/web/src/lib/__tests__/archiveDate.test.ts @@ -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([]); + }); +}); diff --git a/web/src/lib/archiveDate.ts b/web/src/lib/archiveDate.ts new file mode 100644 index 0000000..b2f9d3e --- /dev/null +++ b/web/src/lib/archiveDate.ts @@ -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(); + 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()]; +} diff --git a/web/src/store/__tests__/archive-by-date.test.ts b/web/src/store/__tests__/archive-by-date.test.ts new file mode 100644 index 0000000..ec2119a --- /dev/null +++ b/web/src/store/__tests__/archive-by-date.test.ts @@ -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 & { id: string; name: string }> = []) { + const boxes = new Map & { 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][] }; + const methodResponses = body.methodCalls.map(([name, args, id]) => { + if (name === "Mailbox/set" && args.create) { + const spec = (args.create as Record).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 }>)) { + 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([]); + }); +}); diff --git a/web/src/store/mail.ts b/web/src/store/mail.ts index 61137fa..49c9680 100644 --- a/web/src/store/mail.ts +++ b/web/src/store/mail.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import type { FolderRef } from "@/lib/sieveFolders"; +import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate"; import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client"; import type { Comparator, @@ -155,6 +156,8 @@ export interface MailState { trash(ids: Id[]): Promise; destroy(ids: Id[]): Promise; archive(ids: Id[]): Promise; + /** Archive into a dated subfolder of Archive, creating the folders as needed. */ + archiveByDate(ids: Id[], granularity: ArchiveGranularity): Promise; spam(ids: Id[], isSpam: boolean): Promise; emptyMailbox(mailboxId: Id): Promise; /** Mark every unread message in a mailbox read; optionally its subfolders too. */ @@ -575,6 +578,66 @@ export const useMail = create((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> = {}; + 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> = {}; + 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 +1144,27 @@ export function mailboxIcon(role: MailboxRole): string { export const ROLE_ORDER: Record = { 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 { + 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. * diff --git a/web/src/views/mail/MessageList.tsx b/web/src/views/mail/MessageList.tsx index 38e8b60..4640e4e 100644 --- a/web/src/views/mail/MessageList.tsx +++ b/web/src/views/mail/MessageList.tsx @@ -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); @@ -478,6 +495,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on } label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} /> } label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} /> + } label={archiveDateLabel("year")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "year")} /> + } label={archiveDateLabel("month")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "month")} /> } label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} /> } label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />