diff --git a/components/email/__tests__/more-actions-menu.test.tsx b/components/email/__tests__/more-actions-menu.test.tsx
index 086347a38..caa6676bf 100644
--- a/components/email/__tests__/more-actions-menu.test.tsx
+++ b/components/email/__tests__/more-actions-menu.test.tsx
@@ -125,7 +125,8 @@ describe('email viewer More actions menu', () => {
flushFrames();
const subMenu = screen.getByRole('menu', { name: 'move_to' });
- tap(within(subMenu).getByRole('menuitem', { name: /Archive/ }));
+ // Named as the sidebar names it (the i18n mock renders the key).
+ tap(within(subMenu).getByRole('menuitem', { name: /mailboxes.archive/ }));
expect(onMoveToMailbox).toHaveBeenCalledWith('archive');
});
diff --git a/components/email/__tests__/move-target-list.test.tsx b/components/email/__tests__/move-target-list.test.tsx
new file mode 100644
index 000000000..a85de34bc
--- /dev/null
+++ b/components/email/__tests__/move-target-list.test.tsx
@@ -0,0 +1,36 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { MailboxNode } from '@/lib/utils';
+import { MoveTargetList } from '../move-target-list';
+
+// The reader's Move menus show the names the sidebar shows (Stalwart's
+// "Sent Items" / "Junk Mail" / "Deleted Items" are Sent, Spam and Trash, in
+// the user's language), and indent subfolders on the reading side.
+
+const node = (id: string, name: string, role: string | undefined, children: MailboxNode[] = []) =>
+ ({ id, name, role, children, depth: 0 }) as unknown as MailboxNode;
+
+const tree = [
+ node('sent', 'Sent Items', 'sent'),
+ node('junk', 'Junk Mail', 'junk'),
+ node('archive', 'Archive', 'archive', [node('y2025', '2025', undefined)]),
+];
+
+describe('MoveTargetList', () => {
+ it('shows the localized folder names and moves on a pick', () => {
+ const onPick = vi.fn();
+ render();
+ // The i18n mock returns the key: the role's name, not the server's.
+ expect(screen.queryByText('Sent Items')).toBeNull();
+ expect(screen.getByText('mailboxes.sent')).toBeTruthy();
+ fireEvent.click(screen.getByText('mailboxes.spam'));
+ expect(onPick).toHaveBeenCalledWith('junk');
+ });
+
+ it('indents subfolders with a logical padding', () => {
+ render();
+ const row = screen.getByText('2025').closest('button')!;
+ expect(row.style.paddingInlineStart).toBe('1.75rem');
+ expect(row.style.paddingLeft).toBe('');
+ });
+});
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx
index 866131191..e91de40e9 100644
--- a/components/email/email-context-menu.tsx
+++ b/components/email/email-context-menu.tsx
@@ -28,10 +28,6 @@ import {
FolderInput,
FolderX,
Tag,
- Inbox,
- Send,
- File,
- Folder,
ShieldAlert,
ShieldCheck,
EditIcon,
@@ -48,6 +44,7 @@ import { useCopyLink } from "@/hooks/use-copy-link";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { localizeMailboxName } from "@/lib/mailbox-label";
+import { moveMailboxIcon } from "./move-target-list";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
import { RulesContextSubMenu } from "./rules-menu";
@@ -133,24 +130,6 @@ interface EmailContextMenuProps {
onBatchForwardAsAttachment?: () => void;
}
-// Get mailbox icon based on role
-const getMailboxIcon = (role?: string) => {
- switch (role) {
- case "inbox":
- return Inbox;
- case "sent":
- return Send;
- case "drafts":
- return File;
- case "trash":
- return Trash2;
- case "archive":
- return Archive;
- default:
- return Folder;
- }
-};
-
export function EmailContextMenu({
email,
position,
@@ -259,7 +238,7 @@ export function EmailContextMenu({
onPick: (mailboxId: string) => void,
): React.ReactNode =>
nodes.map((node) => {
- const Icon = getMailboxIcon(node.role);
+ const Icon = moveMailboxIcon(node.role);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return (
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index fdbdfe1ed..cefae0293 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -21,7 +21,7 @@ import { SubMenuPanel } from "@/components/ui/context-menu";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
-import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
+import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
@@ -36,6 +36,7 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/
import { emailToReadView } from "@/lib/plugin-projection";
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
import { ViewSourceDialog } from "./view-source-dialog";
+import { MoveTargetList } from "./move-target-list";
import { ScheduleSendDialog } from "./schedule-send-dialog";
import {
Reply,
@@ -84,8 +85,6 @@ import {
Send,
FolderInput,
FolderX,
- Inbox,
- Folder,
Sun,
Upload,
Moon,
@@ -1645,18 +1644,6 @@ export function EmailViewer({
[mailboxes, selectedMailbox, moveOwnerAccountId],
);
- // Get mailbox icon based on role
- const getMoveMailboxIcon = (role?: string) => {
- switch (role) {
- case "inbox": return Inbox;
- case "sent": return Send;
- case "drafts": return File;
- case "trash": return Trash2;
- case "archive": return Archive;
- default: return Folder;
- }
- };
-
// Close dropdown menus on click outside
useEffect(() => {
if (!moreMenuOpen && !tagMenuOpen && !moveMenuOpen) return;
@@ -3993,39 +3980,11 @@ export function EmailViewer({
aria-label={t('move_to')}
className="absolute end-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10"
>
- {(() => {
- const renderNodes = (nodes: MailboxNode[], depth = 0) => {
- return nodes.map((node) => {
- const Icon = getMoveMailboxIcon(node.role);
- const isTarget = moveTargetIds.has(node.id);
- return (
-
- {isTarget ? (
-
- ) : (
-
-
- {node.name}
-
- )}
- {node.children.length > 0 && renderNodes(node.children, depth + 1)}
-
- );
- });
- };
- return renderNodes(moveTree);
- })()}
+
{ onMoveToMailbox(mailboxId); setMoveMenuOpen(false); }}
+ />
)}
@@ -4268,39 +4227,11 @@ export function EmailViewer({
aria-label={t('move_to')}
className="py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border"
>
- {(() => {
- const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
- return nodes.map((node) => {
- const Icon = getMoveMailboxIcon(node.role);
- const isTarget = moveTargetIds.has(node.id);
- return (
-
- {isTarget ? (
-
- ) : (
-
-
- {node.name}
-
- )}
- {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
-
- );
- });
- };
- return renderMobileNodes(moveTree);
- })()}
+ { onMoveToMailbox(mailboxId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
+ />
)}
@@ -4766,39 +4697,14 @@ export function EmailViewer({
)}
>
)}
- {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
- const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
- return nodes.map((node) => {
- const Icon = getMoveMailboxIcon(node.role);
- const isTarget = moveTargetIds.has(node.id);
- return (
-
- {isTarget ? (
-
- ) : (
-
-
- {node.name}
-
- )}
- {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
-
- );
- });
- };
- return renderMobileNodes(moveTree);
- })()}
+ {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (
+ { onMoveToMailbox(mailboxId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
+ />
+ )}
{moreMenuSub === 'tag' && (
;
+ onPick: (mailboxId: string) => void;
+ /** `menu` for a dropdown, `panel` for the phone's full-width panel (44px rows). */
+ variant?: "menu" | "panel";
+}
+
+/**
+ * The folders a message can be moved to, as the reader's Move dropdown, its ⋮
+ * and the phone panel show them: the names the sidebar shows (localized
+ * Sent, Spam, Trash), subfolders indented on the reading side.
+ */
+export function MoveTargetList({ tree, targetIds, onPick, variant = "menu" }: MoveTargetListProps) {
+ const tSidebar = useTranslations("sidebar");
+ const panel = variant === "panel";
+ const indent = (depth: number) => `${(panel ? 1 : 0.75) + depth}rem`;
+
+ const render = (nodes: MailboxNode[], depth: number): React.ReactNode =>
+ nodes.map((node) => {
+ const Icon = moveMailboxIcon(node.role);
+ const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
+ const rowClass = panel
+ ? "w-full pe-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3"
+ : "w-full pe-3 py-1.5 text-sm text-start flex items-center gap-2";
+ const icon = ;
+ return (
+
+ {targetIds.has(node.id) ? (
+
+ ) : (
+
+ {icon}
+ {label}
+
+ )}
+ {node.children.length > 0 && render(node.children, depth + 1)}
+
+ );
+ });
+
+ return <>{render(tree, 0)}>;
+}