Delete all spam, and call folders what the server calls them
Junk Mail can now be emptied in one action, the way every other mail
client offers it: a banner across the top of the folder, and an item in
both the folder's right-click menu and the list's own menu.
The messages are destroyed rather than moved to Deleted Items. Routing
spam through the bin on its way out leaves you with the same problem in
a different folder, and "delete all spam" means gone everywhere else. So
the dialog says it before you commit, and there is no undo.
emptyMailbox already did the hard part -- walking a folder a page at a
time so it survives maxObjectsInSet, which a Deleted Items of 5192 once
did not. All that changed is which folders it will accept. The guard
stays in the store rather than living only in the menus, so a fourth
caller cannot empty the Inbox by asking nicely.
The three entry points share one helper, because three dialogs warning
about a permanent deletion in three slightly different ways is how one
of them ends up not warning at all. A folder with nothing in it offers
the item greyed out rather than hiding it, so it is where you expect it
to be next time.
Folder naming is fixed in the same commit because it changed the same
file, and because testing this is what surfaced it. Two problems, one
cause:
- The mock called its folders "Trash" and "Sent". Stalwart's defaults
follow the Exchange convention -- "Deleted Items", "Sent Items" --
so anything built from a folder's name read differently against the
mock than against a real server, and every screenshot in the README
showed a folder list no user has.
- Worse, and in shipping code: the undo toast took a hardcoded label
in preference to the folder's actual name, so deleting a message
announced "moved to Trash" on a server whose folder is called
"Deleted Items", and reporting spam said "moved to Spam" where it is
"Junk Mail". The one message whose job is saying where mail went was
naming somewhere that does not exist. It now prefers the mailbox's
own name and keeps the hardcoded word only as a fallback.
Verified against the mock: the banner appears only in Junk and only with
something to delete, the dialog counts and pluralises, the messages are
destroyed and Deleted Items stays empty afterwards, the banner
disappears once the folder is, the item greys out when empty, Archive is
offered neither, and Trash still says "Empty Deleted Items".
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import type { MailboxRole } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Emptying destroys everything in a folder in one action, with no undo and no
|
||||
* trip through Deleted Items. Which folders may be emptied is therefore a
|
||||
* safety property, not a presentation one — the store enforces it too, and
|
||||
* these pin the half the menus decide.
|
||||
*/
|
||||
|
||||
describe("which folders may be emptied", () => {
|
||||
it("allows exactly Deleted Items and Junk Mail", () => {
|
||||
expect(canEmpty("trash")).toBe(true);
|
||||
expect(canEmpty("junk")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses folders holding mail someone meant to keep", () => {
|
||||
const keep: MailboxRole[] = ["inbox", "archive", "sent", "drafts", "all", "flagged", "important", "subscribed"];
|
||||
for (const role of keep) expect(canEmpty(role), String(role)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a plain folder, which has no role at all", () => {
|
||||
expect(canEmpty(null)).toBe(false);
|
||||
expect(canEmpty(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the action is called", () => {
|
||||
it("says what it does to spam, rather than naming the folder", () => {
|
||||
// "Delete all spam" is what this is called everywhere else; "Empty Junk
|
||||
// Mail" would be accurate and still leave people hunting for it.
|
||||
expect(emptyLabel({ name: "Junk Mail", role: "junk" })).toBe("Delete all spam");
|
||||
expect(emptyLabel({ name: "Spam", role: "junk" })).toBe("Delete all spam");
|
||||
});
|
||||
|
||||
it("names the folder for Deleted Items, whatever the server calls it", () => {
|
||||
expect(emptyLabel({ name: "Deleted Items", role: "trash" })).toBe("Empty Deleted Items");
|
||||
expect(emptyLabel({ name: "Trash", role: "trash" })).toBe("Empty Trash");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Emptying a folder, and asking first.
|
||||
*
|
||||
* There are three ways in — the folder's right-click menu, the list's own
|
||||
* menu, and the banner across the top of Junk Mail — and they must not drift
|
||||
* apart in what they warn about. A folder can only be emptied when it is one
|
||||
* whose whole purpose is holding things you did not want: Deleted Items, or
|
||||
* Junk Mail.
|
||||
*
|
||||
* The wording differs between them for a reason. Emptying Deleted Items is
|
||||
* what anyone expects it to do. Emptying Junk Mail is the surprising one: the
|
||||
* messages do not travel to Deleted Items on the way out, so there is no
|
||||
* second chance to change your mind, and the dialog says so rather than
|
||||
* leaving it to be discovered.
|
||||
*/
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { useMail } from "@/store/mail";
|
||||
import type { Id, MailboxRole } from "@/jmap/types";
|
||||
|
||||
export interface EmptyTarget {
|
||||
id: Id;
|
||||
name: string;
|
||||
role: MailboxRole;
|
||||
totalEmails: number;
|
||||
}
|
||||
|
||||
/** Whether this folder is one that may be emptied at all. */
|
||||
export function canEmpty(role: MailboxRole | undefined | null): boolean {
|
||||
return role === "trash" || role === "junk";
|
||||
}
|
||||
|
||||
const plural = (n: number) => `${n.toLocaleString()} message${n === 1 ? "" : "s"}`;
|
||||
|
||||
/** What the button or menu item is called, in the folder's own terms. */
|
||||
export function emptyLabel(target: Pick<EmptyTarget, "name" | "role">): string {
|
||||
return target.role === "junk" ? "Delete all spam" : `Empty ${target.name}`;
|
||||
}
|
||||
|
||||
/** Ask, then empty. Resolves once the emptying has been attempted, or declined. */
|
||||
export async function confirmAndEmpty(target: EmptyTarget): Promise<void> {
|
||||
if (!canEmpty(target.role)) return;
|
||||
const junk = target.role === "junk";
|
||||
const ok = await confirmDialog({
|
||||
title: junk ? `Delete all spam in “${target.name}”?` : `Empty “${target.name}”?`,
|
||||
message: junk
|
||||
? `All ${plural(target.totalEmails)} will be deleted permanently. They do not go to Deleted Items first, so this cannot be undone.`
|
||||
: `All ${plural(target.totalEmails)} will be permanently deleted.`,
|
||||
confirmLabel: junk ? "Delete all spam" : "Empty folder",
|
||||
danger: true,
|
||||
});
|
||||
if (ok) await useMail.getState().emptyMailbox(target.id);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { JmapSession } from "@/jmap/types";
|
||||
*/
|
||||
|
||||
const TRASH = "mbTrash";
|
||||
const JUNK = "mbJunk";
|
||||
const MAX = 500;
|
||||
|
||||
interface Call {
|
||||
@@ -21,13 +22,13 @@ interface Call {
|
||||
}
|
||||
|
||||
/** A server that holds `count` messages and enforces MAX objects per call. */
|
||||
function server(count: number, opts: { refuseDestroy?: boolean } = {}) {
|
||||
function server(count: number, opts: { refuseDestroy?: boolean; mailbox?: string } = {}) {
|
||||
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) {
|
||||
if (name === "Email/query" && (args.filter as { inMailbox?: string })?.inMailbox === (opts.mailbox ?? 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];
|
||||
}
|
||||
@@ -57,7 +58,16 @@ beforeEach(() => {
|
||||
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: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
mailboxes: {
|
||||
[TRASH]: { id: TRASH, role: "trash", name: "Deleted Items" },
|
||||
[JUNK]: { id: JUNK, role: "junk", name: "Junk Mail" },
|
||||
mbArchive: { id: "mbArchive", role: "archive", name: "Archive" },
|
||||
} as never,
|
||||
list: null,
|
||||
emails: {},
|
||||
});
|
||||
useToasts.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
@@ -82,13 +92,35 @@ describe("emptyMailbox", () => {
|
||||
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");
|
||||
/**
|
||||
* Junk Mail is emptiable too, and the messages are destroyed rather than
|
||||
* moved to Deleted Items — routing spam through the bin on its way out
|
||||
* would leave the user with the same problem in a different folder.
|
||||
*/
|
||||
it("empties Junk Mail, destroying rather than moving to Deleted Items", async () => {
|
||||
const s = server(1200, { mailbox: JUNK });
|
||||
await useMail.getState().emptyMailbox(JUNK);
|
||||
expect(s.live.size).toBe(0);
|
||||
expect(Math.max(...s.destroyBatches)).toBeLessThanOrEqual(MAX);
|
||||
expect(messages()).toContain("Deleted 1200 messages");
|
||||
// Nothing was moved anywhere: every mutating call was a destroy.
|
||||
const sets = s.fetchMock.mock.calls.flatMap(([, init]) => {
|
||||
const body = JSON.parse((init as RequestInit).body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
return body.methodCalls.filter(([n]) => n === "Email/set").map(([, a]) => a);
|
||||
});
|
||||
expect(sets.length).toBeGreaterThan(0);
|
||||
for (const a of sets) {
|
||||
expect(Array.isArray(a.destroy)).toBe(true);
|
||||
expect(a.update).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a folder that is neither Deleted Items nor Junk Mail", async () => {
|
||||
const s = server(5192, { mailbox: "mbArchive" });
|
||||
await useMail.getState().emptyMailbox("mbArchive");
|
||||
expect(s.destroyBatches).toEqual([]);
|
||||
expect(s.live.size).toBe(5192);
|
||||
expect(messages()).toContain("Only Deleted Items can be emptied.");
|
||||
expect(messages()).toContain("Only Deleted Items and Junk Mail can be emptied.");
|
||||
});
|
||||
|
||||
it("stops instead of looping when the server destroys nothing", async () => {
|
||||
|
||||
+17
-7
@@ -447,7 +447,12 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
try {
|
||||
await setEmails(accountId, update);
|
||||
if (!opts.silent) {
|
||||
const name = opts.label ?? mailboxes[toMailboxId]?.name ?? "folder";
|
||||
// The folder's own name, because that is what the user is looking at
|
||||
// in the sidebar. A hardcoded word here told people their mail had
|
||||
// moved to "Trash" or "Spam" on a server whose folders are called
|
||||
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
|
||||
// exist, in the one message whose job is saying where it went.
|
||||
const name = mailboxes[toMailboxId]?.name ?? opts.label ?? "folder";
|
||||
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
|
||||
action: {
|
||||
label: "Undo",
|
||||
@@ -506,7 +511,7 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
const inTrash = ids.filter((id) => (trashId && emails[id]?.mailboxIds[trashId]) || (roleId("junk") && emails[id]?.mailboxIds[roleId("junk")!]));
|
||||
const toMove = ids.filter((id) => !inTrash.includes(id));
|
||||
if (inTrash.length) await get().destroy(inTrash);
|
||||
if (toMove.length && trashId) await get().move(toMove, trashId, { label: "Trash" });
|
||||
if (toMove.length && trashId) await get().move(toMove, trashId, { label: "Deleted Items" });
|
||||
else if (toMove.length) await get().destroy(toMove);
|
||||
},
|
||||
|
||||
@@ -552,17 +557,22 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
} catch {
|
||||
/* keyword may be rejected; still move */
|
||||
}
|
||||
await get().move(ids, target, { label: isSpam ? "Spam" : "Inbox" });
|
||||
await get().move(ids, target, { label: isSpam ? "Junk Mail" : "Inbox" });
|
||||
},
|
||||
|
||||
async emptyMailbox(mailboxId) {
|
||||
const accountId = get().accountId;
|
||||
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.");
|
||||
// offered only for the two folders whose whole purpose is holding what you
|
||||
// did not want. The menus hide it elsewhere; this is the guard that makes
|
||||
// that true of the action itself, whatever calls it.
|
||||
//
|
||||
// Junk Mail is destroyed outright rather than moved to Deleted Items —
|
||||
// there is no point routing spam through the bin on its way out, and it is
|
||||
// what "delete all spam" means everywhere else. The dialogs say so.
|
||||
if (mailboxId !== get().roleId("trash") && mailboxId !== get().roleId("junk")) {
|
||||
toast.error("Only Deleted Items and Junk Mail can be emptied.");
|
||||
return;
|
||||
}
|
||||
// A folder can hold far more messages than the server will destroy in one
|
||||
|
||||
@@ -504,7 +504,7 @@ img { max-width: 100%; }
|
||||
.msg-row .msg-important { color: var(--warn); }
|
||||
.list-footer { padding: 12px; text-align: center; color: var(--fg-muted); font-size: .9em; }
|
||||
.list-hint { padding: 8px 12px; font-size: .85em; color: var(--fg-muted); background: var(--bg-sunken); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
|
||||
.list-hint button { color: var(--link); font-weight: 600; }
|
||||
.list-hint button { color: var(--link); font-weight: 600; white-space: nowrap; }
|
||||
.drag-ghost { position: fixed; top: -1000px; left: -1000px; padding: 8px 12px; background: var(--accent); color: var(--accent-fg); border-radius: 999px; font-weight: 600; box-shadow: var(--shadow-2); pointer-events: none; z-index: 5000; }
|
||||
|
||||
/* Splitter between list and reading pane */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { isScheduledMailbox } from "@/store/scheduled";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
@@ -328,10 +329,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const empty = async () => {
|
||||
const ok = await confirmDialog({ title: `Empty “${m.name}”?`, message: `All ${m.totalEmails} messages will be permanently deleted.`, confirmLabel: "Empty folder", danger: true });
|
||||
if (ok) await useMail.getState().emptyMailbox(m.id);
|
||||
};
|
||||
const empty = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails });
|
||||
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
||||
const color = folderColor(colors, m.id);
|
||||
const setColor = (c: string | null) => {
|
||||
@@ -372,7 +370,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
</div>
|
||||
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
|
||||
<MenuSep />
|
||||
{m.role === "trash" && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />}
|
||||
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,10 +6,10 @@ 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 { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { displayName, shortName } from "@/lib/address";
|
||||
import { Avatar, Empty, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
|
||||
@@ -74,8 +74,6 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
const selCount = Object.keys(selected).length;
|
||||
const mailbox = mailboxId ? mailboxes[mailboxId] : undefined;
|
||||
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 rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44;
|
||||
@@ -200,16 +198,15 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<MenuSep />
|
||||
<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} />
|
||||
{isTrash && (
|
||||
{mailbox && canEmpty(mailbox.role) && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Eraser size={16} />}
|
||||
label={`Empty ${mailbox?.name}`}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog({ title: `Empty ${mailbox?.name}?`, message: "All messages will be permanently deleted.", confirmLabel: "Empty", danger: true })) void useMail.getState().emptyMailbox(mailboxId!);
|
||||
}}
|
||||
label={emptyLabel(mailbox)}
|
||||
disabled={!mailbox.totalEmails}
|
||||
onClick={() => void confirmAndEmpty(mailbox)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -223,6 +220,23 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<button onClick={() => void doRefresh()}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Junk Mail's own banner, the way every other mail client offers it:
|
||||
clearing spam is the one thing people come to this folder to do, and
|
||||
making them find it in a menu is making them hunt for it.
|
||||
|
||||
Only here, and only with something to delete. It says "permanently"
|
||||
because that is the part worth knowing before clicking — these do not
|
||||
pass through Deleted Items on the way out.
|
||||
*/}
|
||||
{mailbox?.role === "junk" && !!mailbox.totalEmails && !selCount && (
|
||||
<div className="list-hint">
|
||||
<span className="grow">
|
||||
Deleting spam is permanent — it does not go to Deleted Items first.
|
||||
</span>
|
||||
<button onClick={() => void confirmAndEmpty(mailbox)}>Delete all spam now</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={parentRef} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1}>
|
||||
{list?.loading && ids.length === 0 ? (
|
||||
<div style={{ padding: 8 }}>
|
||||
|
||||
Reference in New Issue
Block a user