Merge public ihasmail: the app's name comes from APP_NAME everywhere

Upstream's {app} placeholder (#406) replaces most of the fork's own
renamed strings: the user menu, the About heading and its version line
now say INBUXA because APP_NAME does, not because the fork wrote it in.

Kept from the fork: inbuxa.org rather than ihasmail.org, no Documentation
entry until INBUXA has its own, the INBUXA mark and wordmark, the "Built
on ihasmail" credit, and the About note that says nothing about the
server software. DEFAULT_APP_NAME stays INBUXA.

The credit's placeholder is {project} now, so the name of the project is
not spelled inside a key that upstream's new test reads as a hard-coded
app name.
This commit is contained in:
2026-09-19 14:29:43 -07:00
25 changed files with 367 additions and 254 deletions
+10 -6
View File
@@ -1,4 +1,5 @@
import { useSession } from "@/store/session";
import { useAppName } from "@/lib/brand";
import { client } from "@/jmap/client";
import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version";
import { withBase } from "@/lib/basePath";
@@ -6,22 +7,25 @@ import { t, tNode } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
export function AboutSettings() {
const appName = useAppName();
const session = useSession((s) => s.session);
const caps = Object.keys(session?.capabilities ?? {});
// The exact source of this build, written next to the app by the build.
return (
<div>
{/* ihasmail-inbuxa: INBUXA's webmail, built on ihasmail. The version and
source are this build's, the AGPL's offer; ihasmail keeps its credit. */}
<h1>{t("About INBUXA webmail")}</h1>
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <span className="notranslate" translate="no">INBUXA</span> })}</p>
source are this build's, the AGPL's offer; ihasmail keeps its credit.
The name comes from APP_NAME now, so a renamed deployment is named
here too. */}
<h1>{t("About {app}", { app: appName })}</h1>
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <span className="notranslate" translate="no">{appName}</span> })}</p>
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
<img src={withBase("/img/inbuxa-mark.png")} alt="" width={80} />
<div>
<InbuxaWordmark height={26} />
{/* A product name and a version string: neither is a word to translate. */}
<div style={{ fontWeight: 700 }} className="notranslate" translate="no">INBUXA webmail v{APP_VERSION}</div>
<div className="hint">{tNode("Built on {ihasmail}", { ihasmail: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">ihasmail</a> })}</div>
<div style={{ fontWeight: 700 }} className="notranslate" translate="no">{appName} webmail v{APP_VERSION}</div>
<div className="hint">{tNode("Built on {project}", { project: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">ihasmail</a> })}</div>
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={withBase(SOURCE_ARCHIVE)} target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">source.tar.gz ({SOURCE_ID})</a> })}</div>
</div>
</div>
@@ -36,7 +40,7 @@ export function AboutSettings() {
</tbody>
</table>
<p className="hint" style={{ marginTop: 6 }}>{t("This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.")}</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 the mail server; 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">{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 the mail server; 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";
@@ -201,6 +202,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);
@@ -228,7 +230,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} />