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;
+}