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
+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;
}