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:
@@ -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,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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user