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
@@ -12,6 +12,18 @@ APP_SECRET=change-me
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8080 PORT=8080
# Serve the app from a subpath instead of the domain root, for a reverse proxy
# that maps https://example.com/mail/ here. Leave it unset for the root, which
# is what every deployment gets unless it asks otherwise. "/mail", "mail" and
# "/mail/" all mean the same thing.
#
# The prefix must reach ihasmail intact -- do not strip it in the proxy -- and
# it has to be set for the *build* as well as the run: the web bundle writes
# its own asset URLs, so a build that does not know the prefix produces an app
# that cannot load itself under one. With Docker that means
# `--build-arg BASE_PATH=/mail` alongside `-e BASE_PATH=/mail`.
# BASE_PATH=/mail
# Set to "1" when running behind a TLS-terminating reverse proxy (trusts # Set to "1" when running behind a TLS-terminating reverse proxy (trusts
# X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev. # X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev.
TRUST_PROXY=1 TRUST_PROXY=1
+22 -2
View File
@@ -7,6 +7,15 @@ FROM node:22-alpine AS build
# Left empty, the build falls back to the base version from package.json. # Left empty, the build falls back to the base version from package.json.
ARG IHASMAIL_VERSION="" ARG IHASMAIL_VERSION=""
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
# The subpath the app will be served from, e.g. /mail. Empty -- the default --
# is the domain root and is what every deployment gets unless it asks
# otherwise. Unlike the rest of ihasmail's configuration this cannot wait for
# the process to start: the web build writes its own asset URLs into
# index.html, so a build that does not know the prefix produces a shell that
# cannot load itself under one. It is therefore a build argument here and an
# environment variable in the runtime stage, from the same value.
ARG BASE_PATH=""
ENV BASE_PATH=$BASE_PATH
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
COPY server/package.json server/ COPY server/package.json server/
@@ -19,12 +28,14 @@ RUN npm run build
FROM node:22-alpine AS runtime FROM node:22-alpine AS runtime
# Re-declared: an ARG does not cross stages. # Re-declared: an ARG does not cross stages.
ARG IHASMAIL_VERSION="" ARG IHASMAIL_VERSION=""
ARG BASE_PATH=""
ENV NODE_ENV=production \ ENV NODE_ENV=production \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=8080 \ PORT=8080 \
STATIC_DIR=/app/web/dist \ STATIC_DIR=/app/web/dist \
SESSION_FILE=/data/sessions.json \ SESSION_FILE=/data/sessions.json \
IHASMAIL_VERSION=$IHASMAIL_VERSION IHASMAIL_VERSION=$IHASMAIL_VERSION \
BASE_PATH=$BASE_PATH
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
COPY server/package.json server/ COPY server/package.json server/
@@ -47,5 +58,14 @@ USER node
# that want the sessions to survive say so themselves: docker-compose.yml and # that want the sessions to survive say so themselves: docker-compose.yml and
# deploy.example.sh both mount a *named* volume at /data, which is unaffected. # deploy.example.sh both mount a *named* volume at /data, which is unaffected.
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1 # Shell form, so $BASE_PATH is expanded by the container rather than baked in
# empty at build time: the health endpoint moves with the mount.
#
# The two substitutions repeat, in sh, what scripts/basePath.mjs does in
# JavaScript -- drop a trailing slash, add a leading one -- because this runs
# before there is a Node process to ask. It is worth the duplication: an
# operator who writes BASE_PATH=mail/ gets a working server, and without this
# a healthcheck that says the working server is unhealthy and has Docker
# restart it forever.
HEALTHCHECK --interval=30s --timeout=5s CMD BP="${BASE_PATH%/}"; case "$BP" in ""|/*) ;; *) BP="/$BP";; esac; wget -qO- "http://127.0.0.1:8080$BP/api/health" || exit 1
CMD ["node", "server/dist/index.js"] CMD ["node", "server/dist/index.js"]
+38 -1
View File
@@ -954,6 +954,7 @@ wizard, because either would be state.
| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` | | `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` |
| `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it | | `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it |
| `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address | | `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address |
| `BASE_PATH` | — (the domain root) | Subpath to serve from, e.g. `/mail`. Must be set for the **build** as well as the run — see below |
| `TRUST_PROXY` | `1` | Believe `X-Forwarded-*` | | `TRUST_PROXY` | `1` | Believe `X-Forwarded-*` |
| `TRUSTED_PROXIES` | loopback + private ranges | Which peers to believe | | `TRUSTED_PROXIES` | loopback + private ranges | Which peers to believe |
| `SECURE_COOKIES` | `auto` | `Secure` when the request arrived over HTTPS; `1`/`0` to force | | `SECURE_COOKIES` | `auto` | `Secure` when the request arrived over HTTPS; `1`/`0` to force |
@@ -973,6 +974,41 @@ Full documentation, including TLS and reverse proxies:
[Configuring](https://docs.ihasmail.org/configure/). `Caddyfile.example` and [Configuring](https://docs.ihasmail.org/configure/). `Caddyfile.example` and
`nginx.example.conf` are in the repository. `nginx.example.conf` are in the repository.
### Serving from a subpath
`BASE_PATH` mounts the whole app under a prefix, for a host that is not
ihasmail's alone:
```bash
docker build --build-arg BASE_PATH=/mail -t ihasmail .
docker run -e BASE_PATH=/mail ... ihasmail
```
`/mail`, `mail` and `/mail/` all mean the same mount; unset means the domain
root, which is exactly what it has always been. Everything moves together —
`/mail/api/health`, every deep link, the icons, the manifest, the service
worker's scope and the session cookie's `Path`.
Two things are worth knowing before you reach for it.
**The prefix must arrive intact.** Point the proxy at ihasmail without
stripping it: `proxy_pass http://127.0.0.1:8080;` with no trailing slash in
nginx, `reverse_proxy` without a `uri strip_prefix` in Caddy. A proxy that
strips the prefix is talking to an app at the root, and should be paired with
no `BASE_PATH` at all.
**It is baked in at build time, not only at run time.** This is the one setting
that cannot wait for the process to start: the web bundle writes its own
`<script src>` into `index.html` when it is built, so a build that does not
know the prefix produces a shell that cannot load itself under one. Hence the
`--build-arg` above. Get it wrong and the page comes up blank — so the server
checks the built shell against its own `BASE_PATH` at the first request and
says so in the log rather than leaving you with an empty page and a 404.
The manifest and the service worker need neither: a manifest's URLs resolve
against the manifest's own address, and the worker's own address tells it where
it was mounted. Both follow the prefix with nothing substituted into them.
## Rebranding ## Rebranding
`APP_NAME` and `SOURCE_URL` are variables; the logo, icons and palette are `APP_NAME` and `SOURCE_URL` are variables; the logo, icons and palette are
@@ -984,7 +1020,8 @@ and in Settings About.
## Operations ## Operations
- **`GET /api/health`** answers name, version and `ok`, and is what the - **`GET /api/health`** answers name, version and `ok`, and is what the
container health check uses. container health check uses. Under a `BASE_PATH` it moves with everything
else, to `/mail/api/health`; the image's health check follows it.
- **Versions** read `2026.8.30+pr129`: the date of the commit the build came - **Versions** read `2026.8.30+pr129`: the date of the commit the build came
from, and the pull request it arrived through (or `+g<sha>` for one that did from, and the pull request it arrived through (or `+g<sha>` for one that did
not). It comes from git at build time; nothing writes a version into the tree. not). It comes from git at build time; nothing writes a version into the tree.
+8 -1
View File
@@ -1,6 +1,12 @@
services: services:
ihasmail: ihasmail:
build: . build:
context: .
args:
# Passed to the build as well as the run because the web bundle writes
# its own asset URLs: a build that does not know the prefix produces an
# app that cannot load itself under one. Empty is the domain root.
BASE_PATH: ${BASE_PATH:-}
image: ihasmail:2 image: ihasmail:2
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -9,6 +15,7 @@ services:
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env} STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)} APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail} APP_NAME: ${APP_NAME:-ihasmail}
BASE_PATH: ${BASE_PATH:-}
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail} SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
TRUST_PROXY: "1" TRUST_PROXY: "1"
IMAGE_PROXY: "1" IMAGE_PROXY: "1"
+4
View File
@@ -0,0 +1,4 @@
/** Types for `basePath.mjs`, which is plain JS so both packages can import it. */
export function normalizeBasePath(value: string | undefined | null): string;
export function baseUrlOf(basePath: string | undefined | null): string;
export function stripBasePath(basePath: string | undefined | null, pathname: string): string | null;
+70
View File
@@ -0,0 +1,70 @@
/**
* The subpath ihasmail is mounted at, from `BASE_PATH`.
*
* Plain JS, and here rather than in either package, because both halves of the
* app have to agree on the answer: `web/vite.config.ts` bakes it into the built
* asset URLs and `server/src/config.ts` reads it again to decide where the
* routes live. Two implementations of "what does /mail/ mean" is exactly the
* bug where the server serves an app whose own script tags point somewhere
* else, and the page comes up blank with no clue why.
*
* The canonical form is a leading slash and no trailing one -- `/mail` -- with
* the empty string for the root. Empty is the ordinary case and it is chosen
* so that the concatenation `${base}/api/health` is right without a branch:
* anything with a trailing slash would need one, and every caller that forgot
* would produce `//api/health`, which browsers read as a *protocol-relative
* URL* and send to a host called `api`. Getting that wrong once, quietly, in
* one call site is worse than the small awkwardness of an empty string.
*/
/**
* Reduce whatever the operator wrote to the canonical form.
*
* Accepts `/mail`, `mail`, `/mail/`, `mail/`, `//mail//`, an empty string and
* undefined, because the variable is typed by a human into a compose file or a
* `docker run` line and every one of those is a reasonable thing to write.
* Being strict here would mean an instance that refuses to start over a
* trailing slash, which teaches nobody anything.
*
* A value of `/` means the root and is returned as empty, since `/` and `""`
* describe the same mount and only one of them can be the canonical one.
*/
export function normalizeBasePath(value) {
if (typeof value !== "string") return "";
// Collapse repeated separators before trimming: `//mail//` is a typo, not a
// path with empty segments in it, and `path.posix.normalize` is not
// available to the browser bundle that also uses this.
const trimmed = value.trim().replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
if (!trimmed) return "";
return `/${trimmed}`;
}
/**
* The same value as a directory URL -- `/` or `/mail/`.
*
* This is the form Vite's `base` and the PWA scope want, both of which are
* about "the directory the app lives in" rather than a path to join onto.
*/
export function baseUrlOf(basePath) {
return `${normalizeBasePath(basePath)}/`;
}
/**
* Whether `pathname` falls inside the mount, and what is left of it if so.
*
* Returns null for anything outside, so a caller can 404 rather than guess.
* The bare mount with no trailing slash -- a request for `/mail` -- yields
* `/`, because that is the app's own index and typing the prefix without the
* slash is how people reach it.
*
* The comparison is deliberately not `startsWith(base)`: that would let
* `/mailbox` in under a `/mail` mount and serve it the app shell, which is
* both wrong and a small open door for a neighbouring site on the same host.
*/
export function stripBasePath(basePath, pathname) {
const base = normalizeBasePath(basePath);
if (!base) return pathname;
if (pathname === base) return "/";
if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
return null;
}
+28 -7
View File
@@ -116,12 +116,28 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
await next(); await next();
}; };
/**
* Scope the session cookie to the mount, not the whole host.
*
* Under a prefix the browser is talking to a hostname that other applications
* share, and a cookie at `/` would be sent to every one of them. Path scoping
* is not a security boundary -- anything on the origin can reach the cookie
* jar -- but it keeps the credential out of requests that have no business
* carrying it, and it lets two ihasmail instances live at `/mail` and
* `/mail2` on one host without signing each other out, which a shared cookie
* name at `/` would do.
*
* `/` for the root case: an empty Path is not the same thing and browsers
* would fall back to the directory of the request that set it.
*/
const cookiePath = config.basePath || "/";
function setSessionCookie(c: Context, value: string, remember: boolean) { function setSessionCookie(c: Context, value: string, remember: boolean) {
setCookie(c, config.cookieName, value, { setCookie(c, config.cookieName, value, {
httpOnly: true, httpOnly: true,
sameSite: "Lax", sameSite: "Lax",
secure: isSecureRequest(c), secure: isSecureRequest(c),
path: "/", path: cookiePath,
...(remember ? { maxAge: config.sessionRememberTtl } : {}), ...(remember ? { maxAge: config.sessionRememberTtl } : {}),
}); });
} }
@@ -138,7 +154,12 @@ function upstreamFailure(c: Context, err: unknown) {
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502); return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
} }
export function createApp(): Hono<Env> { /**
* `basePath` is a parameter rather than read straight from the config so the
* tests can mount the same app twice, at the root and under a prefix, without
* re-importing the module to change one environment variable.
*/
export function createApp(basePath = config.basePath): Hono<Env> {
const app = new Hono<Env>(); const app = new Hono<Env>();
app.use("*", securityHeaders); app.use("*", securityHeaders);
@@ -247,7 +268,7 @@ export function createApp(): Hono<Env> {
} catch (err) { } catch (err) {
if (err instanceof UpstreamError && err.status === 401) { if (err instanceof UpstreamError && err.status === 401) {
sessions.destroy(session.id); sessions.destroy(session.id);
deleteCookie(c, config.cookieName, { path: "/" }); deleteCookie(c, config.cookieName, { path: cookiePath });
} }
return upstreamFailure(c, err); return upstreamFailure(c, err);
} }
@@ -260,7 +281,7 @@ export function createApp(): Hono<Env> {
sessions.destroy(session.id); sessions.destroy(session.id);
forgetUpstreamSession(session.id); forgetUpstreamSession(session.id);
} }
deleteCookie(c, config.cookieName, { path: "/" }); deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ ok: true }); return c.json({ ok: true });
}); });
@@ -473,7 +494,7 @@ export function createApp(): Hono<Env> {
if (res.status === 401) { if (res.status === 401) {
sessions.destroy(session.id); sessions.destroy(session.id);
forgetUpstreamSession(session.id); forgetUpstreamSession(session.id);
deleteCookie(c, config.cookieName, { path: "/" }); deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ error: "unauthenticated" }, 401); return c.json({ error: "unauthenticated" }, 401);
} }
return passthrough(res); return passthrough(res);
@@ -599,10 +620,10 @@ export function createApp(): Hono<Env> {
return c.json({ error: "internal_error" }, 500); return c.json({ error: "internal_error" }, 500);
}); });
app.route("/api", api); app.route(`${basePath}/api`, api);
// ---------- Static SPA ---------- // ---------- Static SPA ----------
app.get("*", staticHandler(config.staticDir)); app.get("*", staticHandler(config.staticDir, basePath));
return app; return app;
} }
+63
View File
@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
/**
* `BASE_PATH` is read once, into `config`, so these mount the app by argument
* instead of re-importing the module with a different environment. The
* root-mounted half is the one that matters most: every instance in existence
* is at `/`, and this feature has to be invisible to them.
*/
test("at the root, the API is exactly where it was", async () => {
const app = createApp("");
const res = await app.request("/api/health");
assert.equal(res.status, 200);
});
test("under a prefix, the API moves with it", async () => {
const app = createApp("/mail");
const res = await app.request("/mail/api/health");
assert.equal(res.status, 200);
const body = (await res.json()) as { ok?: boolean };
assert.equal(body.ok, true);
});
test("under a prefix, the unprefixed API is gone", async () => {
// Not merely unrouted: a proxy that forwards without the prefix, against a
// server told to expect one, would otherwise appear to half-work -- the API
// answering while the app shell it belongs to 404s.
const app = createApp("/mail");
const res = await app.request("/api/health");
assert.equal(res.status, 404);
});
/*
* Whether a route reached the static handler, without depending on there being
* a web build in the tree. With one it serves the index; without one it says
* the build is missing. Either is proof the request got that far -- a routing
* mistake is the 404, and asserting on 200 or 503 would make these tests pass
* or fail on whether somebody had run `npm run build` first.
*/
const reachedTheApp = (status: number) => status === 200 || status === 503;
test("a deep SPA route under the prefix reaches the static handler", async () => {
const app = createApp("/mail");
const res = await app.request("/mail/calendar/week/2026-09-01");
assert.ok(reachedTheApp(res.status), `expected the app shell, got ${res.status}`);
});
test("a path that only shares the prefix's letters is not the app", async () => {
// `/mailbox` under a `/mail` mount belongs to whatever else the proxy
// serves on this host; answering it with our shell would shadow it.
const app = createApp("/mail");
assert.equal((await app.request("/mailbox")).status, 404);
assert.equal((await app.request("/")).status, 404);
});
test("the root mount still serves the SPA from the root", async () => {
const app = createApp("");
assert.ok(reachedTheApp((await app.request("/calendar/week/2026-09-01")).status));
assert.ok(reachedTheApp((await app.request("/")).status));
});
+14
View File
@@ -1,4 +1,5 @@
import { resolveVersion } from "../../scripts/version.mjs"; import { resolveVersion } from "../../scripts/version.mjs";
import { normalizeBasePath } from "../../scripts/basePath.mjs";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
@@ -130,6 +131,19 @@ export const config = {
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"), sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
host: env("HOST", "0.0.0.0"), host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080), port: int("PORT", 8080),
/**
* The subpath this instance answers on: `/mail` for a proxy that maps
* `https://example.com/mail/` here, and `""` -- the default -- for the root.
*
* The prefix is expected to arrive intact: a proxy that strips it before
* forwarding should leave BASE_PATH unset, because then as far as this
* process is concerned it *is* at the root. What must match is the web
* build, which bakes the same variable into its asset URLs; a server that
* strips a prefix the bundle still asks for serves an app that cannot load
* its own scripts. `staticHandler` says so at the first request rather than
* leaving a blank page to explain itself.
*/
basePath: normalizeBasePath(process.env.BASE_PATH),
stalwartUrl, stalwartUrl,
appSecret, appSecret,
trustProxy: bool("TRUST_PROXY", true), trustProxy: bool("TRUST_PROXY", true),
+35 -2
View File
@@ -3,6 +3,7 @@ import { stat, readFile } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path"; import { extname, join, normalize, resolve, sep } from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import type { Context, Handler } from "hono"; import type { Context, Handler } from "hono";
import { stripBasePath } from "../../scripts/basePath.mjs";
const MIME: Record<string, string> = { const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8", ".html": "text/html; charset=utf-8",
@@ -47,9 +48,30 @@ export const APP_CSP = [
"manifest-src 'self'", "manifest-src 'self'",
].join("; "); ].join("; ");
export function staticHandler(root: string): Handler { export function staticHandler(root: string, basePath = ""): Handler {
const absRoot = resolve(root); const absRoot = resolve(root);
let indexCache: { body: string; mtime: number } | null = null; let indexCache: { body: string; mtime: number } | null = null;
let mismatchWarned = false;
/**
* A build that does not know the prefix loads nothing under it, and says so
* with a blank page and a 404 in a console nobody has open. The shell is
* already being read here, so checking what it asks for costs one substring
* search per rebuild and turns a mystery into a line in the log.
*
* A warning rather than a refusal: this reads a built artefact to guess at a
* misconfiguration, and a wrong guess that stops the server from starting is
* worse than the problem it is describing.
*/
function warnOnBaseMismatch(body: string) {
if (mismatchWarned || !basePath) return;
if (body.includes(`src="${basePath}/assets/`)) return;
mismatchWarned = true;
console.warn(
`[ihasmail] BASE_PATH is ${basePath}, but the web build in ${absRoot} references its assets elsewhere. ` +
`The prefix is baked in at build time: rebuild with BASE_PATH=${basePath} set, or the app will not load.`,
);
}
async function serveIndex(c: Context) { async function serveIndex(c: Context) {
try { try {
@@ -57,7 +79,9 @@ export function staticHandler(root: string): Handler {
const st = await stat(p); const st = await stat(p);
if (!indexCache || indexCache.mtime !== st.mtimeMs) { if (!indexCache || indexCache.mtime !== st.mtimeMs) {
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs }; indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
mismatchWarned = false;
} }
warnOnBaseMismatch(indexCache.body);
c.header("Content-Type", "text/html; charset=utf-8"); c.header("Content-Type", "text/html; charset=utf-8");
c.header("Cache-Control", "no-cache"); c.header("Cache-Control", "no-cache");
c.header("Content-Security-Policy", APP_CSP); c.header("Content-Security-Policy", APP_CSP);
@@ -70,7 +94,16 @@ export function staticHandler(root: string): Handler {
return async (c) => { return async (c) => {
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405); if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405);
const urlPath = decodeURIComponent(new URL(c.req.url).pathname); /*
* Everything below works in paths relative to the mount, so the prefix
* comes off once, here. Anything outside it is a 404 and not the app
* shell: under `/mail` this process shares a hostname with whatever else
* the proxy serves, and answering `/` or `/other-app/thing` with our
* index would shadow a neighbour rather than let it 404 honestly.
*/
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
const urlPath = stripBasePath(basePath, fullPath);
if (urlPath === null) return c.text("Not Found", 404);
if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c); if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c);
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, ""); const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
const filePath = join(absRoot, rel); const filePath = join(absRoot, rel);
+10 -9
View File
@@ -2,12 +2,13 @@
"name": "ihasmail", "name": "ihasmail",
"short_name": "ihasmail", "short_name": "ihasmail",
"description": "Fast, friendly JMAP webmail for Stalwart", "description": "Fast, friendly JMAP webmail for Stalwart",
"start_url": "/mail", "_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
"scope": "/", "start_url": "mail",
"scope": "./",
"protocol_handlers": [ "protocol_handlers": [
{ {
"protocol": "mailto", "protocol": "mailto",
"url": "/mail?mailto=%s" "url": "mail?mailto=%s"
} }
], ],
"display": "standalone", "display": "standalone",
@@ -16,17 +17,17 @@
"theme_color": "#0f766e", "theme_color": "#0f766e",
"icons": [ "icons": [
{ {
"src": "/img/icon-192.png", "src": "img/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/img/icon-512.png", "src": "img/icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/img/icon-maskable.png", "src": "img/icon-maskable.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
@@ -35,16 +36,16 @@
"shortcuts": [ "shortcuts": [
{ {
"name": "Compose", "name": "Compose",
"url": "/mail?compose=new", "url": "mail?compose=new",
"description": "Write a new message" "description": "Write a new message"
}, },
{ {
"name": "Calendar", "name": "Calendar",
"url": "/calendar" "url": "calendar"
}, },
{ {
"name": "Contacts", "name": "Contacts",
"url": "/contacts" "url": "contacts"
} }
] ]
} }
+30 -11
View File
@@ -3,7 +3,22 @@
are never cached), and Web Push, which is the only part of ihasmail that runs are never cached), and Web Push, which is the only part of ihasmail that runs
when no tab is open. */ when no tab is open. */
const VERSION = "ihasmail-v2"; const VERSION = "ihasmail-v2";
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
/*
* The mount, worked out rather than configured.
*
* This file is copied to the build verbatim -- Vite's `base` never touches
* public/ -- so there is nothing to substitute BASE_PATH into. It does not
* need one: the worker is served from the mount, so its own address says
* where that is. `/mail/sw.js` gives `/mail`, `/sw.js` gives `""`, which is
* the same canonical form the rest of the app uses.
*
* Deriving it here also means the worker cannot disagree with the page that
* registered it, which a second copy of the value in a build-time constant
* eventually would.
*/
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
@@ -20,10 +35,10 @@ self.addEventListener("fetch", (event) => {
if (req.method !== "GET") return; if (req.method !== "GET") return;
const url = new URL(req.url); const url = new URL(req.url);
if (url.origin !== self.location.origin) return; if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return; if (url.pathname.startsWith(`${BASE}/api/`)) return;
// Hashed build assets: cache-first. // Hashed build assets: cache-first.
if (url.pathname.startsWith("/assets/")) { if (url.pathname.startsWith(`${BASE}/assets/`)) {
event.respondWith( event.respondWith(
caches.match(req).then((hit) => hit || fetch(req).then((res) => { caches.match(req).then((hit) => hit || fetch(req).then((res) => {
const copy = res.clone(); const copy = res.clone();
@@ -36,7 +51,7 @@ self.addEventListener("fetch", (event) => {
// Navigations & everything else: network-first, fall back to cached shell. // Navigations & everything else: network-first, fall back to cached shell.
if (req.mode === "navigate") { if (req.mode === "navigate") {
event.respondWith(fetch(req).catch(() => caches.match("/"))); event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
return; return;
} }
event.respondWith(fetch(req).catch(() => caches.match(req))); event.respondWith(fetch(req).catch(() => caches.match(req)));
@@ -98,7 +113,7 @@ self.addEventListener("push", (event) => {
// A StateChange, or a payload too large to carry the message. Say // A StateChange, or a payload too large to carry the message. Say
// something true rather than inventing a sender. // something true rather than inventing a sender.
await self.registration.showNotification("New mail", { await self.registration.showNotification("New mail", {
icon: "/img/icon-192.png", badge: "/img/favicon-64.png", tag: "ihasmail-mail", data: { url: "/mail" }, icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
}); });
return; return;
} }
@@ -108,10 +123,10 @@ self.addEventListener("push", (event) => {
const { title, body, preview } = textOf(email); const { title, body, preview } = textOf(email);
await self.registration.showNotification(title, { await self.registration.showNotification(title, {
body: preview ? `${body}\n${preview}` : body, body: preview ? `${body}\n${preview}` : body,
icon: "/img/icon-192.png", icon: `${BASE}/img/icon-192.png`,
badge: "/img/favicon-64.png", badge: `${BASE}/img/favicon-64.png`,
tag: `ihasmail-${email.id || body}`, tag: `ihasmail-${email.id || body}`,
data: { url: email.id ? `/mail/inbox/${email.id}` : "/mail" }, data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
}); });
} }
})()); })());
@@ -119,12 +134,16 @@ self.addEventListener("push", (event) => {
self.addEventListener("notificationclick", (event) => { self.addEventListener("notificationclick", (event) => {
event.notification.close(); event.notification.close();
const url = event.notification.data?.url || "/mail"; const url = event.notification.data?.url || `${BASE}/mail`;
event.waitUntil((async () => { event.waitUntil((async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
// Reuse a tab if one is open rather than piling up windows. // Reuse a tab if one is open rather than piling up windows. Same origin is
// not enough under a prefix: `includeUncontrolled` widens the match to the
// whole origin, so on a host that also serves something else this would
// navigate a stranger's tab to our inbox.
for (const c of clients) { for (const c of clients) {
if (new URL(c.url).origin === self.location.origin) { const at = new URL(c.url);
if (at.origin === self.location.origin && (at.pathname === BASE || at.pathname.startsWith(`${BASE}/`))) {
await c.focus(); await c.focus();
if ("navigate" in c) await c.navigate(url).catch(() => {}); if ("navigate" in c) await c.navigate(url).catch(() => {});
return; return;
+12
View File
@@ -22,6 +22,7 @@ import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlready
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable"; import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n"; import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges"; 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 ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView }))); 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. * Reload and tab close are covered by `beforeunload` instead.
*/ */
<Router <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) => { aroundNav={(navigate, to, options) => {
if (!hasUnsavedChanges()) { if (!hasUnsavedChanges()) {
navigate(to, options); navigate(to, options);
+12 -4
View File
@@ -1,4 +1,5 @@
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types"; import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
import { withBase } from "@/lib/basePath";
export const CAP = { export const CAP = {
core: "urn:ietf:params:jmap:core", 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" }; 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> { export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, { const res = await fetch(withBase(path), {
...init, ...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) }, headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
credentials: "same-origin", credentials: "same-origin",
@@ -276,12 +284,12 @@ export class JmapClient {
} }
uploadUrl(accountId: Id): string { 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 { downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_"); 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; return inline ? `${u}&inline=1` : u;
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Id, StateChange } from "./types"; import type { Id, StateChange } from "./types";
import { withBase } from "@/lib/basePath";
export type PushListener = (accountId: Id, type: string, newState: string) => void; export type PushListener = (accountId: Id, type: string, newState: string) => void;
@@ -70,7 +71,7 @@ class PushManager {
private connect(): void { private connect(): void {
if (this.stopped || this.es) return; if (this.stopped || this.es) return;
if (this.state !== "connected") this.setState("connecting"); 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 }); const es = new EventSource(url, { withCredentials: true });
this.es = es; this.es = es;
es.onopen = () => { 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 type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
import { withBase } from "@/lib/basePath";
/** Best display name for a card. */ /** Best display name for a card. */
export function contactDisplayName(c: ContactCard): string { 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"); const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
if (!m) return null; if (!m) return null;
if (m.uri) return m.uri.startsWith("data:") ? m.uri : 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; return null;
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { withBase } from "@/lib/basePath";
export interface SanitizeOptions { export interface SanitizeOptions {
/** Map of Content-ID (without angle brackets) → URL for inline images. */ /** 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 { 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 { export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
+5 -3
View File
@@ -1,3 +1,5 @@
import { withBase } from "./basePath";
let baseTitle = "ihasmail"; let baseTitle = "ihasmail";
let faviconCanvas: HTMLCanvasElement | null = null; let faviconCanvas: HTMLCanvasElement | null = null;
let baseFavicon: HTMLImageElement | null = null; let baseFavicon: HTMLImageElement | null = null;
@@ -14,13 +16,13 @@ export function setUnreadBadge(count: number): void {
if (!link) return; if (!link) return;
if (!baseFavicon) { if (!baseFavicon) {
baseFavicon = new Image(); baseFavicon = new Image();
baseFavicon.src = "/img/favicon-64.png"; baseFavicon.src = withBase("/img/favicon-64.png");
baseFavicon.onload = () => setUnreadBadge(count); baseFavicon.onload = () => setUnreadBadge(count);
return; return;
} }
if (!baseFavicon.complete) return; if (!baseFavicon.complete) return;
if (count <= 0) { if (count <= 0) {
link.href = "/img/favicon-64.png"; link.href = withBase("/img/favicon-64.png");
return; return;
} }
faviconCanvas ??= document.createElement("canvas"); 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 (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return; if (document.visibilityState === "visible" && document.hasFocus()) return;
try { 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 = () => { n.onclick = () => {
window.focus(); window.focus();
opts.onClick?.(); opts.onClick?.();
+2 -1
View File
@@ -1,4 +1,5 @@
import { APP_VERSION } from "./version"; import { APP_VERSION } from "./version";
import { withBase } from "./basePath";
import { push, type PushState } from "@/jmap/push"; import { push, type PushState } from "@/jmap/push";
/** /**
@@ -71,7 +72,7 @@ export function reloadIfServerRebuilt(): Promise<boolean> {
async function check(): Promise<boolean> { async function check(): Promise<boolean> {
let serverVersion: string; let serverVersion: string;
try { 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; if (!res.ok) return false;
const body = (await res.json()) as { version?: unknown }; const body = (await res.json()) as { version?: unknown };
if (typeof body.version !== "string" || !body.version) return false; 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 "./styles/app.css";
import { App } from "./App"; import { App } from "./App";
import { startBuildWatch } from "@/lib/staleBuild"; import { startBuildWatch } from "@/lib/staleBuild";
import { BASE_PATH, withBase } from "@/lib/basePath";
startBuildWatch(); startBuildWatch();
@@ -14,7 +15,16 @@ createRoot(document.getElementById("root")!).render(
if ("serviceWorker" in navigator && import.meta.env.PROD) { if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => { 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 */ /* ignore */
}); });
}); });
+21 -3
View File
@@ -10,6 +10,7 @@ import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { ensureScheduledMailbox, useScheduled } from "./scheduled"; import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule"; import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n"; import { t as translate } from "@/lib/i18n";
import { BASE_PATH } from "@/lib/basePath";
import { settings } from "./settings"; import { settings } from "./settings";
export interface ComposeAttachment { 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. */ /** 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>> { export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailboxId?: Id | null }): Promise<Record<string, unknown>> {
const mail = useMail.getState(); 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. // Inline attachments shown via blob URLs in the editor → back to cid: references.
for (const a of d.attachments) { for (const a of d.attachments) {
if (a.inline && a.cid && a.blobId && html) { 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}`); 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 related: EmailBodyPart[] = [];
const relatedInline: Array<{ blobId: Id; type: string; name: string; cid: string }> = []; 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. // 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"); const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img"))) { for (const img of Array.from(doc.querySelectorAll("img"))) {
const src = img.getAttribute("src") ?? ""; const src = img.getAttribute("src") ?? "";
const m = /^\/api\/blob\/([^/]+)\/([^/]+)\/([^?]+)(?:\?([^#]*))?/.exec(src); const m = new RegExp(`^${BLOB_URL_RE}([^/]+)/([^/]+)/([^?]+)(?:\\?([^#]*))?`).exec(src);
if (!m) continue; if (!m) continue;
const blobId = decodeURIComponent(m[2]!); const blobId = decodeURIComponent(m[2]!);
const name = decodeURIComponent(m[3]!); const name = decodeURIComponent(m[3]!);
+5 -1
View File
@@ -23,6 +23,7 @@ import { settings, useSettings } from "./settings";
import { useSession } from "./session"; import { useSession } from "./session";
import { mailboxDisplayName } from "@/lib/mailboxName"; import { mailboxDisplayName } from "@/lib/mailboxName";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath";
/* /*
* Named explicitly so `shareWith` comes back, which it does not otherwise -- * 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, tag: e.id,
onClick: () => { onClick: () => {
window.location.hash = ""; 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")); 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 { 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 { 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 { useSession } from "@/store/session";
import { withBase } from "@/lib/basePath";
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose"; import { draftFromMailto, useCompose } from "@/store/compose";
@@ -80,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<MenuIcon size={22} /> <MenuIcon size={22} />
</button> </button>
<Link href="/mail" className="brand"> <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 {/* A product name, not a word. "ihasmail" translated is a different
product, and the one on the tab beside it is still called this. */} product, and the one on the tab beside it is still called this. */}
<span className="brand-name notranslate" translate="no"> <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 { Eye, EyeOff, LogIn } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client"; import { ApiError } from "@/jmap/client";
import { withBase } from "@/lib/basePath";
import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
@@ -14,7 +15,7 @@ export function LoginPage() {
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL); const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
useEffect(() => { useEffect(() => {
let live = true; let live = true;
fetch("/api/config") fetch(withBase("/api/config"))
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((c) => { if (live && c?.sourceUrl) setSourceUrl(c.sourceUrl as string); }) .then((c) => { if (live && c?.sourceUrl) setSourceUrl(c.sourceUrl as string); })
.catch(() => { /* the default stands */ }); .catch(() => { /* the default stands */ });
@@ -53,7 +54,7 @@ export function LoginPage() {
<div className="login-page"> <div className="login-page">
<form className="login-card" onSubmit={submit}> <form className="login-card" onSubmit={submit}>
<div className="logo"> <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> <h1 className="notranslate" translate="no">ihasmail</h1>
<p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p> <p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p>
</div> </div>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearch } from "wouter"; import { useLocation, useSearch } from "wouter";
import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail"; import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { withBase } from "@/lib/basePath";
import { useCompose } from "@/store/compose"; import { useCompose } from "@/store/compose";
import { buildFilter, describeFilter, parseQuery } from "@/lib/search"; import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
import { keyboard } from "@/lib/keyboard"; 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} /> <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"> <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>{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 className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</div>
</div> </div>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useSession } from "@/store/session";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
import { withBase } from "@/lib/basePath";
import { t, tNode } from "@/lib/i18n"; import { t, tNode } from "@/lib/i18n";
export function AboutSettings() { export function AboutSettings() {
@@ -14,7 +15,7 @@ export function AboutSettings() {
<h1>{t("About ihasmail")}</h1> <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> <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 }}> <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> <div>
{/* A product name and a version string: neither is a word to translate. */} {/* 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> <div style={{ fontWeight: 700, fontSize: "1.2em" }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div>
+20 -1
View File
@@ -2,12 +2,28 @@ import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { resolveVersion } from "../scripts/version.mjs"; import { resolveVersion } from "../scripts/version.mjs";
import { baseUrlOf } from "../scripts/basePath.mjs";
// Resolved here, at build time: the browser has no git to ask, and neither does // Resolved here, at build time: the browser has no git to ask, and neither does
// the Docker build, which is handed the answer as IHASMAIL_VERSION instead. // the Docker build, which is handed the answer as IHASMAIL_VERSION instead.
const version = resolveVersion(); const version = resolveVersion();
/*
* Where the app is mounted. Unlike everything else ihasmail is told, this one
* cannot wait until the process starts: the hashed asset URLs are written into
* index.html when the bundle is built, so a build that does not know its prefix
* emits `/assets/...` and the shell 404s under `/mail/`. So `BASE_PATH` is read
* at build time here as well as at run time in the server, and the Dockerfile
* carries one value into both.
*
* Vite wants the directory form with the trailing slash, and hands it back to
* the app as `import.meta.env.BASE_URL` -- which is where `lib/basePath.ts`
* gets it, so the browser never has to be told separately.
*/
const base = baseUrlOf(process.env.BASE_PATH);
export default defineConfig({ export default defineConfig({
base,
plugins: [react()], plugins: [react()],
define: { __IHASMAIL_VERSION__: JSON.stringify(version) }, define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
resolve: { resolve: {
@@ -16,7 +32,10 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { // Under a prefix the dev server serves the app from `base`, so the app's
// API calls arrive here prefixed too. Forwarded whole, prefix included:
// the dev server behind this reads the same BASE_PATH and expects it.
[`${base}api`]: {
target: "http://127.0.0.1:8080", target: "http://127.0.0.1:8080",
changeOrigin: false, changeOrigin: false,
}, },