From fdfcb1711e0883e2b1edcd3d20dd74a1489fddb5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:53:53 +0200 Subject: [PATCH] feat: add Bulwark Lite build for Stalwart Applications --- .github/workflows/static-lite.yml | 82 +- FEATURES.md | 1 + README.md | 40 +- app/(main)/layout.tsx | 4 +- components/contacts/contacts-sidebar.tsx | 2 +- components/files/pdf-mobile-viewer.tsx | 5 +- hooks/use-config.ts | 14 +- i18n/mounted-navigation.tsx | 58 ++ i18n/navigation.ts | 13 +- lib/__tests__/lite-config.test.ts | 21 +- lib/__tests__/lite-mounted-href.test.ts | 59 ++ lib/__tests__/lite-scripts.test.ts | 429 +++++++++ lib/__tests__/lite-stalwart-runtime.test.ts | 133 +++ lib/auth/lite-tokens.ts | 20 +- lib/browser-navigation.ts | 5 + lib/lite-config.ts | 15 +- lib/lite-mounted-href.ts | 67 ++ lib/lite.ts | 88 ++ package.json | 1 + scripts/lite/build.mjs | 40 +- scripts/lite/lib.mjs | 834 +++++++++++++++++- scripts/lite/postbuild.mjs | 67 +- scripts/lite/serve.mjs | 96 +- scripts/lite/smoke/lite-stalwart.spec.ts | 115 +++ scripts/lite/smoke/playwright.config.ts | 3 +- .../lite/smoke/playwright.stalwart.config.ts | 35 + scripts/lite/verify.mjs | 79 +- stores/__tests__/auth-store-lite.test.ts | 3 +- stores/auth-store.ts | 15 +- stores/policy-store.ts | 7 +- 30 files changed, 2267 insertions(+), 84 deletions(-) create mode 100644 i18n/mounted-navigation.tsx create mode 100644 lib/__tests__/lite-mounted-href.test.ts create mode 100644 lib/__tests__/lite-stalwart-runtime.test.ts create mode 100644 lib/lite-mounted-href.ts create mode 100644 scripts/lite/smoke/lite-stalwart.spec.ts create mode 100644 scripts/lite/smoke/playwright.stalwart.config.ts diff --git a/.github/workflows/static-lite.yml b/.github/workflows/static-lite.yml index 21364e395..256da7a5d 100644 --- a/.github/workflows/static-lite.yml +++ b/.github/workflows/static-lite.yml @@ -2,9 +2,16 @@ name: Build Static Lite # Bulwark Lite: the static export of the webmail (see docs in README.md, # "Static Lite build"). Runs the build on every push as a regression check, -# attaches the zip to releases, and lets maintainers build a custom variant +# attaches the zips to releases, and lets maintainers build a custom variant # (fixed server URL, sub-path mount, locale subset, demo) on demand. # +# Two targets are built side by side: +# - static: bulwark-lite-.zip for any static web host; +# - stalwart: bulwark-lite-stalwart.zip, a Stalwart `Application` bundle. Its +# release asset name carries no version, so +# releases/latest/download/bulwark-lite-stalwart.zip is a stable +# resourceUrl that follows new releases. +# # scripts/lite/prepare.mjs deletes the server-only trees in place; that is # safe here because the Actions checkout is disposable (CI=true is the guard). @@ -17,14 +24,14 @@ on: workflow_dispatch: inputs: jmap_server_url: - description: "Default JMAP server URL baked into config.json (editable after download)" + description: "Default JMAP server URL baked into config.json (static: editable after download; stalwart: leave empty for the serving Stalwart)" required: false app_name: description: "App name" required: false default: "Bulwark Webmail" base_path: - description: "Sub-path mount, e.g. /webmail (baked into asset URLs)" + description: "Sub-path mount for the static target, e.g. /webmail (baked into asset URLs; the stalwart target ignores it)" required: false locales: description: "Comma-separated locale subset, e.g. en,de,fr (default: all)" @@ -41,6 +48,10 @@ jobs: build: runs-on: ubuntu-latest timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + target: [static, stalwart] steps: - name: Checkout uses: actions/checkout@v4 @@ -58,7 +69,9 @@ jobs: env: CI: "true" GIT_COMMIT: ${{ github.sha }} - NEXT_PUBLIC_BASE_PATH: ${{ inputs.base_path }} + LITE_TARGET: ${{ matrix.target }} + # The Stalwart bundle learns its mount prefix at runtime. + NEXT_PUBLIC_BASE_PATH: ${{ matrix.target == 'static' && inputs.base_path || '' }} LITE_JMAP_SERVER_URL: ${{ inputs.jmap_server_url }} LITE_APP_NAME: ${{ inputs.app_name }} LITE_LOCALES: ${{ inputs.locales }} @@ -69,13 +82,19 @@ jobs: env: REF_NAME: ${{ github.ref_name }} run: | - if [ "${{ github.event_name }}" = "release" ]; then - VERSION="${REF_NAME#v}" + if [ "${{ matrix.target }}" = "stalwart" ]; then + # postbuild already packed it (entries at the archive root, no + # wrapping folder); the name stays version-less on purpose. + ZIP="bulwark-lite-stalwart.zip" else - VERSION="$(cat VERSION)-${GITHUB_SHA::7}" + if [ "${{ github.event_name }}" = "release" ]; then + VERSION="${REF_NAME#v}" + else + VERSION="$(cat VERSION)-${GITHUB_SHA::7}" + fi + ZIP="bulwark-lite-${VERSION}.zip" + (cd out && zip -qr "../${ZIP}" .) fi - ZIP="bulwark-lite-${VERSION}.zip" - (cd out && zip -qr "../${ZIP}" .) echo "ZIP=${ZIP}" >> "$GITHUB_ENV" ls -la "${ZIP}" @@ -90,7 +109,7 @@ jobs: if: github.event_name != 'release' uses: actions/upload-artifact@v4 with: - name: bulwark-lite + name: bulwark-lite-${{ matrix.target }} path: ${{ env.ZIP }} retention-days: 7 @@ -137,3 +156,46 @@ jobs: name: lite-smoke-report path: playwright-report retention-days: 7 + + smoke-stalwart: + # The Stalwart Application bundle, served by scripts/lite/serve.mjs + # --stalwart (an emulation of how Stalwart serves Applications) from the + # zip itself, under two prefixes. + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'push' || github.event_name == 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Build demo bundle + env: + CI: "true" + GIT_COMMIT: ${{ github.sha }} + LITE_TARGET: stalwart + LITE_DEMO_MODE: "true" + LITE_LOCALES: "en,de" + run: npm run build:lite + + - name: Run smoke test + run: npm run test:lite-smoke:stalwart + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: lite-smoke-stalwart-report + path: playwright-report + retention-days: 7 diff --git a/FEATURES.md b/FEATURES.md index 54ee35bd2..fb4461820 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -158,5 +158,6 @@ - Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles. - Docker images on GHCR, for release (`main`) and development (`dev`) - Bulwark Lite: a static export of the client (`npm run build:lite`, `bulwark-lite-.zip` on every release) for plain static hosts; runtime `config.json`, cookie-free token login against Stalwart, no admin/plugin/sync features +- Bulwark Lite for Stalwart: `bulwark-lite-stalwart.zip` installs as a Stalwart `Application` (served by Stalwart itself under any prefix, same origin, zero configuration) - `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy - Demo mode runs on fixture data, no mail server required diff --git a/README.md b/README.md index b33158bf5..27fcd821b 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ npm run build && npm start Bulwark Lite is the same client exported as static files: no Node.js process, upload the folder to any static host (nginx, Caddy, Netlify, Cloudflare Pages, GitHub Pages, an S3 bucket). Mail, calendar, contacts and files talk to the JMAP server straight from the browser, so the only server-side requirement is CORS on the mail server (`http.permissive-cors = true` in Stalwart). -Every release ships `bulwark-lite-.zip`, and the **Build Static Lite** workflow can be dispatched with a fixed server URL, app name, sub-path mount, locale subset or the demo flag. To build locally: +Every release ships `bulwark-lite-.zip` (and `bulwark-lite-stalwart.zip`, see [Install on Stalwart](#install-on-stalwart)), and the **Build Static Lite** workflow can be dispatched with a fixed server URL, app name, sub-path mount, locale subset or the demo flag. To build locally: ```bash git worktree add ../bulwark-lite HEAD # disposable checkout: the build deletes server-only trees @@ -108,6 +108,44 @@ npm run lite:serve # http://localhost:4173/ Lite keeps everything that runs in the browser (multi-account, password and TOTP login, "remember me" via Stalwart refresh tokens, themes, deep links, demo mode). Server-backed features are off: the admin console and setup wizard, plugins and sidebar apps, settings sync, OAuth/SSO, the account security tab, ICS URL subscriptions and CalDAV discovery, sender favicons, office editing, web push, the update banner, the "Default apps" protocol-handler registration. Password logins go through Stalwart's token endpoints in the browser (`/api/auth` + `/auth/token`, so those need CORS as well); without "remember me" the refresh token lives in sessionStorage and the session ends with the tab. `scripts/lite/verify.mjs` fails the build if a client chunk references a server endpoint that is not documented in `scripts/lite/lib.mjs`, and `npm run test:lite-smoke` drives a demo export with Playwright. +#### Install on Stalwart + +Stalwart 0.16 can host the webmail itself as an `Application`: it downloads a zip, serves it under a path of its own HTTP listener, and the webmail talks to the same origin's JMAP, `/api/auth` and `/auth/token`, so there is no server URL to enter and no CORS to set up. Every release ships `bulwark-lite-stalwart.zip` for this (built with `npm run build:lite -- --target=stalwart`). An administrator creates the Application and then asks Stalwart to fetch it. Creating it alone mounts nothing: + +```bash +stalwart-cli create Application \ + --field description='Bulwark Webmail' \ + --field resourceUrl='https://github.com/bulwarkmail/webmail/releases/latest/download/bulwark-lite-stalwart.zip' \ + --field 'urlPrefix={"/webmail":true}' +stalwart-cli create Action/UpdateApps +``` + +The same in the web admin: **Settings > Web Applications**, create the entry, then run the "update applications" action (or restart Stalwart). Over JMAP, as an administrator: + +```json +{ + "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"], + "methodCalls": [ + ["x:Application/set", { "create": { "webmail": { + "description": "Bulwark Webmail", + "resourceUrl": "https://github.com/bulwarkmail/webmail/releases/latest/download/bulwark-lite-stalwart.zip", + "urlPrefix": { "/webmail": true } + } } }, "0"], + ["x:Action/set", { "create": { "u": { "@type": "UpdateApps" } } }, "1"] + ] +} +``` + +Then open `https:///webmail/`. Notes: + +- **Permissions**: `sysApplicationCreate` (plus `sysApplicationGet`/`Query`/`Update`/`Destroy` to manage it) and `actionUpdateApps`. +- **Prefix**: `urlPrefix` matches the first path segment only, and one bundle may be mounted under several prefixes; it reads its prefix at runtime. Stalwart routes these segments itself, so they cannot host an Application: `jmap`, `dav`, `.well-known`, `auth`, `api`, `scim`, `mail`, `calendar`, `autodiscover`, `login`, `device`, `metrics`, `healthz`, `logo`, `form`, `robots.txt`, and the web admin's `admin` and `account`. +- **Updates**: Stalwart caches the zip for `autoUpdateFrequency` (default 90 days) and downloads `resourceUrl` again at the next restart or `UpdateApps` after that. `UpdateApps` always re-downloads, so it is also how to update right away. Open tabs switch to the new build on their next navigation (one full page load); files the app fetches without a content hash carry the build id, so Stalwart's year-long cache headers never pin an old build. +- **Pinning**: use `https://github.com/bulwarkmail/webmail/releases/download/v/bulwark-lite-stalwart.zip` as `resourceUrl`. +- **OAuth client**: login uses the Application's `oauthClientId`, or `bulwark-webmail` when it is empty. If client registration is required, register that id with the redirect URI `https:////` (one per prefix). +- **Minimum version**: Stalwart 0.16.0 (0.16.19 for `oauthClientId`). +- **Differences from the static-host zip**: one entry document boots every route (Stalwart has no rewrite rules), no `_redirects`/`_headers`/nginx/Caddy files and no `404.html`, `config.json` defaults to the serving Stalwart and hides the server field, and `manifest.json` replaces `manifest.webmanifest`. The bundle is read-only; to rename or rebrand it, run the **Build Static Lite** workflow (or the build above with `LITE_APP_NAME` etc.), host the resulting `bulwark-lite-stalwart.zip` yourself and point `resourceUrl` at it. + ### Development ```bash diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 301611556..41dff480e 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -15,7 +15,7 @@ import { } from "@/lib/admin/domain-branding"; import { withBasePath } from "@/lib/browser-navigation"; import { locales, defaultLocale } from "@/i18n/routing"; -import { IS_LITE } from "@/lib/lite"; +import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite"; import "../globals.css"; // This layout renders and sits ABOVE the [locale] segment, so @@ -239,7 +239,7 @@ function liteLocaleBootstrap(): string { try { var locales = ${JSON.stringify([...locales])}; var rtl = ${JSON.stringify(rtl)}; - var base = ${JSON.stringify(basePath)}; + var base = ${JSON.stringify(basePath)} || (typeof window.${LITE_MOUNT_GLOBAL} === 'string' ? window.${LITE_MOUNT_GLOBAL} : ''); var path = location.pathname; if (base && path.indexOf(base) === 0) path = path.slice(base.length); var seg = path.split('/').filter(Boolean)[0]; diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index dc6ed5ad5..24230b88f 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -3,7 +3,7 @@ import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useTranslations } from "next-intl"; import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useRouter } from "@/i18n/navigation"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; diff --git a/components/files/pdf-mobile-viewer.tsx b/components/files/pdf-mobile-viewer.tsx index aab33550c..cab79cb9d 100644 --- a/components/files/pdf-mobile-viewer.tsx +++ b/components/files/pdf-mobile-viewer.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Loader2, ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { liteMountedAssetUrl } from "@/lib/lite"; // Real inline PDF preview for mobile browsers. Android Chrome / iOS WebKit have // no native inline PDF viewer, so the desktop