diff --git a/scripts/i18n-catalog-check.mjs b/scripts/i18n-catalog-check.mjs
index 30b03f5..695857a 100644
--- a/scripts/i18n-catalog-check.mjs
+++ b/scripts/i18n-catalog-check.mjs
@@ -35,6 +35,16 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
const fn = n.expression.text, a0 = n.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
+ // tc(context, source) keys the catalogue on both, joined by the same
+ // control character tc() uses. Without this the contextual entries all
+ // looked stale, which is the checker's own false alarm rather than a
+ // catalogue problem.
+ if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
+ // Only the contextual key is required. The plain one is tc()'s
+ // fallback, not a second obligation -- asking for both would report
+ // work that does not exist.
+ wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
+ }
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
for (const p of n.arguments[1].properties) {
if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text);
diff --git a/web/src/lib/__tests__/i18n.test.tsx b/web/src/lib/__tests__/i18n.test.tsx
index 24fe203..d5a64a4 100644
--- a/web/src/lib/__tests__/i18n.test.tsx
+++ b/web/src/lib/__tests__/i18n.test.tsx
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
-import { currentLanguage, interpolate, plural, setCatalog, t, tNode, type Catalog } from "@/lib/i18n";
+import { CONTEXT_SEPARATOR, currentLanguage, interpolate, plural, setCatalog, subscribeForTest, t, tc, tNode, type Catalog } from "@/lib/i18n";
const de: Catalog = {
strings: {
@@ -109,3 +109,44 @@ describe("tNode", () => {
expect(render(tNode("{count} of {scheme}", { scheme: x }, { count: 3 }))).toBe("3 of x");
});
});
+
+describe("setCatalog", () => {
+ it("does not announce a change that did not happen", () => {
+ /*
+ * The root keys its tree on the language version, so every publish
+ * remounts the app -- which re-runs the effect that loads the account's
+ * settings, which calls applyLang, which lands back in setCatalog with the
+ * same language. Publishing that non-change looped for ever, and from the
+ * outside it looked like the message list refreshing without end.
+ */
+ const seen: number[] = [];
+ const stop = subscribeForTest(() => seen.push(1));
+ const cat: Catalog = { strings: { Archive: "Archivieren" }, plurals: {} };
+ setCatalog("de", cat);
+ setCatalog("de", cat);
+ setCatalog("de", cat);
+ expect(seen.length).toBe(1);
+ setCatalog("en", { strings: {}, plurals: {} });
+ expect(seen.length).toBe(2);
+ stop();
+ });
+});
+
+describe("tc", () => {
+ it("tells apart an English word doing two jobs", () => {
+ // "Archive" is the button and the folder; German wants a different word
+ // for each, and one key cannot hold both.
+ setCatalog("de", {
+ strings: { "Archive": "Archivieren", [`folder${CONTEXT_SEPARATOR}Archive`]: "Archiv" },
+ plurals: {},
+ });
+ expect(t("Archive")).toBe("Archivieren");
+ expect(tc("folder", "Archive")).toBe("Archiv");
+ });
+
+ it("falls back to the plain translation, then to English", () => {
+ setCatalog("de", { strings: { "Drafts": "Entwürfe" }, plurals: {} });
+ expect(tc("folder", "Drafts")).toBe("Entwürfe"); // no context entry yet
+ expect(tc("folder", "Sent")).toBe("Sent"); // nothing at all
+ });
+});
diff --git a/web/src/lib/__tests__/mailboxName.test.ts b/web/src/lib/__tests__/mailboxName.test.ts
new file mode 100644
index 0000000..555065d
--- /dev/null
+++ b/web/src/lib/__tests__/mailboxName.test.ts
@@ -0,0 +1,68 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
+import { setCatalog, type Catalog } from "@/lib/i18n";
+import type { Mailbox } from "@/jmap/types";
+
+/**
+ * Stalwart names the standard folders once, at account creation, and never
+ * renames them — so a German reader on an English-provisioned account would
+ * otherwise see "Deleted Items" in an otherwise German app. The role is what
+ * lets ihasmail say "Papierkorb" without writing anything to the server.
+ */
+const de: Catalog = {
+ strings: { Inbox: "Posteingang", "Deleted Items": "Papierkorb", Drafts: "Entwürfe" },
+ plurals: {},
+};
+const mb = (id: string, name: string, role: string | null = null, parentId: string | null = null) =>
+ ({ id, name, role, parentId } as unknown as Mailbox);
+
+afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
+
+describe("mailboxDisplayName", () => {
+ it("is the server's name until a catalogue says otherwise", () => {
+ expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
+ });
+
+ it("follows the interface language for a folder carrying a role", () => {
+ setCatalog("de", de);
+ expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Papierkorb");
+ expect(mailboxDisplayName(mb("2", "Inbox", "inbox"))).toBe("Posteingang");
+ });
+
+ it("leaves a folder somebody made alone", () => {
+ // "Newsletters" is their word. Translating it would name a folder they
+ // never created, and it would not match what any other client shows.
+ setCatalog("de", de);
+ expect(mailboxDisplayName(mb("3", "Newsletters"))).toBe("Newsletters");
+ expect(mailboxDisplayName(mb("4", "Work", "subscribed"))).toBe("Work");
+ });
+
+ it("survives a missing mailbox rather than printing undefined", () => {
+ expect(mailboxDisplayName(null)).toBe("");
+ expect(mailboxDisplayName(undefined)).toBe("");
+ });
+});
+
+describe("isLocalisedName", () => {
+ it("tells an editor when the name on screen is not the server's", () => {
+ // A rename box prefilled with "Papierkorb" would rename the folder to that
+ // the moment somebody pressed Save — a real change made by accident.
+ expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
+ expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
+ expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
+ });
+});
+
+describe("mailboxDisplayPath", () => {
+ it("localises each part that has a role and leaves the rest", () => {
+ setCatalog("de", de);
+ const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
+ expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
+ });
+
+ it("stops rather than looping on a parent cycle", () => {
+ // A malformed tree from the server must not hang the folder picker.
+ const all: Record = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") };
+ expect(mailboxDisplayPath(all.a!, all)).toBe("B / A");
+ });
+});
diff --git a/web/src/lib/datetime.ts b/web/src/lib/datetime.ts
index d31d06b..7d6e15b 100644
--- a/web/src/lib/datetime.ts
+++ b/web/src/lib/datetime.ts
@@ -24,6 +24,28 @@ export interface DateTimePrefs {
const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" };
+/**
+ * The interface language, when one has been chosen over the default.
+ *
+ * Formatting and interface language are separate settings on purpose -- German
+ * dates with an English interface is a real preference. But somebody who picks
+ * German and is then shown "September" and "Monday" has not got what they
+ * asked for: choosing a language *is* a statement about language, and month
+ * names are language.
+ *
+ * So it joins the automatic chain, ahead of the server and the browser, and
+ * only while the formatting locale is left on "Automatic". Setting one
+ * explicitly still wins over everything, which is what that setting is for.
+ * English is not counted, because it is the default nobody has to choose --
+ * an English interface on a German browser should keep German dates, as it
+ * always has.
+ */
+let uiLanguage: string | null = null;
+
+export function setUiLanguageForFormatting(tag: string | null | undefined): void {
+ uiLanguage = tag && tag !== "en" ? tag : null;
+}
+
let prefs: DateTimePrefs = DEFAULT_PREFS;
let serverLocale: string | null = null;
@@ -90,9 +112,9 @@ export function normalizeLocale(raw: string | null | undefined): string | null {
}
}
-/** The locale Intl should use: explicit choice → server → browser default. */
+/** Explicit choice → chosen interface language → server → browser default. */
export function resolvedLocale(): string | undefined {
- return prefs.locale || serverLocale || undefined;
+ return prefs.locale || uiLanguage || serverLocale || undefined;
}
/** Where the effective locale came from — used to label the "Automatic" option. */
@@ -524,7 +546,7 @@ let optionsExtras = "";
* outside the generated list is still selectable).
*/
export function localeOptions(): LocaleOption[] {
- const extras = `${serverLocale ?? ""}|${prefs.locale}`;
+ const extras = `${serverLocale ?? ""}|${prefs.locale}|${uiLanguage ?? ""}`;
if (optionsCache && optionsExtras === extras) return optionsCache;
const tags = new Set(LOCALE_TAGS);
if (serverLocale) tags.add(serverLocale);
diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts
index 51af7b9..ebe4510 100644
--- a/web/src/lib/i18n.ts
+++ b/web/src/lib/i18n.ts
@@ -61,6 +61,30 @@ export function t(source: string, vars?: Vars): string {
return interpolate(current.strings[source] ?? source, vars);
}
+/**
+ * Translate where the English word is doing two jobs.
+ *
+ * English-as-key has one real weakness and this is it: "Archive" is the button
+ * that archives a message and the folder the message lands in, and German
+ * needs "Archivieren" for the first and "Archiv" for the second. One key
+ * cannot hold both. "Important" is the same — a priority tag and a folder.
+ *
+ * So a context can be given, and the lookup becomes context + source while the
+ * fallback stays the plain English. A translator sees the context and knows
+ * which sense to render; a catalogue that has not got round to it still
+ * renders the English word, which was right in English all along.
+ *
+ * The separator is a control character rather than a punctuation mark, which
+ * is the gettext convention and for the same reason: no English string can
+ * contain it by accident.
+ */
+export const CONTEXT_SEPARATOR = "\u0004";
+
+export function tc(context: string, source: string, vars?: Vars): string {
+ const keyed = current.strings[`${context}${CONTEXT_SEPARATOR}${source}`];
+ return interpolate(keyed ?? current.strings[source] ?? source, vars);
+}
+
/**
* Translate a counted thing.
*
@@ -119,6 +143,12 @@ export function tNode(source: string, parts: Record, vars?: V
return out;
}
+/** Subscribe to catalogue changes without React. Used by the tests. */
+export function subscribeForTest(fn: () => void): () => void {
+ listeners.add(fn);
+ return () => void listeners.delete(fn);
+}
+
/** The language in force, for anything that needs the tag itself. */
export function currentLanguage(): string {
return currentTag;
@@ -132,6 +162,23 @@ export function currentLanguage(): string {
* language's rules against another's forms.
*/
export function setCatalog(tag: string, catalog: Catalog): void {
+ /*
+ * Publishing only when something actually changed is not an optimisation
+ * here, it is the thing that stops an infinite loop.
+ *
+ * The root keys its tree on the language version, so a publish remounts
+ * everything. Remounting re-runs the effect that fetches the account's
+ * settings file, which calls `hydrate`, which calls `applyLang`, which lands
+ * back here -- with the identical tag and the identical catalogue. Publishing
+ * that non-change bumped the version again and went round for ever: the
+ * message list refetched on every pass, which is what it looked like from
+ * the outside.
+ *
+ * Reference equality is enough. `EMPTY` is a module constant and a
+ * dynamically imported catalogue is cached, so the same language really does
+ * hand back the same object.
+ */
+ if (currentTag === tag && current === catalog) return;
currentTag = tag;
current = catalog;
publish();
diff --git a/web/src/lib/mailboxName.ts b/web/src/lib/mailboxName.ts
new file mode 100644
index 0000000..2dbef51
--- /dev/null
+++ b/web/src/lib/mailboxName.ts
@@ -0,0 +1,79 @@
+import { tc } from "@/lib/i18n";
+import type { Mailbox } from "@/jmap/types";
+
+/**
+ * What to call a folder on screen.
+ *
+ * Stalwart names the standard folders once, when the account is created, in
+ * whatever language the server was set up in — and never renames them
+ * afterwards, because the name is stored data every other client has mapped.
+ * So a German reader on an English-provisioned account sees "Deleted Items"
+ * in an otherwise German app, and there is nothing the server can be asked to
+ * do about it: the account locale exists in `x:AccountSettings`, but writing it
+ * needs `sysAccountSettingsSet`, which the built-in user role does not carry.
+ *
+ * The role is the way out. JMAP tags the standard folders — `inbox`, `trash`,
+ * `drafts` and the rest — and ihasmail already trusts the role rather than the
+ * name everywhere it matters, so the display name can follow the interface
+ * language without anything being written to the server.
+ *
+ * Only the roles. A folder somebody made and called "Newsletters" keeps that
+ * name, because those are their words and translating them would be inventing
+ * a folder they never made.
+ *
+ * The cost, and it is real: another client on the same account still shows
+ * "Deleted Items", because that is what the folder is called. Within ihasmail
+ * this stays consistent — everything that names a folder goes through here,
+ * including the "moved to …" toast, which exists precisely so that message
+ * does not name somewhere the reader cannot find.
+ */
+/*
+ * Every one of these is translated in the "folder" context, including the
+ * unambiguous ones. Two of them genuinely need it -- "Archive" is also the
+ * button that archives, "Important" is also a priority tag, and German wants a
+ * different word for each -- and applying it to only those two would leave the
+ * next person to notice which. A context on all of them is one rule.
+ */
+const ROLE_NAMES: Record string> = {
+ inbox: () => tc("folder", "Inbox"),
+ archive: () => tc("folder", "Archive"),
+ drafts: () => tc("folder", "Drafts"),
+ sent: () => tc("folder", "Sent"),
+ trash: () => tc("folder", "Deleted Items"),
+ junk: () => tc("folder", "Junk Mail"),
+ important: () => tc("folder", "Important"),
+ all: () => tc("folder", "All mail"),
+};
+
+/** The folder's name as the reader should see it. */
+export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string {
+ if (!mailbox) return "";
+ const localised = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
+ return localised ? localised() : mailbox.name;
+}
+
+/**
+ * Whether this folder's displayed name is ihasmail's rather than the server's.
+ *
+ * Anything that *edits* the name has to know: a rename dialog prefilled with
+ * "Papierkorb" would rename the folder to that on the server the moment
+ * somebody pressed Save, which is a real change made by accident to a folder
+ * they were only looking at. Renaming a role folder is refused anyway, but
+ * relying on that would be relying on a rule enforced somewhere else.
+ */
+export function isLocalisedName(mailbox: { role?: string | null } | null | undefined): boolean {
+ return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES);
+}
+
+/** A path of folder names, for a picker that shows where a folder sits. */
+export function mailboxDisplayPath(mailbox: Mailbox, all: Record): string {
+ const parts: string[] = [];
+ let cur: Mailbox | undefined = mailbox;
+ const seen = new Set();
+ while (cur && !seen.has(cur.id)) {
+ seen.add(cur.id);
+ parts.unshift(mailboxDisplayName(cur));
+ cur = cur.parentId ? all[cur.parentId] : undefined;
+ }
+ return parts.join(" / ");
+}
diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts
index 251ca96..449e095 100644
--- a/web/src/locales/de.ts
+++ b/web/src/locales/de.ts
@@ -851,6 +851,30 @@ export const catalog: Catalog = {
"does not match regex": "entspricht regulärem Ausdruck nicht",
"exists": "ist vorhanden",
"does not exist": "ist nicht vorhanden",
+
+ // ── Folder names shown for a JMAP role (see lib/mailboxName.ts) ────
+ // Not what the server calls them: Stalwart names these once at account
+ // creation and cannot rename them afterwards. Custom folders keep the
+ // reader's own words and are never translated.
+ // Keyed with the "folder" context (see tc() in lib/i18n.ts), because two
+ // of these are also something else in English: "Archive" is the button
+ // that archives a message, and "Important" is a priority tag. German wants
+ // a different word for each, and one key cannot hold both.
+ "folder\u0004Inbox": "Posteingang",
+ "folder\u0004Archive": "Archiv",
+ "folder\u0004Drafts": "Entwürfe",
+ "folder\u0004Sent": "Gesendet",
+ "folder\u0004Deleted Items": "Papierkorb",
+ "folder\u0004Junk Mail": "Spam",
+ "folder\u0004Important": "Wichtig",
+ "folder\u0004All mail": "Alle Nachrichten",
+ "folder": "Ordner",
+ "“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
+ "“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
+ "Could not move “{name}”: {reason}": "„{name}“ konnte nicht verschoben werden: {reason}",
+ "Delete “{name}”?": "„{name}“ löschen?",
+ "Rename folder": "Ordner umbenennen",
+ "Search: {query}": "Suche: {query}",
},
plurals: {
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
diff --git a/web/src/store/mail.ts b/web/src/store/mail.ts
index fddc9a9..6b2c4fa 100644
--- a/web/src/store/mail.ts
+++ b/web/src/store/mail.ts
@@ -21,6 +21,8 @@ import type {
import { toast } from "@/ui/toast";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
+import { mailboxDisplayName } from "@/lib/mailboxName";
+import { t } from "@/lib/i18n";
/*
* Named explicitly so `shareWith` comes back, which it does not otherwise --
@@ -478,7 +480,9 @@ export const useMail = create((set, get) => ({
// moved to "Trash" or "Spam" on a server whose folders are called
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
// exist, in the one message whose job is saying where it went.
- const name = mailboxes[toMailboxId]?.name ?? opts.label ?? "folder";
+ // Through the display name, so the message names the folder the reader
+ // is looking at in the sidebar rather than the server's own word for it.
+ const name = mailboxDisplayName(mailboxes[toMailboxId]) || opts.label || t("folder");
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
action: {
label: "Undo",
diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts
index 8c767bb..80975f8 100644
--- a/web/src/store/settings.ts
+++ b/web/src/store/settings.ts
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { create } from "zustand";
import { loadJson, saveJson } from "@/lib/storage";
import { queueSettingsPush } from "@/lib/settingsSync";
-import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
+import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe";
import { resolveUiLanguage } from "@/lib/languages";
import { loadLanguage } from "@/lib/i18n";
@@ -344,6 +344,9 @@ export const useSettings = create((set, get) => ({
}));
function applyDateTimePrefs(s: Settings): void {
+ // The interface language feeds the automatic locale, so month and weekday
+ // names follow the language somebody chose rather than staying English.
+ setUiLanguageForFormatting(resolveUiLanguage(s.uiLanguage));
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
}
diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx
index 732e124..4f84828 100644
--- a/web/src/views/mail/MailView.tsx
+++ b/web/src/views/mail/MailView.tsx
@@ -17,6 +17,7 @@ import { toast } from "@/ui/toast";
import { isUnknownMailbox } from "@/lib/mailboxRoute";
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
import { plural, t as translate, tNode } from "@/lib/i18n";
+import { mailboxDisplayName } from "@/lib/mailboxName";
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
const [, navigate] = useLocation();
@@ -306,7 +307,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
[emails, mailboxId, mailboxes, openThread, openDraft],
);
- const title = search ? `Search: ${listQuery?.label ?? q}` : (mailboxId && mailboxes[mailboxId]?.name) || "Mail";
+ const title = search ? translate("Search: {query}", { query: listQuery?.label ?? q }) : (mailboxId && mailboxDisplayName(mailboxes[mailboxId])) || translate("Mail");
const reading = Boolean(threadId);
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
diff --git a/web/src/views/mail/MailboxPicker.tsx b/web/src/views/mail/MailboxPicker.tsx
index 2d92bd0..8bd9630 100644
--- a/web/src/views/mail/MailboxPicker.tsx
+++ b/web/src/views/mail/MailboxPicker.tsx
@@ -4,6 +4,7 @@ import { useMail } from "@/store/mail";
import { Dialog } from "@/ui/dialog";
import type { Id, Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
+import { mailboxDisplayPath } from "@/lib/mailboxName";
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
const mailboxes = useMail((s) => s.mailboxes);
@@ -13,7 +14,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
const list = useMemo(() => {
const all = Object.values(mailboxes)
.filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems)
- .map((m) => ({ m, path: mailboxPath(m.id) }))
+ .map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes) }))
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
const ql = q.trim().toLowerCase();
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx
index 421f6bb..d2b3de9 100644
--- a/web/src/views/mail/MailboxTree.tsx
+++ b/web/src/views/mail/MailboxTree.tsx
@@ -15,6 +15,7 @@ import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
import { haptic, useTouchRow } from "@/lib/touch";
import { t } from "@/lib/i18n";
+import { mailboxDisplayName } from "@/lib/mailboxName";
const ROLE_ICONS: Record = {
inbox: ,
@@ -63,9 +64,9 @@ export function MailboxTree() {
setExpanded(next);
saveJson("mbx-expanded", next);
}
- toast.success(parentId ? `“${m?.name}” moved into “${mailboxes[parentId]?.name}”` : `“${m?.name}” moved to the top level`);
+ toast.success(parentId ? t("“{name}” moved into “{parent}”", { name: mailboxDisplayName(m), parent: mailboxDisplayName(mailboxes[parentId]) }) : t("“{name}” moved to the top level", { name: mailboxDisplayName(m) }));
} catch (err) {
- toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`);
+ toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(m), reason: (err as Error).message }));
}
};
@@ -152,7 +153,7 @@ export function MailboxTree() {
{
- const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
+ const name = await // The server's own name, never the localised one: this box writes
+ // back whatever it is prefilled with.
+ promptDialog({ title: t("Rename folder"), defaultValue: m.name });
if (!name?.trim() || name.trim() === m.name) return;
try {
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
@@ -341,7 +344,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
}
};
const remove = async () => {
- const ok = await confirmDialog({ title: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
+ const ok = await confirmDialog({ title: t("Delete “{name}”?", { name: mailboxDisplayName(m) }), message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
if (!ok) return;
try {
await useMail.getState().destroyMailbox(m.id, true);
@@ -351,7 +354,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
toast.error((err as Error).message);
}
};
- const empty = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails });
+ const empty = () => confirmAndEmpty({ id: m.id, name: mailboxDisplayName(m), role: m.role, totalEmails: m.totalEmails });
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
const color = folderColor(colors, m.id);
const setColor = (c: string | null) => {
diff --git a/web/src/views/settings/FoldersSettings.tsx b/web/src/views/settings/FoldersSettings.tsx
index bfa238a..4e1b0ee 100644
--- a/web/src/views/settings/FoldersSettings.tsx
+++ b/web/src/views/settings/FoldersSettings.tsx
@@ -7,6 +7,7 @@ import { formatSize } from "@/lib/format";
import { ShareDialog } from "./ShareDialog";
import type { Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
+import { mailboxDisplayPath } from "@/lib/mailboxName";
export function FoldersSettings() {
const mailboxes = useMail((s) => s.mailboxes);
@@ -42,12 +43,14 @@ export function FoldersSettings() {
{list.map(({ m, path }) => (