Archive into a dated folder

Archiving put everything in one folder, so an Archive that has been
collecting for years is a single flat list with no way to narrow it except
search.

Archive by year and Archive by month file into Archive/2026 and
Archive/2026/09, creating the folders as needed and reusing them after
that, including folders made by hand or by another client.

The names are numeric and zero-padded rather than month names, because
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, since the name is stored and not translated. And 09 sorts
between 08 and 10 where a name does not.

The date is read in the reader's own timezone rather than UTC so it agrees
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. A message whose date cannot be read goes to
Archive itself rather than to a folder named after a guess.

A selection spanning two months is two destinations, not one. The moves
are made silently and one toast names where everything went -- the folder
where there is a single answer, the count where there is not -- because
each group raising its own toast with its own Undo would mean undoing a
third of a move. One Undo restores the whole selection to wherever each
message came from, captured before anything moved.

The menu labels name the destination where there is one, so it reads
"Archive to 2026/09" rather than describing the rule, and falls back to
"Archive by month" for a selection with no single answer.
This commit is contained in:
2026-09-01 22:06:33 -07:00
parent 34578ba426
commit 785570a41d
6 changed files with 481 additions and 1 deletions
@@ -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([]);
});
});
+84
View File
@@ -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<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 +578,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 +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 };
/**
* 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.
*