Merge pull request #20 from LINUXexpert-org/empty-trash-batching

Empty a folder in batches the server will accept
This commit is contained in:
LINUXexpert.org
2026-08-24 12:57:24 -07:00
committed by GitHub
6 changed files with 228 additions and 35 deletions
+26 -1
View File
@@ -254,6 +254,30 @@ class MethodError extends Error {
} }
} }
const MAX_OBJECTS = 500;
/**
* Stalwart refuses a whole method call that carries more objects than it will
* process at once - it does not quietly handle the first 500. Enforce the same
* ceiling the session advertises, so an unbatched client fails here too.
*/
function enforceLimits(name: string, args: Obj): void {
const tooLarge = () => {
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
};
if (name.endsWith("/get")) {
const ids = args.ids as unknown[] | null | undefined;
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
}
if (name.endsWith("/set")) {
const n =
Object.keys((args.create as Obj) ?? {}).length +
Object.keys((args.update as Obj) ?? {}).length +
((args.destroy as unknown[] | undefined)?.length ?? 0);
if (n > MAX_OBJECTS) tooLarge();
}
}
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra }); const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
function genericGet(list: Obj[]) { function genericGet(list: Obj[]) {
@@ -520,7 +544,7 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
} }
const session = () => ({ const session = () => ({
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) }, capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) },
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } }, accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) }, primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
username: USER, username: USER,
@@ -596,6 +620,7 @@ export const server = createServer(async (req, res) => {
if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; } if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
try { try {
const args = resolveRefs(rawArgs, responses); const args = resolveRefs(rawArgs, responses);
enforceLimits(name, args);
const r = h(args); const r = h(args);
responses.push([name, r as Obj, id]); responses.push([name, r as Obj, id]);
if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!); if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!);
+6 -1
View File
@@ -104,6 +104,11 @@ export class JmapClient {
return core?.maxObjectsInGet ?? 500; return core?.maxObjectsInGet ?? 500;
} }
get maxObjectsInSet(): number {
const core = this.session?.capabilities[CAP.core] as { maxObjectsInSet?: number } | undefined;
return core?.maxObjectsInSet ?? 500;
}
get maxSizeUpload(): number { get maxSizeUpload(): number {
const core = this.session?.capabilities[CAP.core] as { maxSizeUpload?: number } | undefined; const core = this.session?.capabilities[CAP.core] as { maxSizeUpload?: number } | undefined;
return core?.maxSizeUpload ?? 50_000_000; return core?.maxSizeUpload ?? 50_000_000;
@@ -358,7 +363,7 @@ export function ref(resultOf: string, name: string, path: string): ResultRef {
return { resultOf, name, path }; return { resultOf, name, path };
} }
/** Chunk ids for /get calls to respect maxObjectsInGet. */ /** Chunk ids so a /get or /set call stays under the server's per-call maximum. */
export function chunk<T>(arr: T[], size: number): T[][] { export function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []; const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
@@ -0,0 +1,110 @@
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 } from "@/jmap/types";
/**
* Emptying a full folder used to back-reference one Email/query straight into
* one Email/set, so every id in the folder arrived in a single call. Stalwart
* refuses the whole call over `maxObjectsInSet` with `requestTooLarge` — a
* Deleted Items with 5192 messages in it could not be emptied at all.
*/
const TRASH = "mbTrash";
const MAX = 500;
interface Call {
name: string;
args: Record<string, unknown>;
id: string;
}
/** A server that holds `count` messages and enforces MAX objects per call. */
function server(count: number, opts: { refuseDestroy?: boolean } = {}) {
const live = new Set(Array.from({ length: count }, (_, i) => `e${i}`));
const destroyBatches: number[] = [];
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]: [string, Record<string, unknown>, string]) => {
if (name === "Email/query" && (args.filter as { inMailbox?: string })?.inMailbox === TRASH) {
const limit = Math.min((args.limit as number) ?? 50, MAX);
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids: [...live].slice(0, limit), total: live.size }, id];
}
if (name === "Email/set" && Array.isArray(args.destroy)) {
const destroy = args.destroy as string[];
destroyBatches.push(destroy.length);
if (destroy.length > MAX) return ["error", { type: "requestTooLarge", description: "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call." }, id];
if (opts.refuseDestroy) return [name, { accountId: "a1", oldState: "1", newState: "2", destroyed: [], notDestroyed: Object.fromEntries(destroy.map((x) => [x, { type: "forbidden", description: "no" }])) }, id];
for (const x of destroy) live.delete(x);
return [name, { accountId: "a1", oldState: "1", newState: "2", destroyed: destroy, notDestroyed: {} }, id];
}
// Everything the store refreshes afterwards; shape fits get and query.
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
}) as unknown as Call[];
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return { live, destroyBatches, fetchMock };
}
const messages = () => useToasts.getState().toasts.map((t) => t.message);
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: MAX, maxObjectsInSet: MAX }, [CAP.mail]: {} },
accounts: {},
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useMail.setState({ accountId: "a1", mailboxes: { [TRASH]: { id: TRASH, role: "trash", name: "Deleted Items" } } as never, list: null, emails: {} });
useToasts.setState({ toasts: [] });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("emptyMailbox", () => {
it("deletes a folder larger than maxObjectsInSet, one accepted batch at a time", async () => {
const s = server(5192);
await useMail.getState().emptyMailbox(TRASH);
expect(Math.max(...s.destroyBatches)).toBeLessThanOrEqual(MAX);
expect(s.live.size).toBe(0);
expect(messages()).toContain("Deleted 5192 messages");
});
it("needs no batching when the folder already fits in one call", async () => {
const s = server(12);
await useMail.getState().emptyMailbox(TRASH);
expect(s.destroyBatches).toEqual([12]);
expect(messages()).toContain("Deleted 12 messages");
});
it("refuses any folder that is not Deleted Items", async () => {
const s = server(5192);
useMail.setState({ mailboxes: { ...useMail.getState().mailboxes, mbJunk: { id: "mbJunk", role: "junk", name: "Junk" } } as never });
await useMail.getState().emptyMailbox("mbJunk");
expect(s.destroyBatches).toEqual([]);
expect(s.live.size).toBe(5192);
expect(messages()).toContain("Only Deleted Items can be emptied.");
});
it("stops instead of looping when the server destroys nothing", async () => {
const s = server(5192, { refuseDestroy: true });
await useMail.getState().emptyMailbox(TRASH);
expect(s.destroyBatches).toHaveLength(1);
expect(messages().some((m) => m.startsWith("Could not empty folder"))).toBe(true);
});
});
describe("destroy", () => {
it("splits a selection bigger than maxObjectsInSet across calls", async () => {
const s = server(1200);
await useMail.getState().destroy(Array.from({ length: 1200 }, (_, i) => `e${i}`));
expect(s.destroyBatches).toEqual([MAX, MAX, 200]);
expect(s.live.size).toBe(0);
expect(messages()).toContain("1200 messages deleted forever");
});
});
+78 -27
View File
@@ -11,6 +11,7 @@ import type {
MailboxRole, MailboxRole,
QueryResponse, QueryResponse,
Quota, Quota,
SetError,
SetResponse, SetResponse,
Thread, Thread,
VacationResponse, VacationResponse,
@@ -517,8 +518,8 @@ export const useMail = create<MailState>((set, get) => ({
return { emails: next, selected: {} }; return { emails: next, selected: {} };
}); });
try { try {
const res = await client.call<SetResponse>("Email/set", { accountId, destroy: ids }); const { notDestroyed } = await destroyEmails(accountId, ids);
const failed = Object.keys(res.notDestroyed ?? {}); const failed = Object.keys(notDestroyed);
if (failed.length) toast.error(`${failed.length} message(s) could not be deleted`); if (failed.length) toast.error(`${failed.length} message(s) could not be deleted`);
else toast.show(`${ids.length === 1 ? "Message" : `${ids.length} messages`} deleted forever`); else toast.show(`${ids.length === 1 ? "Message" : `${ids.length} messages`} deleted forever`);
void get().loadMailboxes(); void get().loadMailboxes();
@@ -555,19 +556,44 @@ export const useMail = create<MailState>((set, get) => ({
async emptyMailbox(mailboxId) { async emptyMailbox(mailboxId) {
const accountId = get().accountId; const accountId = get().accountId;
if (!accountId) return; if (!accountId) return;
// Emptying is permanent and covers the whole folder at once, so it is
// offered for Deleted Items alone. The menus hide it elsewhere; this is
// the guard that makes that true of the action itself.
if (mailboxId !== get().roleId("trash")) {
toast.error("Only Deleted Items can be emptied.");
return;
}
// A folder can hold far more messages than the server will destroy in one
// call, so walk it a page at a time instead of back-referencing one huge
// query into one Email/set. Each pass re-runs the filter, so the next page
// is simply whatever is still in the folder.
const page = client.maxObjectsInSet;
let deleted = 0;
let progress: number | null = null;
try { try {
const res = await client.chain([ for (;;) {
["Email/query", { accountId, filter: { inMailbox: mailboxId }, limit: 5000 }, "q"], const q = await client.call<QueryResponse>("Email/query", { accountId, filter: { inMailbox: mailboxId }, limit: page });
["Email/set", { accountId, "#destroy": { resultOf: "q", name: "Email/query", path: "/ids" } }, "s"], if (!q.ids.length) break;
]); if (progress === null && (q.total ?? q.ids.length) > page) {
const s = res.get("s")?.[0] as unknown as SetResponse; progress = toast.show("Emptying folder…", { duration: 0 });
const n = s.destroyed?.length ?? 0; }
toast.show(`Deleted ${n} message${n === 1 ? "" : "s"}`); const { destroyed, notDestroyed } = await destroyEmails(accountId, q.ids);
deleted += destroyed.length;
// Nothing went through: the rest is undeletable, and looping again
// would ask for the same ids forever.
if (!destroyed.length) {
const [, err] = Object.entries(notDestroyed)[0] ?? [];
throw new Error(err ? setErrorMessage(err) : "the server refused to delete these messages");
}
}
toast.show(`Deleted ${deleted} message${deleted === 1 ? "" : "s"}`);
set({ list: get().list ? { ...get().list!, ids: get().list!.mailboxId === mailboxId ? [] : get().list!.ids, total: 0 } : null }); set({ list: get().list ? { ...get().list!, ids: get().list!.mailboxId === mailboxId ? [] : get().list!.ids, total: 0 } : null });
} catch (err) {
toast.error(`Could not empty folder: ${(err as Error).message}${deleted ? ` (${deleted} deleted first)` : ""}`);
} finally {
if (progress !== null) toast.dismiss(progress);
void get().loadMailboxes(); void get().loadMailboxes();
void get().refreshList(); void get().refreshList();
} catch (err) {
toast.error(`Could not empty folder: ${(err as Error).message}`);
} }
}, },
@@ -590,32 +616,40 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId; const accountId = get().accountId;
if (!accountId) return; if (!accountId) return;
const boxes = includeChildren ? get().descendantMailboxIds(mailboxId) : [mailboxId]; const boxes = includeChildren ? get().descendantMailboxIds(mailboxId) : [mailboxId];
// The ids the query returns are all we need; asking Email/get to echo them
// back only risks blowing past maxObjectsInGet on a very full folder.
const page = client.maxObjectsInSet;
const unreadIn = async (filter: EmailFilter): Promise<Id[]> => { const unreadIn = async (filter: EmailFilter): Promise<Id[]> => {
const res = await client.chain([ const res = await client.call<QueryResponse>("Email/query", { accountId, filter, limit: page });
["Email/query", { accountId, filter, limit: 5000 }, "q"], return res.ids;
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: ["id"] }, "g"],
]);
return ((res.get("g")?.[0] as unknown as GetResponse<Email>).list ?? []).map((e) => e.id);
}; };
const nextUnread = async (): Promise<Id[]> => {
if (boxes.length === 1) return unreadIn({ inMailbox: boxes[0]!, notKeyword: "$seen" });
try { try {
let ids: Id[]; return await unreadIn({ operator: "AND", conditions: [{ notKeyword: "$seen" }, { operator: "OR", conditions: boxes.map((id) => ({ inMailbox: id })) }] });
if (boxes.length === 1) {
ids = await unreadIn({ inMailbox: boxes[0]!, notKeyword: "$seen" });
} else {
try {
ids = await unreadIn({ operator: "AND", conditions: [{ notKeyword: "$seen" }, { operator: "OR", conditions: boxes.map((id) => ({ inMailbox: id })) }] });
} catch { } catch {
// Server without filter-operator support: one query per folder. // Server without filter-operator support: one query per folder.
const per = await Promise.all(boxes.map((id) => unreadIn({ inMailbox: id, notKeyword: "$seen" }).catch(() => [] as Id[]))); const per = await Promise.all(boxes.map((id) => unreadIn({ inMailbox: id, notKeyword: "$seen" }).catch(() => [] as Id[])));
ids = [...new Set(per.flat())]; return [...new Set(per.flat())];
} }
};
try {
// One page per pass; the ones just marked drop out of the filter, so a
// repeated head id means the last pass changed nothing and we stop.
let marked = 0;
let lastHead: Id | null = null;
for (;;) {
const ids = await nextUnread();
if (!ids.length || ids[0] === lastHead) break;
lastHead = ids[0]!;
await get().markRead(ids, true);
marked += ids.length;
} }
if (!ids.length) { if (!marked) {
toast.show("Nothing unread here"); toast.show("Nothing unread here");
return; return;
} }
await get().markRead(ids, true); toast.success(`Marked ${marked} message${marked === 1 ? "" : "s"} as read${includeChildren && boxes.length > 1 ? ` in ${boxes.length} folders` : ""}`);
toast.success(`Marked ${ids.length} message${ids.length === 1 ? "" : "s"} as read${includeChildren && boxes.length > 1 ? ` in ${boxes.length} folders` : ""}`);
void get().loadMailboxes(); void get().loadMailboxes();
} catch (err) { } catch (err) {
toast.error(`Could not mark as read: ${(err as Error).message}`); toast.error(`Could not mark as read: ${(err as Error).message}`);
@@ -860,9 +894,26 @@ async function runQuery(accountId: Id, q: ListQuery, position: number, limit: nu
return { ids: query.ids, total: query.total ?? query.ids.length, queryState: query.queryState }; return { ids: query.ids, total: query.total ?? query.ids.length, queryState: query.queryState };
} }
/**
* Destroy emails in batches the server will accept.
*
* Handing Email/set more ids than `maxObjectsInSet` fails the whole call with
* requestTooLarge — nothing is deleted — so split first and merge the results.
*/
async function destroyEmails(accountId: Id, ids: Id[]): Promise<{ destroyed: Id[]; notDestroyed: Record<Id, SetError> }> {
const destroyed: Id[] = [];
const notDestroyed: Record<Id, SetError> = {};
for (const part of chunk(ids, client.maxObjectsInSet)) {
const res = await client.call<SetResponse>("Email/set", { accountId, destroy: part });
destroyed.push(...(res.destroyed ?? []));
Object.assign(notDestroyed, res.notDestroyed ?? {});
}
return { destroyed, notDestroyed };
}
async function setEmails(accountId: Id, update: Record<Id, Record<string, unknown>>) { async function setEmails(accountId: Id, update: Record<Id, Record<string, unknown>>) {
const ids = Object.keys(update); const ids = Object.keys(update);
for (const part of chunk(ids, 400)) { for (const part of chunk(ids, client.maxObjectsInSet)) {
const sub: Record<Id, Record<string, unknown>> = {}; const sub: Record<Id, Record<string, unknown>> = {};
for (const id of part) sub[id] = update[id]!; for (const id of part) sub[id] = update[id]!;
const res = await client.call<SetResponse>("Email/set", { accountId, update: sub }); const res = await client.call<SetResponse>("Email/set", { accountId, update: sub });
+1 -1
View File
@@ -261,7 +261,7 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox;
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} /> <MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} /> <MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} />
<MenuSep /> <MenuSep />
{(m.role === "trash" || m.role === "junk") && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />} {m.role === "trash" && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />}
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} /> <MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
</> </>
); );
+3 -1
View File
@@ -74,6 +74,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
const selCount = Object.keys(selected).length; const selCount = Object.keys(selected).length;
const mailbox = mailboxId ? mailboxes[mailboxId] : undefined; const mailbox = mailboxId ? mailboxes[mailboxId] : undefined;
const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk"; const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk";
// Emptying in one action is for Deleted Items only; Junk is cleared by hand.
const isTrash = mailbox?.role === "trash";
const isDrafts = mailbox?.role === "drafts"; const isDrafts = mailbox?.role === "drafts";
const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44; const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44;
@@ -198,7 +200,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<MenuSep /> <MenuSep />
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} /> <MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} /> <MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
{isTrashOrJunk && ( {isTrash && (
<> <>
<MenuSep /> <MenuSep />
<MenuItem <MenuItem