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