From f9720681432a61dc3cd3aeaff4c2913e1003c6d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:32:13 +0200 Subject: [PATCH] fix: localize special-folder names by JMAP role #404 --- app/(main)/[locale]/page.tsx | 10 ++++-- components/email/email-context-menu.tsx | 7 ++-- components/layout/sidebar.tsx | 5 ++- lib/__tests__/mailbox-label.test.ts | 43 ++++++++++++++++++++++++ lib/mailbox-label.ts | 44 +++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 lib/__tests__/mailbox-label.test.ts create mode 100644 lib/mailbox-label.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 542a9bd6..589d5315 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -32,6 +32,7 @@ import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navi import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; import { cn } from "@/lib/utils"; +import { localizeMailboxName } from "@/lib/mailbox-label"; import { ErrorBoundary, SidebarErrorFallback, @@ -734,7 +735,7 @@ export default function Home() { // Mailbox view const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); if (mailbox) { - const mailboxName = mailbox.name; + const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)); const unreadCount = mailbox.unreadEmails || 0; title = unreadCount > 0 ? `${mailboxName} (${unreadCount}) - ${appName}` @@ -2320,7 +2321,12 @@ export default function Home() { ? t('sidebar.scheduled') : selectedMailbox === ALL_MAIL_MAILBOX_ID ? t('sidebar.mailboxes.all_mail') - : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; + : (() => { + const mb = mailboxes.find(m => m.id === selectedMailbox); + return mb + ? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)) + : "Inbox"; + })(); const isFocusedMailLayout = mailLayout === 'focus'; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index fbcce94d..83a4b4de 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -34,6 +34,7 @@ import { XCircle, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; +import { localizeMailboxName } from "@/lib/mailbox-label"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; interface Position { @@ -143,6 +144,7 @@ export function EmailContextMenu({ onRescheduleScheduled, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); + const tSidebar = useTranslations("sidebar"); const _tColor = useTranslations("email_viewer.color_tag"); const emailKeywords = useSettingsStore((state) => state.emailKeywords); const isUnread = !email.keywords?.$seen; @@ -302,12 +304,13 @@ export function EmailContextMenu({ return nodes.map((node) => { const Icon = getMailboxIcon(node.role); const isTarget = moveTargetIds.has(node.id); + const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); return (
{isTarget ? ( handleAction(() => showBatchActions @@ -319,7 +322,7 @@ export function EmailContextMenu({ ) : (
- {node.name} + {nodeLabel}
)} {node.children.length > 0 && ( diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index be366652..c7a7a99e 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -37,6 +37,7 @@ import { MailOpen, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; +import { localizeMailboxName } from "@/lib/mailbox-label"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu"; @@ -439,12 +440,14 @@ function MailboxTreeItem({ onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void; }) { const tNotifications = useTranslations('notifications'); + const tSidebar = useTranslations('sidebar'); const hasChildren = node.children.length > 0; const isExpanded = expandedFolders.has(node.id); const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id); const isVirtualNode = node.id.startsWith('shared-'); const isSelected = selectedMailbox === node.id; const roleKey = resolveRoleKey(node.role, node.name); + const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ @@ -471,7 +474,7 @@ function MailboxTreeItem({ <> } - label={node.name} + label={label} depth={node.depth} isSelected={isSelected} isVirtual={isVirtualNode} diff --git a/lib/__tests__/mailbox-label.test.ts b/lib/__tests__/mailbox-label.test.ts new file mode 100644 index 00000000..ff4d5fa8 --- /dev/null +++ b/lib/__tests__/mailbox-label.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { localizeMailboxName } from "@/lib/mailbox-label"; + +// Stand-in translator mirroring the `sidebar.mailboxes` namespace. +const RU: Record = { + inbox: "Входящие", + sent: "Отправленные", + drafts: "Черновики", + trash: "Корзина", + archive: "Архив", + spam: "Спам", + important: "Важные", + starred: "Помечённые", + all_mail: "Вся почта", +}; +const translate = (key: string) => RU[key] ?? `MISSING:${key}`; + +describe("localizeMailboxName", () => { + it("localizes special-use folders by JMAP role, ignoring the server name", () => { + expect(localizeMailboxName("inbox", "Inbox", translate)).toBe("Входящие"); + expect(localizeMailboxName("sent", "Sent", translate)).toBe("Отправленные"); + expect(localizeMailboxName("drafts", "Drafts", translate)).toBe("Черновики"); + expect(localizeMailboxName("trash", "Deleted Items", translate)).toBe("Корзина"); + expect(localizeMailboxName("archive", "Archive", translate)).toBe("Архив"); + expect(localizeMailboxName("important", "Important", translate)).toBe("Важные"); + }); + + it("maps junk/flagged/all onto reused translation keys", () => { + expect(localizeMailboxName("junk", "Junk", translate)).toBe("Спам"); + expect(localizeMailboxName("flagged", "Flagged", translate)).toBe("Помечённые"); + expect(localizeMailboxName("all", "All Mail", translate)).toBe("Вся почта"); + }); + + it("leaves user-created folders (no role) untouched", () => { + expect(localizeMailboxName(undefined, "Projects", translate)).toBe("Projects"); + expect(localizeMailboxName(null, "Работа", translate)).toBe("Работа"); + expect(localizeMailboxName("", "Receipts", translate)).toBe("Receipts"); + }); + + it("falls back to the server name for unknown roles", () => { + expect(localizeMailboxName("subscribed", "Subscribed", translate)).toBe("Subscribed"); + }); +}); diff --git a/lib/mailbox-label.ts b/lib/mailbox-label.ts new file mode 100644 index 00000000..c6045fb3 --- /dev/null +++ b/lib/mailbox-label.ts @@ -0,0 +1,44 @@ +/** + * Localizes the display name of special-use mailboxes. + * + * Mail servers (e.g. Stalwart) always create system folders with English + * names — "Inbox", "Sent", "Junk" — regardless of the account locale. JMAP + * exposes the special-use semantics via the mailbox `role`, so we map that + * role onto a translated label (mirroring Roundcube's behaviour). Folders + * without a recognized role — i.e. user-created folders — keep their server + * name untouched. + * + * The keys live under the `sidebar.mailboxes` namespace; `junk`, `flagged` + * and `all` reuse the existing `spam`/`starred`/`all_mail` keys so every + * locale already has a translation. + */ +const ROLE_TRANSLATION_KEY: Record = { + inbox: "inbox", + sent: "sent", + drafts: "drafts", + trash: "trash", + archive: "archive", + junk: "spam", + important: "important", + flagged: "starred", + all: "all_mail", +}; + +/** + * Returns the localized name for a mailbox, or its raw server name when the + * role is unknown/absent. + * + * @param translate Resolves a `sidebar.mailboxes` leaf key (e.g. `"inbox"`) to + * its localized string. Callers scoped to the `sidebar` namespace pass + * `(k) => t(`mailboxes.${k}`)`; root-scoped callers pass + * `(k) => t(`sidebar.mailboxes.${k}`)`. + */ +export function localizeMailboxName( + role: string | undefined | null, + name: string, + translate: (key: string) => string, +): string { + if (!role) return name; + const key = ROLE_TRANSLATION_KEY[role]; + return key ? translate(key) : name; +}