diff --git a/scripts/i18n-coverage.mjs b/scripts/i18n-coverage.mjs
index fc289f4..efd496d 100755
--- a/scripts/i18n-coverage.mjs
+++ b/scripts/i18n-coverage.mjs
@@ -20,6 +20,19 @@ const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hi
as dividers. Counting these as untranslated would put a floor under the
number that no amount of work could reach. */
const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
+/*
+ * Text that is deliberately not translated is not "remaining work". Counting
+ * it put a floor under the number that no amount of effort could reach -- the
+ * report sat at 21 with only 6 real items left, which makes the number
+ * something to argue with rather than act on. Same rule the codemod uses.
+ */
+const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
+const optedOut = (node, src) => {
+ const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
+ return Boolean(opening?.attributes.properties.some((a) =>
+ ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
+ a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
+};
const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__"));
const rows = [];
@@ -31,11 +44,15 @@ for (const file of files) {
let left = 0;
const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length;
const visit = (node) => {
+ if ((ts.isJsxElement(node) && CODE_TAGS.has(node.openingElement.tagName.getText(src).toLowerCase())) || optedOut(node, src)) return;
if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++;
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
- if (lit && lit.text.trim().length > 1) left++;
+ // The same prose test the text nodes get. Without it, placeholders that
+ // are format examples -- "123456" for a one-time code, "+1 555 0100" for
+ // a phone -- counted as untranslated work forever.
+ if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text.trim())) left++;
}
ts.forEachChild(node, visit);
};
diff --git a/scripts/i18n-extract.mjs b/scripts/i18n-extract.mjs
index 5985ff4..9960137 100644
--- a/scripts/i18n-extract.mjs
+++ b/scripts/i18n-extract.mjs
@@ -76,8 +76,21 @@ for (const file of files) {
const raw = c.text;
const body = raw.trim();
if (body.length < 2 || NOT_PROSE.test(body)) continue;
- // Split around an interpolation: the fragments are not sentences.
- if (meaningful.length > 1) {
+ /*
+ * "Split around an interpolation" is the dangerous case, and it is
+ * narrower than "has siblings". ` New rule` is a phrase next
+ * to an icon: wrapping it alone is correct, and refusing it left a
+ * third of the remaining work to be done by hand for no reason.
+ * `Your script “{name}” was written by hand` is the real thing --
+ * a sibling that renders text, so the fragments are not sentences.
+ */
+ const textSibling = kids.some((k) => k !== c && ts.isJsxExpression(k) && k.expression && !(() => {
+ let jsx = false;
+ const w = (n) => { if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { jsx = true; return; } ts.forEachChild(n, w); };
+ w(k.expression);
+ return jsx;
+ })());
+ if (textSibling) {
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) });
continue;
diff --git a/web/src/lib/__tests__/i18n.test.ts b/web/src/lib/__tests__/i18n.test.tsx
similarity index 65%
rename from web/src/lib/__tests__/i18n.test.ts
rename to web/src/lib/__tests__/i18n.test.tsx
index 7a68b5d..24fe203 100644
--- a/web/src/lib/__tests__/i18n.test.ts
+++ b/web/src/lib/__tests__/i18n.test.tsx
@@ -1,8 +1,15 @@
import { afterEach, describe, expect, it } from "vitest";
-import { currentLanguage, interpolate, plural, setCatalog, t, type Catalog } from "@/lib/i18n";
+import { renderToStaticMarkup } from "react-dom/server";
+import { currentLanguage, interpolate, plural, setCatalog, t, tNode, type Catalog } from "@/lib/i18n";
const de: Catalog = {
- strings: { "Archive": "Archivieren", "Move {n} to {folder}": "{n} nach {folder} verschieben" },
+ strings: {
+ "Archive": "Archivieren",
+ "Move {n} to {folder}": "{n} nach {folder} verschieben",
+ // German puts the parts in a different order, which is the whole reason
+ // the element is a named hole rather than a split sentence.
+ "Open {scheme} links here": "{scheme}-Links hier öffnen",
+ },
plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } },
};
/* Russian is the reason plural() does not take (one, other): it needs three
@@ -77,3 +84,28 @@ describe("plural", () => {
.toBe("2 messages in Inbox");
});
});
+
+describe("tNode", () => {
+ const render = (node: React.ReactNode) => renderToStaticMarkup(<>{node}>);
+
+ it("keeps an element inside the sentence", () => {
+ expect(render(tNode("Open {scheme} links here", { scheme: mailto: })))
+ .toBe("Open mailto: links here");
+ });
+
+ it("lets a translator move the element", () => {
+ // Splitting the sentence into two t() calls could not do this: the
+ // fragments would render in the English order whatever the catalogue said.
+ setCatalog("de", de);
+ expect(render(tNode("Open {scheme} links here", { scheme: mailto: })))
+ .toBe("mailto:-Links hier öffnen");
+ });
+
+ it("leaves a placeholder alone when nothing is supplied for it", () => {
+ expect(render(tNode("Open {scheme} links here", {}))).toBe("Open {scheme} links here");
+ });
+
+ it("takes plain variables alongside elements", () => {
+ expect(render(tNode("{count} of {scheme}", { scheme: x }, { count: 3 }))).toBe("3 of x");
+ });
+});
diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts
index c33a899..51af7b9 100644
--- a/web/src/lib/i18n.ts
+++ b/web/src/lib/i18n.ts
@@ -1,4 +1,4 @@
-import { useSyncExternalStore } from "react";
+import { createElement, Fragment, useSyncExternalStore, type ReactNode } from "react";
import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages";
/**
@@ -83,6 +83,42 @@ export function plural(n: number, forms: PluralForms, vars?: Vars): string {
return interpolate(entry[category] ?? entry.other, { n, ...vars });
}
+/**
+ * A translated sentence with elements inside it.
+ *
+ * Some sentences have a `` or a `` in the middle of them, and the
+ * two obvious approaches are both wrong. Splitting the sentence into two `t()`
+ * calls hands a translator "This browser cannot register apps for" and "links,
+ * in particular…", which are not sentences and cannot be reordered into a
+ * language that puts the verb somewhere else. Dropping the element and
+ * interpolating plain text keeps the sentence whole but loses the monospace
+ * that told the reader it was a literal.
+ *
+ * So the sentence stays whole and the elements are placeholders in it:
+ *
+ * tNode("Open {scheme} links in ihasmail.", { scheme: mailto: })
+ *
+ * A translator sees one sentence with a named hole and can put the hole
+ * wherever their language wants it.
+ */
+export function tNode(source: string, parts: Record, vars?: Vars): ReactNode {
+ const translated = interpolate(current.strings[source] ?? source, vars);
+ const out: ReactNode[] = [];
+ let last = 0;
+ const re = /\{(\w+)\}/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(translated))) {
+ if (!Object.prototype.hasOwnProperty.call(parts, m[1]!)) continue;
+ if (m.index > last) out.push(translated.slice(last, m.index));
+ // Keyed, because this is an array and React asks; the index is stable for
+ // a given rendering of a given sentence.
+ out.push(createElement(Fragment, { key: `${m[1]}-${m.index}` }, parts[m[1]!]));
+ last = m.index + m[0].length;
+ }
+ if (last < translated.length) out.push(translated.slice(last));
+ return out;
+}
+
/** The language in force, for anything that needs the tag itself. */
export function currentLanguage(): string {
return currentTag;
diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx
index 42c1243..3a0eb7b 100644
--- a/web/src/views/AppShell.tsx
+++ b/web/src/views/AppShell.tsx
@@ -168,19 +168,23 @@ export function AppShell({ children }: { children: ReactNode }) {
>
@@ -209,7 +213,7 @@ function QuotaBar() {
- {formatSize(q.used)} of {formatSize(q.hardLimit)}
+ {t("{used} of {total}", { used: formatSize(q.used), total: formatSize(q.hardLimit) })}
diff --git a/web/src/views/SearchBar.tsx b/web/src/views/SearchBar.tsx
index 4440f62..ac77141 100644
--- a/web/src/views/SearchBar.tsx
+++ b/web/src/views/SearchBar.tsx
@@ -85,8 +85,8 @@ export function SearchBar() {
- The message waits on the server, so it goes out whether or not ihasmail is open.
- {maxMs > 0 && ` This server holds a message for up to ${describeSpan(maxMs)}.`}
+ {`${t("The message waits on the server, so it goes out whether or not ihasmail is open.")}${maxMs > 0 ? ` ${t("This server holds a message for up to {span}.", { span: describeSpan(maxMs) })}` : ""}`}
diff --git a/web/src/views/contacts/ContactsSidebar.tsx b/web/src/views/contacts/ContactsSidebar.tsx
index fd9c3ca..f922962 100644
--- a/web/src/views/contacts/ContactsSidebar.tsx
+++ b/web/src/views/contacts/ContactsSidebar.tsx
@@ -170,10 +170,10 @@ export function ContactsSidebar() {
{/* Import and export lived in the pane this replaced. */}
-
+
diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx
index 377fb64..62731b9 100644
--- a/web/src/views/contacts/ContactsView.tsx
+++ b/web/src/views/contacts/ContactsView.tsx
@@ -166,8 +166,8 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
{narrow && }
-
-
+
+
@@ -223,13 +223,13 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
diff --git a/web/src/views/mail/FilterFromMessage.tsx b/web/src/views/mail/FilterFromMessage.tsx
index df654ac..de92438 100644
--- a/web/src/views/mail/FilterFromMessage.tsx
+++ b/web/src/views/mail/FilterFromMessage.tsx
@@ -45,9 +45,9 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
looking for a problem they do not have.
*/}
{damage ? (
-
Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.
+
{t("Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.", { damage })}
) : loaded ? (
-
Your active Sieve script was written by hand, so rules can't be added automatically. Open {t("Settings → Filters & rules")} to edit the script or switch to managed rules.
+
{t("Your active Sieve script was written by hand, so rules can't be added automatically. Open")} {t("Settings → Filters & rules")} {t("to edit the script or switch to managed rules.")}
) : (
{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}
)}
diff --git a/web/src/views/mail/InviteCard.tsx b/web/src/views/mail/InviteCard.tsx
index b49bdc9..16c3ebc 100644
--- a/web/src/views/mail/InviteCard.tsx
+++ b/web/src/views/mail/InviteCard.tsx
@@ -91,7 +91,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
- The sender asked for a read receipt.
+
+ {translate("The sender asked for a read receipt.")}
{receipt.redirected && (
- <> It would go to {receipt.to!.email}, which is not where the message came from.>
+ <> {translate("It would go to")} {receipt.to!.email}{translate(", which is not where the message came from.")}>
)}
)}
{icsPart && }
@@ -543,15 +545,15 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
})}
{attachments.length > 1 && (
{ for (const a of attachments) { if (!a.blobId) continue; const l = document.createElement("a"); l.href = client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type); l.download = a.name ?? ""; l.click(); } }}>
- Download all
+ {translate("Download all")}
)}
{t("Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.")}
-
ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {t("v2026.8.30+pr129")} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — +g1fa6578. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.
+
{tNode("ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.", { example: v2026.8.30+pr129, sha: +g1fa6578 })}
- {translate("ihasmail")} is the palette from {translate("ihasmail.org")}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
+ {translate("ihasmail")} {translate("is the palette from")} {translate("ihasmail.org")}{translate(", and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.")}
- This is separate from {translate("Language & region")} in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.
+
+ {translate("This is separate from")} {translate("Language & region")} {translate("in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.")}
{translate("Swiping")}
diff --git a/web/src/views/settings/CalendarSettings.tsx b/web/src/views/settings/CalendarSettings.tsx
index 0cb2c50..8e36656 100644
--- a/web/src/views/settings/CalendarSettings.tsx
+++ b/web/src/views/settings/CalendarSettings.tsx
@@ -59,7 +59,7 @@ export function CalendarSettings() {
update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} />
@@ -88,7 +88,7 @@ function RulesEditor() {
return (
{t("Only part of your filter script arrived.")}
-
It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.
+
{t("It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.", { damage })}
window.location.reload()}>{t("Reload")}
);
@@ -97,16 +97,16 @@ function RulesEditor() {
if (rules === null) {
return (
-
Your active script “{script?.name}” was written by hand.
-
The visual rule editor only manages scripts it created. You can edit the script in the {t("Scripts")} tab, or start fresh with rules (the existing script will be kept but deactivated).
- { if (await confirmDialog({ title: "Switch to rules?", message: `“${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>{t("Start with rules")}
+
{t("Your active script “{name}” was written by hand.", { name: script?.name ?? "" })}
+
{tNode("The visual rule editor only manages scripts it created. You can edit the script in the {tab} tab, or start fresh with rules (the existing script will be kept but deactivated).", { tab: {t("Scripts")} })}
+ { if (await confirmDialog({ title: t("Switch to rules?"), message: t("“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.", { name: script?.name ?? "" }), confirmLabel: t("Continue") })) void save([]); }}>{t("Start with rules")}
);
}
return (
- {activeIsOther &&
Another script (“{script?.name}”) is active. Saving rules here will activate the “ihasmail” script instead.
}
+ {activeIsOther &&
{t("Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.", { name: script?.name ?? "" })}
}
{list.length === 0 &&
{t("No filters yet")}
{t("Create a rule to move newsletters to a folder, flag important senders, or forward mail.")}
{serverLocale ? `Your mail server reports ${localeLabel(serverLocale)} (${serverLocale}).` : "Your mail server does not report a locale, so the browser's is used."} Dates, times and month names follow this choice.
+
{`${serverLocale ? t("Your mail server reports {name} ({tag}).", { name: localeLabel(serverLocale), tag: serverLocale }) : t("Your mail server does not report a locale, so the browser's is used.")} ${t("Dates, times and month names follow this choice.")}`}
@@ -167,13 +167,13 @@ export function GeneralSettings() {
update({ timeFormat: e.target.value as typeof s.timeFormat })}>
-
+
@@ -182,8 +182,8 @@ export function GeneralSettings() {
{ const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>{t("Export settings")} { reset(); toast.show("Settings reset to defaults"); }}>{t("Reset to defaults")}
@@ -217,17 +217,16 @@ function MailHandlerSettings() {
};
if (support === "unsupported") {
- return
This browser cannot register apps for mailto: links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.
;
+ return
{tNode("This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.", { scheme: mailto: })}
;
}
if (support === "insecure") {
- return
Registering for mailto: links requires a secure (HTTPS) connection.
;
+ return
{tNode("Registering for {scheme} links requires a secure (HTTPS) connection.", { scheme: mailto: })}
;
}
return (
<>
- Open mailto: links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client.
- Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).
+ {tNode("Open {scheme} links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).", { scheme: mailto: })}
{requested ? "Ask again" : "Make ihasmail the default mail app"}
diff --git a/web/src/views/settings/IdentitiesSettings.tsx b/web/src/views/settings/IdentitiesSettings.tsx
index fbe3e1a..d596f65 100644
--- a/web/src/views/settings/IdentitiesSettings.tsx
+++ b/web/src/views/settings/IdentitiesSettings.tsx
@@ -38,7 +38,7 @@ export function IdentitiesSettings() {
{i.id !== defaultId && (
- { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}> Make default
+ { e.stopPropagation(); setDefault(i.id); toast.success(t("{email} is now your default identity", { email: i.email })); }}> {t("Make default")}
)}
{/*
Hiding is presentation only -- the identity still exists and still
@@ -51,7 +51,7 @@ export function IdentitiesSettings() {
title={isAlwaysVisible(i.id, [defaultId]) ? "The default identity is always offered when composing" : hidden.includes(i.id) ? "Show this in the compose picker" : "Hide this from the compose picker"}
onClick={(e) => { e.stopPropagation(); toggleHidden(i.id); }}
>
- {hidden.includes(i.id) ? <> Show when composing> : <> Hide when composing>}
+ {hidden.includes(i.id) ? <> {t("Show when composing")}> : <> {t("Hide when composing")}>}
{i.mayDelete && (
{ e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}>
@@ -59,10 +59,10 @@ export function IdentitiesSettings() {
{hidden.includes(i.id) &&
{t("Not offered when composing. It still receives mail, and you can still send from it by showing it again.")}
{t("New identities must use an address this account is allowed to send from (aliases configured on the server).")}
{hidden.length > 0 && (
@@ -129,7 +129,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial; on
{t("Images are stored in your Files (folder “ihasmail”) and embedded when you send.")}{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}
- {tooLong &&
This signature is larger than the server's {SIGNATURE_LIMIT}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.
}
+ {tooLong &&
{t("This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.", { limit: SIGNATURE_LIMIT })}
}
);
diff --git a/web/src/views/settings/LabelsSettings.tsx b/web/src/views/settings/LabelsSettings.tsx
index 3c48e5c..a0574cf 100644
--- a/web/src/views/settings/LabelsSettings.tsx
+++ b/web/src/views/settings/LabelsSettings.tsx
@@ -38,8 +38,8 @@ export function LabelsSettings() {
))}
- void add()}> New label
-
Tip: press l on a conversation to apply labels. Search with label:name.
+ void add()}> {t("New label")}
+
{t("Tip: press")} l {t("on a conversation to apply labels. Search with")} label:name.
You're signed in as {session?.username}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.
+
{t("You're signed in as")} {session?.username}{t(". Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.")}