Call the app by its name in every sentence that names it (#406)

APP_NAME renames an instance, but only the sign-in page, the title bar and
a few headings used it. Two dozen sentences wrote "ihasmail" into
themselves, so a renamed instance still told people to keep an ihasmail
tab open and offered to open mail links "in ihasmail".

Those sentences now take the name as {app}, which also lets a translator
put it where their language wants it. brand.ts grew useAppName() for
components and currentAppName() for the few places that build strings
outside React.

Left as they are: the Files folder "ihasmail", the Sieve script
"ihasmail" and ihasmail.org. Those name things a person can go and look
at, and renaming them would rename real data.

All nine catalogues keep their translations: the name inside each one
became the placeholder. Three of the strings had no translation before
and still fall back to English.

A test walks the sources and the catalogues so a new sentence can't
hard-code the name again.
This commit is contained in:
jcoffey
2026-09-19 14:27:34 -07:00
committed by GitHub
parent 8a7ff5d42f
commit 9e3844e94a
25 changed files with 346 additions and 225 deletions
+6 -4
View File
@@ -1,4 +1,5 @@
import { useSession } from "@/store/session";
import { useAppName } from "@/lib/brand";
import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
@@ -6,16 +7,17 @@ import { withBase } from "@/lib/basePath";
import { t, tNode } from "@/lib/i18n";
export function AboutSettings() {
const appName = useAppName();
const session = useSession((s) => s.session);
const caps = Object.keys(session?.capabilities ?? {});
// A deployment running modified code should offer its own source, not ours.
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
return (
<div>
<h1>{t("About ihasmail")}</h1>
<h1>{t("About {app}", { app: appName })}</h1>
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <a href="https://stalw.art" target="_blank" rel="noreferrer">{t("Stalwart Mail Server")}</a> })}</p>
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
<img src={withBase("/img/logo.png")} alt={t("ihasmail")} width={96} />
<img src={withBase("/img/logo.png")} alt={appName} width={96} />
<div>
{/* A product name and a version string: neither is a word to translate. */}
<div style={{ fontWeight: 700, fontSize: "1.2em" }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div>
@@ -32,8 +34,8 @@ export function AboutSettings() {
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
</tbody>
</table>
<p className="hint" style={{ marginTop: 6 }}>{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.")}</p>
<p className="hint">{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: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> })}</p>
<p className="hint" style={{ marginTop: 6 }}>{t("Stalwart does not publish its version number to mail clients, so {app} reports the edition where the server gives one. {app} requires 0.16 or newer, and sign-in refuses anything older.", { app: appName })}</p>
<p className="hint">{tNode("{app}'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: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> }, { app: appName })}</p>
<h2>{t("Server capabilities")}</h2>
<div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
@@ -1,4 +1,5 @@
import { useSettings } from "@/store/settings";
import { useAppName } from "@/lib/brand";
import { PALETTES, effectiveMode, type Mode, type PaletteId } from "@/lib/palette";
import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/input/swipe";
@@ -75,6 +76,7 @@ const ACCENTS = [
];
export function AppearanceSettings() {
const appName = useAppName();
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
@@ -84,7 +86,7 @@ export function AppearanceSettings() {
return (
<div>
<h1>{translate("Appearance")}</h1>
<p className="lead">{translate("Make ihasmail yours.")}</p>
<p className="lead">{translate("Make {app} yours.", { app: appName })}</p>
<h2>{translate("Theme")}</h2>
<div className="mode-switch" role="group" aria-label={translate("Light or dark")}>
{MODES.map((m) => (
@@ -178,7 +180,7 @@ export function AppearanceSettings() {
</p>
)}
<p className="hint">
{translate("Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.")}
{translate("Only languages {app} has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.", { app: appName })}
</p>
<p className="hint">
+6 -4
View File
@@ -1,4 +1,5 @@
import { useSettings } from "@/store/settings";
import { useAppName } from "@/lib/brand";
import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast";
@@ -255,6 +256,7 @@ export function GeneralSettings() {
* it can and points at the browser's own settings for the rest.
*/
function MailHandlerSettings() {
const appName = useAppName();
const support = mailtoHandlerSupport();
const [requested, setRequested] = useState(mailtoHandlerRequested);
@@ -262,7 +264,7 @@ function MailHandlerSettings() {
try {
registerMailtoHandler();
setRequested(true);
toast.success(t("Your browser will ask whether to open mail links in ihasmail"));
toast.success(t("Your browser will ask whether to open mail links in {app}", { app: appName }));
} catch (err) {
toast.error(t("Your browser refused the request: {error}", { error: (err as Error).message }));
}
@@ -275,7 +277,7 @@ function MailHandlerSettings() {
};
if (support === "unsupported") {
return <p className="hint">{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: <code>mailto:</code> })}</p>;
return <p className="hint">{tNode("This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make {app} the default from your operating system if you install it as an app.", { scheme: <code>mailto:</code> }, { app: appName })}</p>;
}
if (support === "insecure") {
return <p className="hint">{tNode("Registering for {scheme} links requires a secure (HTTPS) connection.", { scheme: <code>mailto:</code> })}</p>;
@@ -284,7 +286,7 @@ function MailHandlerSettings() {
return (
<>
<p className="hint">
{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: <code>mailto:</code> })}
{tNode("Open {scheme} links — in web pages, documents and other apps — in {app} 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: <code>mailto:</code> }, { app: appName })}
</p>
<div className="row wrap">
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
@@ -294,7 +296,7 @@ function MailHandlerSettings() {
{!isInstalledApp() && (
<p className="hint mt-8">
{t("For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.")}
{t("For a system-wide default, install {app} as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer {app} directly wherever it asks which mail app to use.", { app: appName })}
</p>
)}
</>
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useAppName } from "@/lib/brand";
import { Plus, Trash2, Star, Eye, EyeOff } from "lucide-react";
import { useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
@@ -75,6 +76,7 @@ export function IdentitiesSettings() {
}
function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; onClose: () => void }) {
const appName = useAppName();
const [name, setName] = useState(identity.name ?? "");
const [email, setEmail] = useState(identity.email ?? "");
const [replyTo, setReplyTo] = useState(formatAddressList(identity.replyTo));
@@ -129,7 +131,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
<span className="hint">{t("Images are stored in your Files (folder “ihasmail”) and embedded when you send.")}</span>
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
</div>
{tooLong && <div className="warn-box mt-8">{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 })}</div>}
{tooLong && <div className="warn-box mt-8">{t("This signature is larger than the server's {limit}-byte limit. {app} 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, app: appName })}</div>}
</div>
</Dialog>
);
+3 -1
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useAppName } from "@/lib/brand";
import { Plus, Trash2 } from "lucide-react";
import { useSettings, type LabelVisibility } from "@/store/settings";
import { labelTree, descendantKeywords } from "@/lib/mailbox/labelTree";
@@ -8,6 +9,7 @@ import { promptDialog } from "@/ui/dialog";
import { t, tNode } from "@/lib/i18n";
export function LabelsSettings() {
const appName = useAppName();
const labels = useSettings((s) => s.settings.labels);
const update = useSettings((s) => s.update);
const [editing, setEditing] = useState<string | null>(null);
@@ -25,7 +27,7 @@ export function LabelsSettings() {
return (
<div>
<h1>{t("Labels")}</h1>
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so every other client sees them. Names, colors and nesting are ihasmail\u2019s own and follow your account. Nesting is display only \u2014 it rewrites nothing in the mailbox.")}</p>
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so every other client sees them. Names, colors and nesting are {app}\u2019s own and follow your account. Nesting is display only \u2014 it rewrites nothing in the mailbox.", { app: appName })}</p>
{labels.map((l) => (
<div key={l.keyword} className="card">
<div className="card-head">
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useAppName } from "@/lib/brand";
import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc";
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify/notify";
@@ -10,6 +11,7 @@ import { t } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
export function NotificationsSettings() {
const appName = useAppName();
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const pushConnected = useSession((st) => st.pushConnected);
@@ -37,7 +39,7 @@ export function NotificationsSettings() {
}
update({ desktopNotifications: v });
}}
label={t("Desktop notifications while ihasmail is open")}
label={t("Desktop notifications while {app} is open", { app: appName })}
hint={perm === "denied" ? t("Notifications are blocked in your browser settings.") : perm === "unsupported" ? t("Not supported in this browser.") : t("Shows a system notification when new mail arrives in your Inbox while the tab is in the background.")}
disabled={perm === "denied" || perm === "unsupported"}
/>
@@ -68,18 +70,18 @@ export function NotificationsSettings() {
setBusy(false);
}
}}
label={t("Notify me even when ihasmail is closed")}
label={t("Notify me even when {app} is closed", { app: appName })}
hint={
!canBackground
? t("Needs a browser with the Push API and a mail server that publishes a push key.")
: supportsEmailPush()
? t("Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.")
? t("Your mail server delivers these straight to your browser, so they arrive with no {app} tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.", { app: appName })
: t("Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.")
}
/>
<Switch locked={isEnforced("notificationSound")} checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label={t("Play a sound for new mail")} />
<div className="row mt-16">
<button className="btn" onClick={() => { showNotification(t("ihasmail test"), { body: t("This is what a new-mail notification looks like.") }); playNewMailSound(); }}>{t("Test notification")}</button>
<button className="btn" onClick={() => { showNotification(t("{app} test", { app: appName }), { body: t("This is what a new-mail notification looks like.") }); playNewMailSound(); }}>{t("Test notification")}</button>
</div>
<p className="hint mt-8">{t("The tab title and favicon always show your unread Inbox count.")}</p>
</div>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useAppName } from "@/lib/brand";
import { useSettings, type ReadReceiptPolicy } from "@/store/settings";
import { useMail } from "@/store/mail";
import { domainOf } from "@/lib/address";
@@ -23,6 +24,7 @@ import { isEnforced } from "@/lib/settingsPolicy";
* behaves toward the reader and toward senders.
*/
export function PrivacySettings() {
const appName = useAppName();
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const trusted = s.trustedImageSenders;
@@ -43,7 +45,7 @@ export function PrivacySettings() {
<option value="always">{t("Always show")}</option>
</select>
<p className="hint">
{t("An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.")}
{t("An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.", { app: appName })}
</p>
</div>
{trusted.length > 0 && (
+3 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useAppName } from "@/lib/brand";
import { Copy, KeyRound, ShieldCheck, Smartphone } from "lucide-react";
import { apiFetch, ApiError } from "@/jmap/client";
import { useSession } from "@/store/session";
@@ -177,6 +178,7 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
* stay — whoever is already enrolled needs a way back.
*/
function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
const appName = useAppName();
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
@@ -200,7 +202,7 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
<div>
<p className="hint" style={{ marginBottom: 12 }}>
{t("This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.")}
{t("This account has two-factor authentication on. {app} can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.", { app: appName })}
</p>
<div className="row" style={{ alignItems: "center", gap: 10 }}>
<ShieldCheck size={18} />