Serve ihasmail from a subpath

`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.

That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.

Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.

Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.

Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.

Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.

Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
This commit is contained in:
2026-09-01 22:41:18 -07:00
parent 34578ba426
commit 93d0a32af2
29 changed files with 579 additions and 55 deletions
+12
View File
@@ -22,6 +22,7 @@ import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlready
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
import { BASE_PATH } from "@/lib/basePath";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -83,6 +84,17 @@ export function App() {
* Reload and tab close are covered by `beforeunload` instead.
*/
<Router
/*
* The one place the mount prefix enters the router. Every `<Route path>`,
* `<Link href>` and `navigate()` in the app stays written root-absolute
* -- `/mail/:mailboxId?` -- and wouter strips the base off the address
* before matching and puts it back on when it navigates. So a deep link
* to `/mail/inbox/abc` under a `/mail` mount is `/mail/mail/inbox/abc`
* and nothing in the views has to know it.
*
* Empty is wouter's own default, so the root case is untouched.
*/
base={BASE_PATH}
aroundNav={(navigate, to, options) => {
if (!hasUnsavedChanges()) {
navigate(to, options);
+12 -4
View File
@@ -1,4 +1,5 @@
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
import { withBase } from "@/lib/basePath";
export const CAP = {
core: "urn:ietf:params:jmap:core",
@@ -62,9 +63,16 @@ export type ResultRef = { resultOf: string; name: string; path: string };
const HEADERS = { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" };
/** Generic fetch against our same-origin API with CSRF header + auth handling. */
/**
* Generic fetch against our same-origin API with CSRF header + auth handling.
*
* `path` is written root-absolute at every call site -- `/api/jmap` -- and the
* mount prefix is added here rather than there. One place to get it right, and
* the `startsWith` below keeps working on the path as written rather than on
* whatever the deployment happens to be called.
*/
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
const res = await fetch(withBase(path), {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
credentials: "same-origin",
@@ -276,12 +284,12 @@ export class JmapClient {
}
uploadUrl(accountId: Id): string {
return `/api/upload/${encodeURIComponent(accountId)}`;
return withBase(`/api/upload/${encodeURIComponent(accountId)}`);
}
downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_");
const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`;
const u = withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`);
return inline ? `${u}&inline=1` : u;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Id, StateChange } from "./types";
import { withBase } from "@/lib/basePath";
export type PushListener = (accountId: Id, type: string, newState: string) => void;
@@ -70,7 +71,7 @@ class PushManager {
private connect(): void {
if (this.stopped || this.es) return;
if (this.state !== "connected") this.setState("connecting");
const url = `/api/events?types=*&closeafter=no&ping=30`;
const url = withBase(`/api/events?types=*&closeafter=no&ping=30`);
const es = new EventSource(url, { withCredentials: true });
this.es = es;
es.onopen = () => {
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { baseUrlOf, normalizeBasePath, stripBasePath } from "../../../../scripts/basePath.mjs";
import { BASE_PATH, withBase } from "@/lib/basePath";
/**
* `BASE_PATH` is typed into a compose file or a `docker run` line by hand, and
* the four spellings below are all reasonable things for someone to write.
* The one that has to be exactly right is the empty one: every deployment that
* exists today is at the root, and this feature must be invisible to them.
*
* The canonical form is a leading slash and no trailing one, so that the
* concatenation `${base}/api/health` is correct with no branch. A trailing
* slash would make the empty case produce `//api/health`, which is not a path
* on this host but a protocol-relative URL pointing at a host called `api` --
* which is why the tests below check the joined result and not just the value.
*/
describe("normalizing what the operator wrote", () => {
it("leaves the canonical form alone", () => {
expect(normalizeBasePath("/mail")).toBe("/mail");
});
it("accepts a missing leading slash", () => {
expect(normalizeBasePath("mail")).toBe("/mail");
});
it("accepts a trailing slash", () => {
expect(normalizeBasePath("/mail/")).toBe("/mail");
expect(normalizeBasePath("mail/")).toBe("/mail");
});
it("accepts a nested mount, however it is punctuated", () => {
expect(normalizeBasePath("apps/mail")).toBe("/apps/mail");
expect(normalizeBasePath("/apps/mail/")).toBe("/apps/mail");
});
it("tidies away doubled separators and stray whitespace", () => {
expect(normalizeBasePath("//mail//")).toBe("/mail");
expect(normalizeBasePath(" /mail ")).toBe("/mail");
});
});
describe("the root, which must behave exactly as it did", () => {
it("is the empty string for every way of saying it", () => {
expect(normalizeBasePath("")).toBe("");
expect(normalizeBasePath("/")).toBe("");
expect(normalizeBasePath("///")).toBe("");
expect(normalizeBasePath(undefined)).toBe("");
expect(normalizeBasePath(null)).toBe("");
});
it("joins onto an app path without doubling the slash", () => {
// `//api/health` would be read as a protocol-relative URL and sent to a
// host called `api`. This is the assertion the whole canonical form is for.
expect(`${normalizeBasePath("/")}/api/health`).toBe("/api/health");
expect(`${normalizeBasePath("/mail")}/api/health`).toBe("/mail/api/health");
});
});
describe("the directory form Vite and the PWA scope want", () => {
it("always ends in a slash", () => {
expect(baseUrlOf("")).toBe("/");
expect(baseUrlOf("mail")).toBe("/mail/");
expect(baseUrlOf("/mail/")).toBe("/mail/");
});
});
describe("taking the prefix off an incoming request", () => {
it("passes everything through untouched at the root", () => {
expect(stripBasePath("", "/")).toBe("/");
expect(stripBasePath("", "/assets/index.js")).toBe("/assets/index.js");
expect(stripBasePath("", "/mail/inbox/abc")).toBe("/mail/inbox/abc");
});
it("strips the mount and keeps the rest", () => {
expect(stripBasePath("/mail", "/mail/assets/index.js")).toBe("/assets/index.js");
expect(stripBasePath("/mail", "/mail/api/health")).toBe("/api/health");
});
it("treats the bare mount as the app's index", () => {
// Typing the prefix without the trailing slash is how people reach it.
expect(stripBasePath("/mail", "/mail")).toBe("/");
expect(stripBasePath("/mail", "/mail/")).toBe("/");
});
it("refuses a path that merely starts with the same letters", () => {
// A plain startsWith would hand `/mailbox` the app shell, shadowing
// whatever else the proxy serves on this host.
expect(stripBasePath("/mail", "/mailbox")).toBe(null);
expect(stripBasePath("/mail", "/mailing/list")).toBe(null);
});
it("refuses anything outside the mount", () => {
expect(stripBasePath("/mail", "/")).toBe(null);
expect(stripBasePath("/mail", "/other-app/thing")).toBe(null);
});
});
describe("the browser's view of the mount", () => {
/*
* Vitest builds with Vite's default base, so this is the root deployment --
* which is the case that must not regress, and the reason these assertions
* are worth writing down rather than dismissing as trivial.
*/
it("is empty in a root build", () => {
expect(BASE_PATH).toBe("");
});
it("leaves app paths exactly as written", () => {
expect(withBase("/api/health")).toBe("/api/health");
expect(withBase("/img/logo.png")).toBe("/img/logo.png");
expect(withBase("/sw.js")).toBe("/sw.js");
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* The subpath this build is mounted at, as the browser sees it.
*
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
* normaliser -- so this is the same answer the server reached, not a second
* guess at it. Reading it here rather than re-deriving it from
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
* there is nothing in the address that says how much of it is the mount.
*
* Vite guarantees the value ends in a slash, so the only conversion is
* dropping it; `""` for the root, `/mail` otherwise, matching
* `scripts/basePath.mjs`.
*/
export const BASE_PATH: string = import.meta.env.BASE_URL.replace(/\/+$/, "");
/**
* Turn a root-absolute app path into one the server will answer.
*
* Every `/api/...`, `/img/...` and `/sw.js` in the app goes through here.
* Router paths do not: wouter is given `BASE_PATH` as its base and strips and
* re-adds the prefix itself, so `<Link href="/mail">` stays written that way.
* Mixing the two would double the prefix, which is why this asserts nothing
* and simply concatenates -- the discipline is at the call sites, and the
* callers that need it are few and all in this repo.
*/
export function withBase(path: string): string {
return BASE_PATH + path;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
import { withBase } from "@/lib/basePath";
/** Best display name for a card. */
export function contactDisplayName(c: ContactCard): string {
@@ -60,7 +61,7 @@ export function contactPhoto(c: ContactCard, accountId: string): string | null {
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
if (!m) return null;
if (m.uri) return m.uri.startsWith("data:") ? m.uri : null;
if (m.blobId) return `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`;
if (m.blobId) return withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`);
return null;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import DOMPurify from "dompurify";
import { withBase } from "@/lib/basePath";
export interface SanitizeOptions {
/** Map of Content-ID (without angle brackets) → URL for inline images. */
@@ -61,7 +62,7 @@ function hardenCss(css: string): string {
}
export function proxiedImageUrl(url: string): string {
return `/api/image?url=${encodeURIComponent(url)}`;
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
}
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
+5 -3
View File
@@ -1,3 +1,5 @@
import { withBase } from "./basePath";
let baseTitle = "ihasmail";
let faviconCanvas: HTMLCanvasElement | null = null;
let baseFavicon: HTMLImageElement | null = null;
@@ -14,13 +16,13 @@ export function setUnreadBadge(count: number): void {
if (!link) return;
if (!baseFavicon) {
baseFavicon = new Image();
baseFavicon.src = "/img/favicon-64.png";
baseFavicon.src = withBase("/img/favicon-64.png");
baseFavicon.onload = () => setUnreadBadge(count);
return;
}
if (!baseFavicon.complete) return;
if (count <= 0) {
link.href = "/img/favicon-64.png";
link.href = withBase("/img/favicon-64.png");
return;
}
faviconCanvas ??= document.createElement("canvas");
@@ -60,7 +62,7 @@ export function showNotification(title: string, opts: NotificationOptions & { on
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return;
try {
const n = new Notification(title, { icon: "/img/icon-192.png", badge: "/img/favicon-64.png", ...opts });
const n = new Notification(title, { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...opts });
n.onclick = () => {
window.focus();
opts.onClick?.();
+2 -1
View File
@@ -1,4 +1,5 @@
import { APP_VERSION } from "./version";
import { withBase } from "./basePath";
import { push, type PushState } from "@/jmap/push";
/**
@@ -71,7 +72,7 @@ export function reloadIfServerRebuilt(): Promise<boolean> {
async function check(): Promise<boolean> {
let serverVersion: string;
try {
const res = await fetch("/api/health", { credentials: "same-origin", cache: "no-store" });
const res = await fetch(withBase("/api/health"), { credentials: "same-origin", cache: "no-store" });
if (!res.ok) return false;
const body = (await res.json()) as { version?: unknown };
if (typeof body.version !== "string" || !body.version) return false;
+11 -1
View File
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import "./styles/app.css";
import { App } from "./App";
import { startBuildWatch } from "@/lib/staleBuild";
import { BASE_PATH, withBase } from "@/lib/basePath";
startBuildWatch();
@@ -14,7 +15,16 @@ createRoot(document.getElementById("root")!).render(
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {
/*
* The scope is spelled out rather than left to default to the script's own
* directory. Both come to `${BASE_PATH}/` today, but the default is a
* property of where the file happens to sit, and this is a statement about
* what the worker is allowed to control -- which under a prefix must stop
* at the mount. A worker scoped to `/` on a host shared with other
* applications would intercept their navigations too, and its offline
* fallback would answer them with ihasmail's shell.
*/
navigator.serviceWorker.register(withBase("/sw.js"), { scope: `${BASE_PATH}/` }).catch(() => {
/* ignore */
});
});
+21 -3
View File
@@ -10,6 +10,7 @@ import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n";
import { BASE_PATH } from "@/lib/basePath";
import { settings } from "./settings";
export interface ComposeAttachment {
@@ -612,6 +613,23 @@ function scheduleAutosave(key: string, get: () => ComposeState) {
);
}
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/*
* How an inline image points at a blob while it is being edited, and how to
* find one again on the way out.
*
* `client.downloadUrl` builds these, so under a subpath they carry the mount
* prefix -- and the patterns have to as well. Neither would have failed
* loudly. A bare `/api/blob/` still appears *inside* `/mail/api/blob/...`, so
* the unanchored replacement would have matched only the tail and left `/mail`
* standing in front of a `cid:` reference; the anchored match would simply
* have missed, and the message would go out linking to the sender's own
* webmail where the picture should be.
*/
const BLOB_URL_PREFIX = `${BASE_PATH}/api/blob/`;
const BLOB_URL_RE = escapeRe(BLOB_URL_PREFIX);
/** Build the JMAP Email creation object from a draft. */
export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailboxId?: Id | null }): Promise<Record<string, unknown>> {
const mail = useMail.getState();
@@ -626,7 +644,7 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb
// Inline attachments shown via blob URLs in the editor → back to cid: references.
for (const a of d.attachments) {
if (a.inline && a.cid && a.blobId && html) {
const re = new RegExp(`/api/blob/[^"' )]*${a.blobId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^"' )]*`, "g");
const re = new RegExp(`${BLOB_URL_RE}[^"' )]*${escapeRe(a.blobId)}[^"' )]*`, "g");
html = html.replace(re, `cid:${a.cid}`);
}
}
@@ -634,11 +652,11 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb
const related: EmailBodyPart[] = [];
const relatedInline: Array<{ blobId: Id; type: string; name: string; cid: string }> = [];
// Images referencing stored blobs (e.g. signature logos kept in Files) → inline cid parts.
if (html && html.includes("/api/blob/")) {
if (html && html.includes(BLOB_URL_PREFIX)) {
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img"))) {
const src = img.getAttribute("src") ?? "";
const m = /^\/api\/blob\/([^/]+)\/([^/]+)\/([^?]+)(?:\?([^#]*))?/.exec(src);
const m = new RegExp(`^${BLOB_URL_RE}([^/]+)/([^/]+)/([^?]+)(?:\\?([^#]*))?`).exec(src);
if (!m) continue;
const blobId = decodeURIComponent(m[2]!);
const name = decodeURIComponent(m[3]!);
+5 -1
View File
@@ -23,6 +23,7 @@ import { settings, useSettings } from "./settings";
import { useSession } from "./session";
import { mailboxDisplayName } from "@/lib/mailboxName";
import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath";
/*
* Named explicitly so `shareWith` comes back, which it does not otherwise --
@@ -1041,7 +1042,10 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
tag: e.id,
onClick: () => {
window.location.hash = "";
window.history.pushState({}, "", `/mail/${inbox}/${e.threadId}`);
// The one navigation that does not go through wouter -- it is
// synthesising a popstate so the router picks the address up -- so
// it is also the one that has to add the mount prefix itself.
window.history.pushState({}, "", withBase(`/mail/${inbox}/${e.threadId}`));
window.dispatchEvent(new PopStateEvent("popstate"));
},
});
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session";
import { withBase } from "@/lib/basePath";
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose";
@@ -80,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<MenuIcon size={22} />
</button>
<Link href="/mail" className="brand">
<img src="/img/logo.png" alt="" />
<img src={withBase("/img/logo.png")} alt="" />
{/* A product name, not a word. "ihasmail" translated is a different
product, and the one on the tab beside it is still called this. */}
<span className="brand-name notranslate" translate="no">
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
import { Eye, EyeOff, LogIn } from "lucide-react";
import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client";
import { withBase } from "@/lib/basePath";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
import { t } from "@/lib/i18n";
@@ -14,7 +15,7 @@ export function LoginPage() {
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
useEffect(() => {
let live = true;
fetch("/api/config")
fetch(withBase("/api/config"))
.then((r) => (r.ok ? r.json() : null))
.then((c) => { if (live && c?.sourceUrl) setSourceUrl(c.sourceUrl as string); })
.catch(() => { /* the default stands */ });
@@ -53,7 +54,7 @@ export function LoginPage() {
<div className="login-page">
<form className="login-card" onSubmit={submit}>
<div className="logo">
<img src="/img/logo.png" alt="" width={120} height={143} />
<img src={withBase("/img/logo.png")} alt="" width={120} height={143} />
<h1 className="notranslate" translate="no">ihasmail</h1>
<p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p>
</div>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearch } from "wouter";
import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
import { useSettings } from "@/store/settings";
import { withBase } from "@/lib/basePath";
import { useCompose } from "@/store/compose";
import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
import { keyboard } from "@/lib/keyboard";
@@ -353,7 +354,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
<ThreadView key={threadId} threadId={threadId} mailboxId={mailboxId ?? null} onBack={() => openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
) : (
<div className="no-thread">
<img src="/img/logo.png" alt="" />
<img src={withBase("/img/logo.png")} alt="" />
<div>{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}</div>
<div className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</div>
</div>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useSession } from "@/store/session";
import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
import { withBase } from "@/lib/basePath";
import { t, tNode } from "@/lib/i18n";
export function AboutSettings() {
@@ -14,7 +15,7 @@ export function AboutSettings() {
<h1>{t("About ihasmail")}</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="/img/logo.png" alt={t("ihasmail")} width={96} />
<img src={withBase("/img/logo.png")} alt={t("ihasmail")} 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>