Merge pull request #201 from Coffey-Labs/feat/base-path

Serve ihasmail from a subpath
This commit is contained in:
Coffey Labs
2026-09-01 22:47:53 -07:00
committed by GitHub
29 changed files with 579 additions and 55 deletions
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { baseUrlOf, normalizeBasePath, stripBasePath } from "../../../../scripts/basePath.mjs";
import { BASE_PATH, withBase } from "@/lib/basePath";
/**
* `BASE_PATH` is typed into a compose file or a `docker run` line by hand, and
* the four spellings below are all reasonable things for someone to write.
* The one that has to be exactly right is the empty one: every deployment that
* exists today is at the root, and this feature must be invisible to them.
*
* The canonical form is a leading slash and no trailing one, so that the
* concatenation `${base}/api/health` is correct with no branch. A trailing
* slash would make the empty case produce `//api/health`, which is not a path
* on this host but a protocol-relative URL pointing at a host called `api` --
* which is why the tests below check the joined result and not just the value.
*/
describe("normalizing what the operator wrote", () => {
it("leaves the canonical form alone", () => {
expect(normalizeBasePath("/mail")).toBe("/mail");
});
it("accepts a missing leading slash", () => {
expect(normalizeBasePath("mail")).toBe("/mail");
});
it("accepts a trailing slash", () => {
expect(normalizeBasePath("/mail/")).toBe("/mail");
expect(normalizeBasePath("mail/")).toBe("/mail");
});
it("accepts a nested mount, however it is punctuated", () => {
expect(normalizeBasePath("apps/mail")).toBe("/apps/mail");
expect(normalizeBasePath("/apps/mail/")).toBe("/apps/mail");
});
it("tidies away doubled separators and stray whitespace", () => {
expect(normalizeBasePath("//mail//")).toBe("/mail");
expect(normalizeBasePath(" /mail ")).toBe("/mail");
});
});
describe("the root, which must behave exactly as it did", () => {
it("is the empty string for every way of saying it", () => {
expect(normalizeBasePath("")).toBe("");
expect(normalizeBasePath("/")).toBe("");
expect(normalizeBasePath("///")).toBe("");
expect(normalizeBasePath(undefined)).toBe("");
expect(normalizeBasePath(null)).toBe("");
});
it("joins onto an app path without doubling the slash", () => {
// `//api/health` would be read as a protocol-relative URL and sent to a
// host called `api`. This is the assertion the whole canonical form is for.
expect(`${normalizeBasePath("/")}/api/health`).toBe("/api/health");
expect(`${normalizeBasePath("/mail")}/api/health`).toBe("/mail/api/health");
});
});
describe("the directory form Vite and the PWA scope want", () => {
it("always ends in a slash", () => {
expect(baseUrlOf("")).toBe("/");
expect(baseUrlOf("mail")).toBe("/mail/");
expect(baseUrlOf("/mail/")).toBe("/mail/");
});
});
describe("taking the prefix off an incoming request", () => {
it("passes everything through untouched at the root", () => {
expect(stripBasePath("", "/")).toBe("/");
expect(stripBasePath("", "/assets/index.js")).toBe("/assets/index.js");
expect(stripBasePath("", "/mail/inbox/abc")).toBe("/mail/inbox/abc");
});
it("strips the mount and keeps the rest", () => {
expect(stripBasePath("/mail", "/mail/assets/index.js")).toBe("/assets/index.js");
expect(stripBasePath("/mail", "/mail/api/health")).toBe("/api/health");
});
it("treats the bare mount as the app's index", () => {
// Typing the prefix without the trailing slash is how people reach it.
expect(stripBasePath("/mail", "/mail")).toBe("/");
expect(stripBasePath("/mail", "/mail/")).toBe("/");
});
it("refuses a path that merely starts with the same letters", () => {
// A plain startsWith would hand `/mailbox` the app shell, shadowing
// whatever else the proxy serves on this host.
expect(stripBasePath("/mail", "/mailbox")).toBe(null);
expect(stripBasePath("/mail", "/mailing/list")).toBe(null);
});
it("refuses anything outside the mount", () => {
expect(stripBasePath("/mail", "/")).toBe(null);
expect(stripBasePath("/mail", "/other-app/thing")).toBe(null);
});
});
describe("the browser's view of the mount", () => {
/*
* Vitest builds with Vite's default base, so this is the root deployment --
* which is the case that must not regress, and the reason these assertions
* are worth writing down rather than dismissing as trivial.
*/
it("is empty in a root build", () => {
expect(BASE_PATH).toBe("");
});
it("leaves app paths exactly as written", () => {
expect(withBase("/api/health")).toBe("/api/health");
expect(withBase("/img/logo.png")).toBe("/img/logo.png");
expect(withBase("/sw.js")).toBe("/sw.js");
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* The subpath this build is mounted at, as the browser sees it.
*
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
* normaliser -- so this is the same answer the server reached, not a second
* guess at it. Reading it here rather than re-deriving it from
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
* there is nothing in the address that says how much of it is the mount.
*
* Vite guarantees the value ends in a slash, so the only conversion is
* dropping it; `""` for the root, `/mail` otherwise, matching
* `scripts/basePath.mjs`.
*/
export const BASE_PATH: string = import.meta.env.BASE_URL.replace(/\/+$/, "");
/**
* Turn a root-absolute app path into one the server will answer.
*
* Every `/api/...`, `/img/...` and `/sw.js` in the app goes through here.
* Router paths do not: wouter is given `BASE_PATH` as its base and strips and
* re-adds the prefix itself, so `<Link href="/mail">` stays written that way.
* Mixing the two would double the prefix, which is why this asserts nothing
* and simply concatenates -- the discipline is at the call sites, and the
* callers that need it are few and all in this repo.
*/
export function withBase(path: string): string {
return BASE_PATH + path;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
import { withBase } from "@/lib/basePath";
/** Best display name for a card. */
export function contactDisplayName(c: ContactCard): string {
@@ -60,7 +61,7 @@ export function contactPhoto(c: ContactCard, accountId: string): string | null {
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
if (!m) return null;
if (m.uri) return m.uri.startsWith("data:") ? m.uri : null;
if (m.blobId) return `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`;
if (m.blobId) return withBase(`/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`);
return null;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import DOMPurify from "dompurify";
import { withBase } from "@/lib/basePath";
export interface SanitizeOptions {
/** Map of Content-ID (without angle brackets) → URL for inline images. */
@@ -61,7 +62,7 @@ function hardenCss(css: string): string {
}
export function proxiedImageUrl(url: string): string {
return `/api/image?url=${encodeURIComponent(url)}`;
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
}
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
+5 -3
View File
@@ -1,3 +1,5 @@
import { withBase } from "./basePath";
let baseTitle = "ihasmail";
let faviconCanvas: HTMLCanvasElement | null = null;
let baseFavicon: HTMLImageElement | null = null;
@@ -14,13 +16,13 @@ export function setUnreadBadge(count: number): void {
if (!link) return;
if (!baseFavicon) {
baseFavicon = new Image();
baseFavicon.src = "/img/favicon-64.png";
baseFavicon.src = withBase("/img/favicon-64.png");
baseFavicon.onload = () => setUnreadBadge(count);
return;
}
if (!baseFavicon.complete) return;
if (count <= 0) {
link.href = "/img/favicon-64.png";
link.href = withBase("/img/favicon-64.png");
return;
}
faviconCanvas ??= document.createElement("canvas");
@@ -60,7 +62,7 @@ export function showNotification(title: string, opts: NotificationOptions & { on
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return;
try {
const n = new Notification(title, { icon: "/img/icon-192.png", badge: "/img/favicon-64.png", ...opts });
const n = new Notification(title, { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...opts });
n.onclick = () => {
window.focus();
opts.onClick?.();
+2 -1
View File
@@ -1,4 +1,5 @@
import { APP_VERSION } from "./version";
import { withBase } from "./basePath";
import { push, type PushState } from "@/jmap/push";
/**
@@ -71,7 +72,7 @@ export function reloadIfServerRebuilt(): Promise<boolean> {
async function check(): Promise<boolean> {
let serverVersion: string;
try {
const res = await fetch("/api/health", { credentials: "same-origin", cache: "no-store" });
const res = await fetch(withBase("/api/health"), { credentials: "same-origin", cache: "no-store" });
if (!res.ok) return false;
const body = (await res.json()) as { version?: unknown };
if (typeof body.version !== "string" || !body.version) return false;