From 93d0a32af266a6f724eaf313a8aa0bbd7d9b0a56 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 1 Sep 2026 22:41:18 -0700 Subject: [PATCH] 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 `` and `` 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. --- .env.example | 12 +++ Dockerfile | 24 ++++- FEATURES.md | 39 +++++++- docker-compose.yml | 9 +- scripts/basePath.d.mts | 4 + scripts/basePath.mjs | 70 ++++++++++++++ server/src/app.ts | 35 +++++-- server/src/basepath.test.ts | 63 +++++++++++++ server/src/config.ts | 14 +++ server/src/static.ts | 37 +++++++- web/public/manifest.webmanifest | 19 ++-- web/public/sw.js | 41 +++++--- web/src/App.tsx | 12 +++ web/src/jmap/client.ts | 16 +++- web/src/jmap/push.ts | 3 +- web/src/lib/__tests__/basePath.test.ts | 113 +++++++++++++++++++++++ web/src/lib/basePath.ts | 29 ++++++ web/src/lib/contacts.ts | 3 +- web/src/lib/html.ts | 3 +- web/src/lib/notify.ts | 8 +- web/src/lib/staleBuild.ts | 3 +- web/src/main.tsx | 12 ++- web/src/store/compose.ts | 24 ++++- web/src/store/mail.ts | 6 +- web/src/views/AppShell.tsx | 3 +- web/src/views/Login.tsx | 5 +- web/src/views/mail/MailView.tsx | 3 +- web/src/views/settings/AboutSettings.tsx | 3 +- web/vite.config.ts | 21 ++++- 29 files changed, 579 insertions(+), 55 deletions(-) create mode 100644 scripts/basePath.d.mts create mode 100644 scripts/basePath.mjs create mode 100644 server/src/basepath.test.ts create mode 100644 web/src/lib/__tests__/basePath.test.ts create mode 100644 web/src/lib/basePath.ts diff --git a/.env.example b/.env.example index 4e1ea78..0c20802 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,18 @@ APP_SECRET=change-me HOST=0.0.0.0 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 # X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev. TRUST_PROXY=1 diff --git a/Dockerfile b/Dockerfile index 952700c..e0c8fab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,15 @@ FROM node:22-alpine AS build # Left empty, the build falls back to the base version from package.json. ARG 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 COPY package.json package-lock.json* ./ COPY server/package.json server/ @@ -19,12 +28,14 @@ RUN npm run build FROM node:22-alpine AS runtime # Re-declared: an ARG does not cross stages. ARG IHASMAIL_VERSION="" +ARG BASE_PATH="" ENV NODE_ENV=production \ HOST=0.0.0.0 \ PORT=8080 \ STATIC_DIR=/app/web/dist \ SESSION_FILE=/data/sessions.json \ - IHASMAIL_VERSION=$IHASMAIL_VERSION + IHASMAIL_VERSION=$IHASMAIL_VERSION \ + BASE_PATH=$BASE_PATH WORKDIR /app COPY package.json ./ COPY server/package.json server/ @@ -47,5 +58,14 @@ USER node # 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. 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"] diff --git a/FEATURES.md b/FEATURES.md index 17b8ce0..7b26a10 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -954,6 +954,7 @@ wizard, because either would be state. | `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 | | `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-*` | | `TRUSTED_PROXIES` | loopback + private ranges | Which peers to believe | | `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 `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 +`