diff --git a/components/email/__tests__/mail-sort-menu.test.tsx b/components/email/__tests__/mail-sort-menu.test.tsx
new file mode 100644
index 000000000..9e6bcf679
--- /dev/null
+++ b/components/email/__tests__/mail-sort-menu.test.tsx
@@ -0,0 +1,51 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { MailSortMenu, sortChoiceFor } from '../mail-list-menus';
+
+// The mail list's Sort menu writes the "Message list order" setting, like the
+// Files header's sort menu writes its own.
+
+const fetchEmails = vi.fn().mockResolvedValue(undefined);
+
+beforeEach(() => {
+ fetchEmails.mockClear();
+ useSettingsStore.setState({ messageListOrder: [], messageListOrderScope: 'inbox' } as never);
+ useAuthStore.setState({ client: { getEmailQuerySortOptions: () => null } } as never);
+ useEmailStore.setState({ selectedMailbox: 'sent', fetchEmails } as never);
+});
+
+describe('mail list Sort menu', () => {
+ it('checks the current order', () => {
+ render();
+ fireEvent.click(screen.getByTestId('mail-sort'));
+ expect(screen.getByTestId('mail-sort-newest').getAttribute('aria-checked')).toBe('true');
+ expect(screen.getByTestId('mail-sort-oldest').getAttribute('aria-checked')).toBe('false');
+ });
+
+ it('writes the setting, makes it reach this folder, and reloads the list', () => {
+ render();
+ fireEvent.click(screen.getByTestId('mail-sort'));
+ fireEvent.click(screen.getByTestId('mail-sort-oldest'));
+ const { messageListOrder, messageListOrderScope } = useSettingsStore.getState();
+ expect(messageListOrder).toEqual([{ criterion: 'receivedAt', direction: 'asc' }]);
+ // The order applied to the Inbox only, so in Sent the pick did nothing.
+ expect(messageListOrderScope).toBe('all');
+ expect(fetchEmails).toHaveBeenCalledWith(expect.anything(), 'sent', { background: true });
+ });
+
+ it('leaves out read state and stars when the server cannot order by them', () => {
+ useAuthStore.setState({ client: { getEmailQuerySortOptions: () => ['receivedAt', 'from'] } } as never);
+ render();
+ fireEvent.click(screen.getByTestId('mail-sort'));
+ expect(screen.queryByTestId('mail-sort-unread')).toBeNull();
+ expect(screen.getByTestId('mail-sort-sender')).toBeTruthy();
+ });
+
+ it('recognises the orders Settings can make', () => {
+ expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }, { criterion: 'receivedAt', direction: 'desc' }])).toBe('unread');
+ expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }, { criterion: 'from', direction: 'asc' }])).toBeNull();
+ });
+});
diff --git a/components/email/mail-list-menus.tsx b/components/email/mail-list-menus.tsx
new file mode 100644
index 000000000..9572ef5cd
--- /dev/null
+++ b/components/email/mail-list-menus.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { ArrowUpDown, Check } from "@/components/icons";
+import { IconButton } from "@/components/ui/icon-button";
+import { ContextMenu, ContextMenuSeparator } from "@/components/ui/context-menu";
+import { useContextMenu } from "@/hooks/use-context-menu";
+import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
+import { usePolicyStore } from "@/stores/policy-store";
+import { useAuthStore } from "@/stores/auth-store";
+import { useEmailStore } from "@/stores/email-store";
+import { isDefaultOrder, sanitizeSortLevels, type SortLevel } from "@/lib/message-list-order";
+import { cn } from "@/lib/utils";
+
+/** The orders the list header offers; Settings has the full editor. */
+export const SORT_CHOICES = [
+ { key: "newest", levels: [] },
+ { key: "oldest", levels: [{ criterion: "receivedAt", direction: "asc" }] },
+ { key: "unread", levels: [{ criterion: "unread", direction: "desc" }], keyword: true },
+ { key: "starred", levels: [{ criterion: "starred", direction: "desc" }], keyword: true },
+ { key: "sender", levels: [{ criterion: "from", direction: "asc" }] },
+ { key: "subject", levels: [{ criterion: "subject", direction: "asc" }] },
+ { key: "largest", levels: [{ criterion: "size", direction: "desc" }] },
+] as const satisfies ReadonlyArray<{ key: string; levels: readonly SortLevel[]; keyword?: boolean }>;
+
+export type SortChoiceKey = (typeof SORT_CHOICES)[number]["key"];
+
+/** The choice `levels` is, or null for an order only Settings can make. */
+export function sortChoiceFor(levels: SortLevel[]): SortChoiceKey | null {
+ // A trailing newest-first level is implicit (see detectPreset).
+ const last = levels[levels.length - 1];
+ const core = levels.length > 1 && last.criterion === "receivedAt" && last.direction === "desc"
+ ? levels.slice(0, -1)
+ : levels;
+ if (isDefaultOrder(core)) return "newest";
+ if (core.length !== 1) return null;
+ const match = SORT_CHOICES.find((choice) =>
+ choice.levels.length === 1
+ && choice.levels[0].criterion === core[0].criterion
+ && choice.levels[0].direction === core[0].direction);
+ return match?.key ?? null;
+}
+
+/**
+ * Picks an order for the list. It writes the "Message list order" setting, so
+ * Settings and every device agree. That setting applies to the Inbox only
+ * unless "Apply to all folders" is on; picked in another folder, it turns
+ * that on, or the pick would do nothing there.
+ */
+export function applySortChoice(key: SortChoiceKey, folderRole: string | null | undefined): void {
+ const choice = SORT_CHOICES.find((c) => c.key === key);
+ if (!choice) return;
+ const { updateSetting, messageListOrderScope } = useSettingsStore.getState();
+ updateSetting("messageListOrder", sanitizeSortLevels(choice.levels.map((level) => ({ ...level }))));
+ if (messageListOrderScope === "inbox" && folderRole !== "inbox" && choice.levels.length > 0) {
+ updateSetting("messageListOrderScope", "all");
+ }
+ const client = useAuthStore.getState().client;
+ const store = useEmailStore.getState();
+ if (client && store.selectedMailbox) void store.fetchEmails(client, store.selectedMailbox, { background: true });
+}
+
+/** The list header's Sort button (MAIL-30), like the one in Files. */
+export function MailSortMenu({ folderRole, disabled, className }: { folderRole: string | null | undefined; disabled?: boolean; className?: string }) {
+ const t = useTranslations("email_list.sort");
+ const menu = useContextMenu();
+ const hidden = usePolicyStore((s) => s.isSettingHidden("messageListOrder"));
+ const locked = usePolicyStore((s) => s.isSettingLocked("messageListOrder"));
+ // Re-render when the order changes elsewhere (Settings, another device).
+ useSettingsStore((s) => s.messageListOrder);
+ useSettingsStore((s) => s.messageListOrderScope);
+ const client = useAuthStore((s) => s.client);
+ if (hidden) return null;
+
+ const current = sortChoiceFor(getMessageListOrderFor(folderRole));
+ // A server without hasKeyword can't order by read state or stars.
+ const sortOptions = client?.getEmailQuerySortOptions?.() ?? null;
+ const keywordSort = sortOptions === null || sortOptions.includes("hasKeyword");
+
+ return (
+ <>
+ { if (menu.contextMenu.isOpen) e.stopPropagation(); }}
+ onClick={(e) => {
+ if (menu.contextMenu.isOpen) menu.closeContextMenu();
+ else menu.openContextMenuFrom(e.currentTarget, null, "end");
+ }}
+ >
+
+
+
+ {SORT_CHOICES.filter((choice) => keywordSort || !("keyword" in choice)).map((choice) => (
+ {
+ menu.closeContextMenu();
+ applySortChoice(choice.key, folderRole);
+ }}
+ />
+ ))}
+ {current === null && (
+ <>
+
+
+ >
+ )}
+
+ >
+ );
+}
+
+/** A checkable entry of the Sort menu, as in Files'. */
+function SortItem({ label, checked, onClick, testId }: { label: string; checked: boolean; onClick: () => void; testId?: string }) {
+ return (
+
+ );
+}
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index e00201af6..fecba531f 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -81,6 +81,7 @@ import {
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { ScheduleSendDialog } from "@/components/email/schedule-send-dialog";
+import { MailSortMenu } from "@/components/email/mail-list-menus";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
@@ -4939,6 +4940,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
)}
+