Files
ihasmail/web/src/lib/mailboxName.ts
T
jcoffey-dev 71dd2e108f Folder names follow the language, and three bugs that found
Answering "can we ask Stalwart to serve German folder names": no, and it would
not help if we could. The account locale exists in `x:AccountSettings`, and
ihasmail already reads it -- that is what "Your mail server reports German"
comes from -- but writing it needs `sysAccountSettingsSet`, which the built-in
user role does not carry; only an admin could. And even then it would change
nothing, because folder names are stored data written once when the account is
provisioned. No server renames them afterwards; every other client has them
mapped.

The role is the way through. JMAP tags the standard folders and ihasmail
already trusts the role over the name everywhere it matters, so the *displayed*
name can follow the interface language with nothing written to the server. A
folder somebody made and called "Newsletters" keeps that name: those are their
words, and translating them would name a folder they never created.

The cost is real and worth stating: Thunderbird on the same account still shows
"Deleted Items", because that is what the folder is called. Inside ihasmail it
stays consistent -- everything that names a folder goes through one function,
including the "moved to …" toast, which exists precisely so that message does
not name somewhere the reader cannot find. Renaming still edits the server's
own name, never the localised one.

Three things fell out of it.

The message list refreshed for ever after a language change, which is the one
somebody noticed. The root keys its tree on the language version, so a publish
remounts everything; remounting re-runs the effect that loads the account's
settings, which calls applyLang, which called setCatalog again -- with an
identical tag and an identical catalogue -- and publishing that non-change went
round again. setCatalog now returns early when nothing changed. Measured
rather than assumed: three consecutive five-second windows with no JMAP calls
at all, against a pre-change count that never settled.

Calendar months and weekdays stayed English, because formatting locale and
interface language are separate settings and only the first feeds Intl.
Keeping them separate is right -- German dates with an English interface is a
real preference -- but somebody who picks German and is shown "September" has
not got what they asked for. A chosen interface language now joins the
*automatic* chain ahead of the server and the browser. Setting a formatting
locale explicitly still wins, and English is not counted, so an English
interface on a German browser keeps German dates exactly as before.

And the Archive folder read "Archivieren", which is the verb. English uses one
word for the button and the folder; German does not, and neither does
"Important", which is also a priority tag. tc(context, source) keys the
catalogue on both and falls back to the plain English, which was right in
English all along -- the gettext approach, including the control character as
separator so no real string can collide.

The catalogue checker needed teaching about tc() twice: first it reported the
eight contextual entries as stale, then it asked for the plain fallbacks as
though they were a second obligation. A check that reports work which does not
exist gets switched off, which is worse than not having one.
2026-08-31 11:24:24 -07:00

80 lines
3.6 KiB
TypeScript

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, () => 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, Mailbox>): string {
const parts: string[] = [];
let cur: Mailbox | undefined = mailbox;
const seen = new Set<string>();
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
parts.unshift(mailboxDisplayName(cur));
cur = cur.parentId ? all[cur.parentId] : undefined;
}
return parts.join(" / ");
}