Merge pull request #201 from Coffey-Labs/feat/base-path
Serve ihasmail from a subpath
This commit is contained in:
@@ -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
@@ -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
@@ -1033,6 +1033,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 |
|
||||||
@@ -1052,6 +1053,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
|
||||||
@@ -1063,7 +1099,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
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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
@@ -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 */
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
import { emlFilename } from "@/lib/emlName";
|
import { emlFilename } from "@/lib/emlName";
|
||||||
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||||
@@ -648,6 +649,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();
|
||||||
@@ -662,7 +680,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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,11 +688,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]!);
|
||||||
|
|||||||
@@ -25,6 +25,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 --
|
||||||
@@ -1106,7 +1107,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,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">
|
||||||
|
|||||||
@@ -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,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,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
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user