fix: localize special-folder names by JMAP role #404

This commit is contained in:
Linus Rath
2026-06-24 19:32:13 +02:00
parent 7882b254a0
commit f972068143
5 changed files with 104 additions and 5 deletions
+8 -2
View File
@@ -32,6 +32,7 @@ import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navi
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound"; import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { import {
ErrorBoundary, ErrorBoundary,
SidebarErrorFallback, SidebarErrorFallback,
@@ -734,7 +735,7 @@ export default function Home() {
// Mailbox view // Mailbox view
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
if (mailbox) { if (mailbox) {
const mailboxName = mailbox.name; const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
const unreadCount = mailbox.unreadEmails || 0; const unreadCount = mailbox.unreadEmails || 0;
title = unreadCount > 0 title = unreadCount > 0
? `${mailboxName} (${unreadCount}) - ${appName}` ? `${mailboxName} (${unreadCount}) - ${appName}`
@@ -2320,7 +2321,12 @@ export default function Home() {
? t('sidebar.scheduled') ? t('sidebar.scheduled')
: selectedMailbox === ALL_MAIL_MAILBOX_ID : selectedMailbox === ALL_MAIL_MAILBOX_ID
? t('sidebar.mailboxes.all_mail') ? 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 isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
+5 -2
View File
@@ -34,6 +34,7 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
interface Position { interface Position {
@@ -143,6 +144,7 @@ export function EmailContextMenu({
onRescheduleScheduled, onRescheduleScheduled,
}: EmailContextMenuProps) { }: EmailContextMenuProps) {
const t = useTranslations("context_menu"); const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const _tColor = useTranslations("email_viewer.color_tag"); const _tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
@@ -302,12 +304,13 @@ export function EmailContextMenu({
return nodes.map((node) => { return nodes.map((node) => {
const Icon = getMailboxIcon(node.role); const Icon = getMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id); const isTarget = moveTargetIds.has(node.id);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return ( return (
<div key={node.id}> <div key={node.id}>
{isTarget ? ( {isTarget ? (
<ContextMenuItem <ContextMenuItem
icon={Icon} icon={Icon}
label={node.name} label={nodeLabel}
onClick={() => onClick={() =>
handleAction(() => handleAction(() =>
showBatchActions showBatchActions
@@ -319,7 +322,7 @@ export function EmailContextMenu({
) : ( ) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground"> <div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" /> <Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span> <span>{nodeLabel}</span>
</div> </div>
)} )}
{node.children.length > 0 && ( {node.children.length > 0 && (
+4 -1
View File
@@ -37,6 +37,7 @@ import {
MailOpen, MailOpen,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { Mailbox } from "@/lib/jmap/types"; import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu"; import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
@@ -439,12 +440,14 @@ function MailboxTreeItem({
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void; onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
}) { }) {
const tNotifications = useTranslations('notifications'); const tNotifications = useTranslations('notifications');
const tSidebar = useTranslations('sidebar');
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id); const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id); const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
const isVirtualNode = node.id.startsWith('shared-'); const isVirtualNode = node.id.startsWith('shared-');
const isSelected = selectedMailbox === node.id; const isSelected = selectedMailbox === node.id;
const roleKey = resolveRoleKey(node.role, node.name); const roleKey = resolveRoleKey(node.role, node.name);
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
const { isDragging: globalDragging } = useDragDropContext(); const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
@@ -471,7 +474,7 @@ function MailboxTreeItem({
<> <>
<SidebarRow <SidebarRow
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />} icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
label={node.name} label={label}
depth={node.depth} depth={node.depth}
isSelected={isSelected} isSelected={isSelected}
isVirtual={isVirtualNode} isVirtual={isVirtualNode}
+43
View File
@@ -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<string, string> = {
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");
});
});
+44
View File
@@ -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<string, string> = {
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;
}