From 841513e5106999214d80e75ad43146120e51c2f3 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 1 May 2026 14:57:49 +0200
Subject: [PATCH] feat: support subpath deployment with NEXT_PUBLIC_BASE_PATH
environment variable
---
Dockerfile | 4 +++
README.md | 20 +++++++++++++
app/[locale]/login/page.tsx | 3 +-
app/manifest.ts | 34 +++++++++++++---------
app/not-found.tsx | 23 ++++++++++-----
components/service-worker-registration.tsx | 4 ++-
components/settings/composing-settings.tsx | 3 +-
hooks/use-calendar-alerts.ts | 5 ++--
lib/browser-navigation.ts | 20 +++++++++----
lib/web-push.ts | 12 +++++---
next.config.ts | 13 +++++++++
public/sw.js | 28 +++++++++++++-----
12 files changed, 127 insertions(+), 42 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 9fec013f..d6095c4b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,10 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
+# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
+# at build time, so it cannot be changed without rebuilding.
+ARG NEXT_PUBLIC_BASE_PATH=
+ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
RUN npx next build --webpack
FROM node:24-alpine AS runner
diff --git a/README.md b/README.md
index 345102d9..ac13436f 100644
--- a/README.md
+++ b/README.md
@@ -217,6 +217,26 @@ LOG_LEVEL=info # error | warn | info | debug
+
+Subpath / reverse proxy mount
+
+To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
+
+```env
+NEXT_PUBLIC_BASE_PATH=/webmail
+NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
+```
+
+Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
+
+```bash
+docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
+```
+
+Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
+
+
+
## Keyboard Shortcuts
| Key | Action |
diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx
index 1739b766..4b5590e2 100644
--- a/app/[locale]/login/page.tsx
+++ b/app/[locale]/login/page.tsx
@@ -411,7 +411,8 @@ export default function LoginPage() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
- const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
+ const prefix = getPathPrefix(params.locale as string);
+ const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
diff --git a/app/manifest.ts b/app/manifest.ts
index a5e12481..6e5da59e 100644
--- a/app/manifest.ts
+++ b/app/manifest.ts
@@ -2,6 +2,12 @@ import type { MetadataRoute } from "next";
export const dynamic = "force-dynamic";
+// Manifest paths must include the deployment subpath - browsers resolve them
+// against the document origin, not the manifest's location, and Next.js does
+// not auto-prefix string literals inside MetadataRoute payloads.
+const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
+const withBase = (p: string) => `${BASE_PATH}${p}`;
+
export default function manifest(): MetadataRoute.Manifest {
const appName =
process.env.APP_NAME ||
@@ -21,26 +27,26 @@ export default function manifest(): MetadataRoute.Manifest {
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [
- { src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
- { src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
- { src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
- { src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
+ { src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "any" },
+ { src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "any" },
+ { src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "maskable" },
+ { src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "maskable" },
]
: [
- { src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
- { src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
- { src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
- { src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
- { src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
- { src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
+ { src: withBase("/icon-192x192.png"), sizes: "192x192", type: "image/png", purpose: "any" },
+ { src: withBase("/icon-512x512.png"), sizes: "512x512", type: "image/png", purpose: "any" },
+ { src: withBase("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
+ { src: withBase("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
+ { src: withBase("/icon-maskable-dark-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
+ { src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
];
return {
name: appName,
short_name: shortName,
description,
- start_url: "/",
- scope: "/",
+ start_url: withBase("/"),
+ scope: withBase("/"),
display: "standalone",
orientation: "portrait-primary",
theme_color: themeColor,
@@ -48,8 +54,8 @@ export default function manifest(): MetadataRoute.Manifest {
icons,
categories: ["productivity"],
screenshots: [
- { src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
- { src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
+ { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
+ { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
],
};
}
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 7d4d30f9..fe955716 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -2,34 +2,43 @@
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
+import { getPathPrefix } from "@/lib/browser-navigation";
export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (!isAuthenticated) {
- // Don't redirect admin routes to the webmail login page
- const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
+ const prefix = getPathPrefix();
+ // Don't redirect admin routes to the webmail login page. Admin paths
+ // are mounted relative to the deployment prefix, so account for it.
+ const adminBase = `${prefix}/admin`;
+ const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
if (!isAdminRoute) {
- window.location.href = "/login";
+ window.location.href = `${prefix}/login`;
}
}
}, [isAuthenticated]);
if (!isAuthenticated) {
- // Allow admin routes to render the 404 without redirecting
- const isAdmin = typeof window !== 'undefined' &&
- (window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
+ let isAdmin = false;
+ if (typeof window !== 'undefined') {
+ const prefix = getPathPrefix();
+ const adminBase = `${prefix}/admin`;
+ isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
+ }
if (!isAdmin) return null;
}
+ const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
+
return (
404
This page could not be found.
Go home
diff --git a/components/service-worker-registration.tsx b/components/service-worker-registration.tsx
index 0258a019..e5a49b25 100644
--- a/components/service-worker-registration.tsx
+++ b/components/service-worker-registration.tsx
@@ -2,6 +2,8 @@
import { useEffect } from "react";
+const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
+
export function ServiceWorkerRegistration() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
@@ -23,7 +25,7 @@ export function ServiceWorkerRegistration() {
}
navigator.serviceWorker
- .register("/sw.js")
+ .register(`${BASE_PATH}/sw.js`, { scope: `${BASE_PATH}/` })
.then((registration) => {
console.log("Service Worker registered successfully:", registration);
})
diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx
index 1da3cd14..1c75ce05 100644
--- a/components/settings/composing-settings.tsx
+++ b/components/settings/composing-settings.tsx
@@ -6,6 +6,7 @@ import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react';
+import { getPathPrefix } from '@/lib/browser-navigation';
import {
SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter,
@@ -32,7 +33,7 @@ export function ComposingSettings() {
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
- navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
+ navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts
index dad104ed..fb5102e2 100644
--- a/hooks/use-calendar-alerts.ts
+++ b/hooks/use-calendar-alerts.ts
@@ -11,6 +11,7 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
import { useToastStore } from '@/stores/toast-store';
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
import { playNotificationSound } from '@/lib/notification-sound';
+import { getPathPrefix } from '@/lib/browser-navigation';
import type { CalendarEvent } from '@/lib/jmap/types';
const CHECK_INTERVAL_MS = 60 * 1000;
@@ -68,7 +69,7 @@ export function useCalendarAlerts() {
message,
duration: 15000,
onClick: () => {
- window.location.href = `/${locale}/calendar`;
+ window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
});
}
@@ -97,7 +98,7 @@ export function useCalendarAlerts() {
message: taskMsg,
duration: 15000,
onClick: () => {
- window.location.href = `/${locale}/calendar`;
+ window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
});
}
diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts
index f6502455..5df6ff91 100644
--- a/lib/browser-navigation.ts
+++ b/lib/browser-navigation.ts
@@ -8,17 +8,27 @@ export function replaceWindowLocation(url: string): void {
window.location.replace(url);
}
+// Build-time constant injected by next.config.ts. When the app is built with
+// NEXT_PUBLIC_BASE_PATH=/webmail, Next.js itself prefixes routes and assets;
+// helpers below use the same value so client code stays consistent.
+const STATIC_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
+
/**
- * Returns the mount prefix from the current URL.
- * When the app is served behind a reverse proxy at e.g. /bulwark,
- * the browser sees /bulwark/en/login while Next.js sees /en/login.
+ * Returns the mount prefix the app is served at.
*
- * If a locale is supplied (e.g. from route params) it is used directly;
- * otherwise the first path segment that matches a known locale is used.
+ * Resolution order:
+ * 1. The build-time `NEXT_PUBLIC_BASE_PATH` constant (set in next.config.ts).
+ * 2. Runtime detection from `window.location.pathname` for legacy deploys
+ * where the reverse proxy mounts the app at a subpath without rebuilding.
+ *
+ * If a locale is supplied (e.g. from route params) it anchors the runtime
+ * detection; otherwise the first path segment that matches a known locale is
+ * used.
*
* Returns '' when there is no prefix.
*/
export function getPathPrefix(locale?: string): string {
+ if (STATIC_BASE_PATH) return STATIC_BASE_PATH;
if (typeof window === 'undefined') return '';
const segments = window.location.pathname.split('/').filter(Boolean);
diff --git a/lib/web-push.ts b/lib/web-push.ts
index 3eee8ee9..741b04cd 100644
--- a/lib/web-push.ts
+++ b/lib/web-push.ts
@@ -9,6 +9,10 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
+const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
+const SW_SCOPE = `${BASE_PATH}/`;
+const SW_URL = `${BASE_PATH}/sw.js`;
+
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
@@ -139,9 +143,9 @@ async function ensureServiceWorker(): Promise {
// The webmail's PWA already registers /sw.js for installability. If it
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
// push handler is in place.
- let registration = await navigator.serviceWorker.getRegistration('/');
+ let registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
if (!registration) {
- registration = await navigator.serviceWorker.register('/sw.js');
+ registration = await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE });
}
await navigator.serviceWorker.ready;
return registration;
@@ -336,7 +340,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise undefined);
}
@@ -345,7 +349,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise {
if (!isWebPushSupported()) return false;
if (Notification.permission !== 'granted') return false;
- const registration = await navigator.serviceWorker.getRegistration('/');
+ const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
if (!registration) return false;
const sub = await registration.pushManager.getSubscription();
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
diff --git a/next.config.ts b/next.config.ts
index cc78bd8e..02070b12 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -18,15 +18,28 @@ try {
// VERSION file not found
}
+// Subpath deployment, e.g. NEXT_PUBLIC_BASE_PATH=/webmail. Read at build time
+// because Next.js bakes basePath into emitted asset URLs and route metadata.
+// Trailing slash is stripped; an empty/missing value disables the feature.
+const rawBasePath = process.env.NEXT_PUBLIC_BASE_PATH?.trim() ?? "";
+const basePath = rawBasePath.replace(/\/+$/, "");
+if (basePath && !basePath.startsWith("/")) {
+ throw new Error(
+ `NEXT_PUBLIC_BASE_PATH must start with "/" (got: ${JSON.stringify(rawBasePath)})`
+ );
+}
+
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.51"],
+ basePath: basePath || undefined,
turbopack: {
root: import.meta.dirname,
},
env: {
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
NEXT_PUBLIC_APP_VERSION: appVersion,
+ NEXT_PUBLIC_BASE_PATH: basePath,
},
};
diff --git a/public/sw.js b/public/sw.js
index 1d188648..d5e55657 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -10,6 +10,20 @@
// task: relay sends only a state-change ping, the client fetches the
// newest unread email itself so the relay never sees mail content.
+// When the app is mounted at a subpath (Next.js basePath, e.g. /webmail), the
+// SW is served at /webmail/sw.js and registered with scope /webmail/. Derive
+// the prefix from the SW's own URL so push fetches and notification clicks
+// land on the right path - service workers can't read process.env.
+function getBasePath() {
+ const path = new URL(self.location.href).pathname;
+ // self.location is .../sw.js; strip the trailing filename to get the dir,
+ // then strip the trailing slash so it concatenates cleanly with `/foo`.
+ const dir = path.replace(/[^/]*$/, "");
+ return dir.replace(/\/+$/, "");
+}
+
+const BASE_PATH = getBasePath();
+
self.addEventListener("install", () => {
self.skipWaiting();
});
@@ -48,7 +62,7 @@ async function handlePush(event) {
let preview = null;
let previewOk = false;
try {
- const res = await fetch("/api/push/preview", {
+ const res = await fetch(`${BASE_PATH}/api/push/preview`, {
credentials: "include",
cache: "no-store",
});
@@ -99,8 +113,8 @@ async function handlePush(event) {
await self.registration.showNotification(title, {
body,
tag,
- icon: "/icon-192x192.png",
- badge: "/icon-192x192.png",
+ icon: `${BASE_PATH}/icon-192x192.png`,
+ badge: `${BASE_PATH}/icon-192x192.png`,
data,
renotify: true,
});
@@ -132,17 +146,17 @@ async function handleNotificationClick(event) {
}
if (self.clients.openWindow) {
- return self.clients.openWindow(targetUrl || "/");
+ return self.clients.openWindow(targetUrl || `${BASE_PATH}/`);
}
}
function buildClickUrl(data) {
- if (!data) return "/";
+ if (!data) return `${BASE_PATH}/`;
if (data.kind === "email" && data.emailId) {
- return `/?email=${encodeURIComponent(data.emailId)}`;
+ return `${BASE_PATH}/?email=${encodeURIComponent(data.emailId)}`;
}
// Generic "New mail" toast (preview API failed or returned no email): land
// the user on the latest unread message in their Inbox rather than just the
// app shell, so the click still feels purposeful.
- return "/?openLatestUnread=1";
+ return `${BASE_PATH}/?openLatestUnread=1`;
}