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
+28 -7
View File
@@ -116,12 +116,28 @@ const requireSession: MiddlewareHandler<Env> = async (c, 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) {
setCookie(c, config.cookieName, value, {
httpOnly: true,
sameSite: "Lax",
secure: isSecureRequest(c),
path: "/",
path: cookiePath,
...(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);
}
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>();
app.use("*", securityHeaders);
@@ -247,7 +268,7 @@ export function createApp(): Hono<Env> {
} catch (err) {
if (err instanceof UpstreamError && err.status === 401) {
sessions.destroy(session.id);
deleteCookie(c, config.cookieName, { path: "/" });
deleteCookie(c, config.cookieName, { path: cookiePath });
}
return upstreamFailure(c, err);
}
@@ -260,7 +281,7 @@ export function createApp(): Hono<Env> {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
}
deleteCookie(c, config.cookieName, { path: "/" });
deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ ok: true });
});
@@ -473,7 +494,7 @@ export function createApp(): Hono<Env> {
if (res.status === 401) {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
deleteCookie(c, config.cookieName, { path: "/" });
deleteCookie(c, config.cookieName, { path: cookiePath });
return c.json({ error: "unauthenticated" }, 401);
}
return passthrough(res);
@@ -599,10 +620,10 @@ export function createApp(): Hono<Env> {
return c.json({ error: "internal_error" }, 500);
});
app.route("/api", api);
app.route(`${basePath}/api`, api);
// ---------- Static SPA ----------
app.get("*", staticHandler(config.staticDir));
app.get("*", staticHandler(config.staticDir, basePath));
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 { normalizeBasePath } from "../../scripts/basePath.mjs";
import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
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"),
host: env("HOST", "0.0.0.0"),
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,
appSecret,
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 { Readable } from "node:stream";
import type { Context, Handler } from "hono";
import { stripBasePath } from "../../scripts/basePath.mjs";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
@@ -47,9 +48,30 @@ export const APP_CSP = [
"manifest-src 'self'",
].join("; ");
export function staticHandler(root: string): Handler {
export function staticHandler(root: string, basePath = ""): Handler {
const absRoot = resolve(root);
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) {
try {
@@ -57,7 +79,9 @@ export function staticHandler(root: string): Handler {
const st = await stat(p);
if (!indexCache || indexCache.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("Cache-Control", "no-cache");
c.header("Content-Security-Policy", APP_CSP);
@@ -70,7 +94,16 @@ export function staticHandler(root: string): Handler {
return async (c) => {
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);
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
const filePath = join(absRoot, rel);