Merge pull request #198 from Coffey-Labs/feat/archive-by-date
Archive into a dated folder
This commit is contained in:
+15
@@ -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
|
in the toast that follows, and the undo restores the previous state rather than
|
||||||
guessing at an inverse.
|
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
|
`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
|
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
|
the menu. Emptying Junk destroys rather than moving to the bin, because routing
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/archiveDate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dates below are written as local-time strings on purpose. The segments
|
||||||
|
* follow the reader's timezone, so a test pinned to UTC instants would pass or
|
||||||
|
* fail depending on where it ran.
|
||||||
|
*/
|
||||||
|
describe("archiveSegments", () => {
|
||||||
|
it("gives the year, and the zero-padded month", () => {
|
||||||
|
expect(archiveSegments("2026-09-04T10:00:00", "year")).toEqual(["2026"]);
|
||||||
|
expect(archiveSegments("2026-09-04T10:00:00", "month")).toEqual(["2026", "09"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zero-pads every month below October, so the folders sort", () => {
|
||||||
|
expect(archiveSegments("2026-01-15T10:00:00", "month")).toEqual(["2026", "01"]);
|
||||||
|
expect(archiveSegments("2026-10-15T10:00:00", "month")).toEqual(["2026", "10"]);
|
||||||
|
expect(archiveSegments("2026-12-15T10:00:00", "month")).toEqual(["2026", "12"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing to append when the date cannot be read", () => {
|
||||||
|
// Archive itself, rather than a folder named after a guess.
|
||||||
|
expect(archiveSegments(null, "month")).toEqual([]);
|
||||||
|
expect(archiveSegments(undefined, "month")).toEqual([]);
|
||||||
|
expect(archiveSegments("", "month")).toEqual([]);
|
||||||
|
expect(archiveSegments("not a date", "month")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins to a path", () => {
|
||||||
|
expect(archivePath(["2026", "09"])).toBe("2026/09");
|
||||||
|
expect(archivePath([])).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupByArchivePath", () => {
|
||||||
|
it("keeps one destination for a selection from one month", () => {
|
||||||
|
const groups = groupByArchivePath(
|
||||||
|
[
|
||||||
|
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||||
|
{ id: "b", receivedAt: "2026-09-28T10:00:00" },
|
||||||
|
],
|
||||||
|
"month",
|
||||||
|
);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0]!.segments).toEqual(["2026", "09"]);
|
||||||
|
expect(groups[0]!.ids).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a selection that spans months, which is the case that matters", () => {
|
||||||
|
const groups = groupByArchivePath(
|
||||||
|
[
|
||||||
|
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||||
|
{ id: "b", receivedAt: "2026-08-30T10:00:00" },
|
||||||
|
{ id: "c", receivedAt: "2026-09-01T10:00:00" },
|
||||||
|
],
|
||||||
|
"month",
|
||||||
|
);
|
||||||
|
expect(groups.map((g) => g.segments)).toEqual([
|
||||||
|
["2026", "09"],
|
||||||
|
["2026", "08"],
|
||||||
|
]);
|
||||||
|
expect(groups[0]!.ids).toEqual(["a", "c"]);
|
||||||
|
expect(groups[1]!.ids).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses the same span back to one group at year granularity", () => {
|
||||||
|
const entries = [
|
||||||
|
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||||
|
{ id: "b", receivedAt: "2026-02-28T10:00:00" },
|
||||||
|
];
|
||||||
|
expect(groupByArchivePath(entries, "month")).toHaveLength(2);
|
||||||
|
expect(groupByArchivePath(entries, "year")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders groups by where their first message appeared", () => {
|
||||||
|
const groups = groupByArchivePath(
|
||||||
|
[
|
||||||
|
{ id: "a", receivedAt: "2024-01-04T10:00:00" },
|
||||||
|
{ id: "b", receivedAt: "2026-01-04T10:00:00" },
|
||||||
|
],
|
||||||
|
"year",
|
||||||
|
);
|
||||||
|
expect(groups.map((g) => archivePath(g.segments))).toEqual(["2024", "2026"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gathers the undatable ones into their own group, bound for Archive itself", () => {
|
||||||
|
const groups = groupByArchivePath(
|
||||||
|
[
|
||||||
|
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
|
||||||
|
{ id: "b", receivedAt: null },
|
||||||
|
{ id: "c", receivedAt: "bad" },
|
||||||
|
],
|
||||||
|
"month",
|
||||||
|
);
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[1]!.segments).toEqual([]);
|
||||||
|
expect(groups[1]!.ids).toEqual(["b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to do with an empty selection", () => {
|
||||||
|
expect(groupByArchivePath([], "month")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Where a message goes when it is archived by date.
|
||||||
|
*
|
||||||
|
* The folders are **numeric and zero-padded** -- `Archive/2026`,
|
||||||
|
* `Archive/2026/09` -- and deliberately not month names. Two reasons, both
|
||||||
|
* about the fact that these are real server-side mailboxes rather than
|
||||||
|
* anything of ihasmail's:
|
||||||
|
*
|
||||||
|
* - Every other client sees them. A folder created as "September" by someone
|
||||||
|
* reading in English stays "September" for the same account read in
|
||||||
|
* Japanese, because the name is stored, not translated. A number reads the
|
||||||
|
* same in every language ihasmail ships.
|
||||||
|
* - They sort. `09` sits between `08` and `10` in any folder list; "September"
|
||||||
|
* sits between "October" and nothing useful.
|
||||||
|
*
|
||||||
|
* The date is read in the reader's own timezone rather than UTC, because it has
|
||||||
|
* to agree with the date shown against the message in the list. A message that
|
||||||
|
* arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
|
||||||
|
* filing it under `09` while the list says August would be the app disagreeing
|
||||||
|
* with itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ArchiveGranularity = "year" | "month";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path segments below the Archive folder. Empty means "no dated subfolder" --
|
||||||
|
* a message whose date cannot be read belongs in Archive itself rather than in
|
||||||
|
* a folder named after a guess.
|
||||||
|
*/
|
||||||
|
export function archiveSegments(when: string | null | undefined, granularity: ArchiveGranularity): string[] {
|
||||||
|
if (!when) return [];
|
||||||
|
const d = new Date(when);
|
||||||
|
if (Number.isNaN(d.getTime())) return [];
|
||||||
|
const year = String(d.getFullYear());
|
||||||
|
if (granularity === "year") return [year];
|
||||||
|
return [year, String(d.getMonth() + 1).padStart(2, "0")];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The segments as one string, for grouping and for naming the destination. */
|
||||||
|
export function archivePath(segments: string[]): string {
|
||||||
|
return segments.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveGroup {
|
||||||
|
segments: string[];
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a selection by where each message is going.
|
||||||
|
*
|
||||||
|
* Archiving by month across a selection spanning two months is two
|
||||||
|
* destinations, not one, so this is the shape the caller needs -- and the
|
||||||
|
* reason the action cannot simply resolve one folder up front. Groups come
|
||||||
|
* back in the order their first message appeared, so the toast that follows
|
||||||
|
* names them in the order the reader was looking at.
|
||||||
|
*/
|
||||||
|
export function groupByArchivePath(
|
||||||
|
entries: Array<{ id: string; receivedAt?: string | null }>,
|
||||||
|
granularity: ArchiveGranularity,
|
||||||
|
): ArchiveGroup[] {
|
||||||
|
const groups = new Map<string, ArchiveGroup>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const segments = archiveSegments(e.receivedAt, granularity);
|
||||||
|
const key = archivePath(segments);
|
||||||
|
const existing = groups.get(key);
|
||||||
|
if (existing) existing.ids.push(e.id);
|
||||||
|
else groups.set(key, { segments, ids: [e.id] });
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { FolderRef } from "@/lib/sieveFolders";
|
import type { FolderRef } from "@/lib/sieveFolders";
|
||||||
|
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||||
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
||||||
import type {
|
import type {
|
||||||
Comparator,
|
Comparator,
|
||||||
@@ -155,6 +156,8 @@ export interface MailState {
|
|||||||
trash(ids: Id[]): Promise<void>;
|
trash(ids: Id[]): Promise<void>;
|
||||||
destroy(ids: Id[]): Promise<void>;
|
destroy(ids: Id[]): Promise<void>;
|
||||||
archive(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>;
|
spam(ids: Id[], isSpam: boolean): Promise<void>;
|
||||||
emptyMailbox(mailboxId: Id): Promise<void>;
|
emptyMailbox(mailboxId: Id): Promise<void>;
|
||||||
/** Mark every unread message in a mailbox read; optionally its subfolders too. */
|
/** Mark every unread message in a mailbox read; optionally its subfolders too. */
|
||||||
@@ -575,6 +578,66 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
await get().move(ids, archiveId, { label: "Archive" });
|
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) {
|
async spam(ids, isSpam) {
|
||||||
const { roleId } = get();
|
const { roleId } = get();
|
||||||
const target = isSpam ? roleId("junk") : roleId("inbox");
|
const target = isSpam ? roleId("junk") : roleId("inbox");
|
||||||
@@ -1081,6 +1144,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 };
|
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.
|
* A folder and everything under it, with the paths they have right now.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
|
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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 { useLocation } from "wouter";
|
||||||
import { useMail, type ListState } from "@/store/mail";
|
import { useMail, type ListState } from "@/store/mail";
|
||||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||||
import type { Email, Id } from "@/jmap/types";
|
import type { Email, Id } from "@/jmap/types";
|
||||||
import { formatListDate } from "@/lib/format";
|
import { formatListDate } from "@/lib/format";
|
||||||
|
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||||
import { displayName, shortName } from "@/lib/address";
|
import { displayName, shortName } from "@/lib/address";
|
||||||
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
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]);
|
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 allSelected = ids.length > 0 && ids.every((id) => selected[id]);
|
||||||
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
|
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
|
||||||
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
|
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
|
||||||
@@ -478,6 +495,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); }} />
|
<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 />
|
<MenuSep />
|
||||||
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
<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={<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)} />
|
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
|
|||||||
Reference in New Issue
Block a user