diff --git a/.github/workflows/static-lite.yml b/.github/workflows/static-lite.yml new file mode 100644 index 000000000..21364e395 --- /dev/null +++ b/.github/workflows/static-lite.yml @@ -0,0 +1,139 @@ +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 +# (fixed server URL, sub-path mount, locale subset, demo) on demand. +# +# 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). + +on: + release: + types: [published] + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + jmap_server_url: + description: "Default JMAP server URL baked into config.json (editable after download)" + 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)" + required: false + locales: + description: "Comma-separated locale subset, e.g. en,de,fr (default: all)" + required: false + demo: + description: "Build the static demo (built-in demo account, no server)" + type: boolean + default: false + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + 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: Build static export + env: + CI: "true" + GIT_COMMIT: ${{ github.sha }} + NEXT_PUBLIC_BASE_PATH: ${{ inputs.base_path }} + LITE_JMAP_SERVER_URL: ${{ inputs.jmap_server_url }} + LITE_APP_NAME: ${{ inputs.app_name }} + LITE_LOCALES: ${{ inputs.locales }} + LITE_DEMO_MODE: ${{ inputs.demo }} + run: npm run build:lite + + - name: Package zip + env: + REF_NAME: ${{ github.ref_name }} + run: | + 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}" .) + echo "ZIP=${ZIP}" >> "$GITHUB_ENV" + ls -la "${ZIP}" + + - name: Upload release asset + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG_NAME" "$ZIP" --clobber + + - name: Upload artifact + if: github.event_name != 'release' + uses: actions/upload-artifact@v4 + with: + name: bulwark-lite + path: ${{ env.ZIP }} + retention-days: 7 + + smoke: + # Boots the demo export on a plain static server and drives it with + # Playwright: login, open a thread, switch surfaces, reload a deep link. + # No JMAP server involved, so it is cheap and deterministic. + 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 + + # prepare.mjs prunes every test tree in the checkout; the smoke suite + # lives under scripts/lite/smoke, which it leaves alone. + - name: Build demo export + env: + CI: "true" + GIT_COMMIT: ${{ github.sha }} + LITE_DEMO_MODE: "true" + LITE_LOCALES: "en,de" + run: npm run build:lite + + - name: Run smoke test + run: npm run test:lite-smoke + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: lite-smoke-report + path: playwright-report + retention-days: 7 diff --git a/FEATURES.md b/FEATURES.md index d37939c92..54ee35bd2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -157,5 +157,6 @@ - Structured logging (`text` or `json`) with per-category levels - 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 - `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 74674f586..aa38ff521 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,23 @@ npm run build && npm start # Then open http://localhost:3000 to run the setup wizard ``` +### Static Lite build + +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: + +```bash +git worktree add ../bulwark-lite HEAD # disposable checkout: the build deletes server-only trees +cd ../bulwark-lite && npm ci +LITE_JMAP_SERVER_URL=https://mail.example.com npm run build:lite -- --in-place +npm run lite:serve # http://localhost:4173/ +``` + +`out/` then holds one shell per locale and surface plus the deployer files: `config.json` (server URL, app name, login branding; editable without a rebuild), `policy.json`, `manifest.webmanifest`, `_redirects` and `_headers` (Netlify/Cloudflare), `nginx.conf.example`, `Caddyfile.example`, a `404.html` that replays deep links on hosts without rewrite rules, and `LITE-README.md` with the three-step setup. Build inputs: `LITE_JMAP_SERVER_URL`, `LITE_APP_NAME`, `LITE_ALLOW_CUSTOM_ENDPOINT`, `LITE_REMEMBER_ME`, `LITE_DEMO_MODE`, `LITE_LOCALES` (e.g. `en,de`), `NEXT_PUBLIC_BASE_PATH`. + +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. `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. + ### Development ```bash diff --git a/app/(main)/[locale]/calendar/[[...segments]]/page.tsx b/app/(main)/[locale]/calendar/[[...segments]]/page.tsx index 2a8cb8a5c..7ac1875fd 100644 --- a/app/(main)/[locale]/calendar/[[...segments]]/page.tsx +++ b/app/(main)/[locale]/calendar/[[...segments]]/page.tsx @@ -1,10 +1,16 @@ import { CalendarApp } from "@/components/calendar/calendar-app"; +import { generateLiteSegmentParams } from "@/lib/lite-static-params"; // Calendar permalinks (#733): // /calendar the stored view, today // /calendar// a view anchored on a date // /calendar/event/ one event, with ?calendar= to // disambiguate across accounts +// Static Lite export: only the bare surface is prerendered; deep-link segments +// are read from the address bar in the browser (hooks/use-lite-link-segments.ts). +// `undefined` in the server build keeps the route dynamic (lib/lite-static-params.ts). +export const generateStaticParams = generateLiteSegmentParams; + export default async function CalendarRoute({ params, }: { diff --git a/app/(main)/[locale]/contacts/[[...segments]]/page.tsx b/app/(main)/[locale]/contacts/[[...segments]]/page.tsx index 4adf80a48..7c18db300 100644 --- a/app/(main)/[locale]/contacts/[[...segments]]/page.tsx +++ b/app/(main)/[locale]/contacts/[[...segments]]/page.tsx @@ -1,8 +1,14 @@ import { ContactsApp } from "@/components/contacts/contacts-app"; +import { generateLiteSegmentParams } from "@/lib/lite-static-params"; // Contact permalinks (#733): /contacts/[/edit], plus /contacts/new // for the "add this sender" flow. The older query form (?contactId=, ?addEmail=) // still works - see parseContactsPath. +// Static Lite export: only the bare surface is prerendered; deep-link segments +// are read from the address bar in the browser (hooks/use-lite-link-segments.ts). +// `undefined` in the server build keeps the route dynamic (lib/lite-static-params.ts). +export const generateStaticParams = generateLiteSegmentParams; + export default async function ContactsRoute({ params, }: { diff --git a/app/(main)/[locale]/files/[[...segments]]/page.tsx b/app/(main)/[locale]/files/[[...segments]]/page.tsx index 810b38d15..a9782d6f6 100644 --- a/app/(main)/[locale]/files/[[...segments]]/page.tsx +++ b/app/(main)/[locale]/files/[[...segments]]/page.tsx @@ -1,7 +1,13 @@ import { FilesApp } from "@/components/files/files-app"; +import { generateLiteSegmentParams } from "@/lib/lite-static-params"; // Files permalinks (#733): /files//, one path segment per // level, with ?preview= to open a file straight into the preview modal. +// Static Lite export: only the bare surface is prerendered; deep-link segments +// are read from the address bar in the browser (hooks/use-lite-link-segments.ts). +// `undefined` in the server build keeps the route dynamic (lib/lite-static-params.ts). +export const generateStaticParams = generateLiteSegmentParams; + export default async function FilesRoute({ params, }: { diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 19886974f..ce7e7a1db 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -13,7 +13,14 @@ import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog" import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; +import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; +import { generateLiteLocaleParams } from "@/lib/lite-static-params"; + +// The static Lite export enumerates every locale here. In the server build the +// export is `undefined`, which Next treats as absent, so the routes stay +// dynamic (lib/lite-static-params.ts). +export const generateStaticParams = generateLiteLocaleParams; export default async function LocaleLayout({ children, @@ -25,6 +32,9 @@ export default async function LocaleLayout({ const { locale } = await params; if (!(locales as readonly string[]).includes(locale)) notFound(); + // Pins the request locale for next-intl's server helpers, which is what lets + // the tree render without request headers (static export). + setRequestLocale(locale); let messages; try { diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 70bbc5590..ade7b925d 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { Suspense, useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "@/i18n/navigation"; import { useParams, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -19,6 +19,8 @@ import { type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { useUpdateStore, selectBanner } from "@/stores/update-store"; import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; +import { IS_LITE } from "@/lib/lite"; +import { probeLiteTokenLogin } from "@/lib/auth/lite-tokens"; function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined { if (!email || !email.includes("@")) return undefined; @@ -114,7 +116,7 @@ function VersionBadge() { // that funnels password and token material to any caller-supplied URL. const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://"; -export default function LoginPage() { +function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); const params = useParams(); @@ -168,6 +170,10 @@ export default function LoginPage() { const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); const [rememberMe, setRememberMe] = useState(false); + // Lite (no server session): "remember me" needs Stalwart's token login on the + // target server. Probe it once per URL and hide the box when it is missing; + // those sessions then only last as long as the tab (lib/auth/lite-tokens.ts). + const [liteTokenLoginSupported, setLiteTokenLoginSupported] = useState(null); const [sessionExpired, setSessionExpired] = useState(false); const [showPassword, setShowPassword] = useState(false); const [shakeError, setShakeError] = useState(false); @@ -203,6 +209,25 @@ export default function LoginPage() { initializeTheme(); }, [initializeTheme]); + const probeTarget = hasServerList + ? selectedServer?.url ?? "" + : (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl) || ""; + useEffect(() => { + if (!IS_LITE || !probeTarget) return; + let cancelled = false; + // Debounced: the custom endpoint field fires per keystroke. + const timer = setTimeout(() => { + probeLiteTokenLogin(probeTarget).then((supported) => { + if (!cancelled) setLiteTokenLoginSupported(supported); + }); + }, 500); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [probeTarget]); + const showRememberMe = rememberMeEnabled && (!IS_LITE || liteTokenLoginSupported !== false); + useEffect(() => { if (serverUrl) { document.title = appName; @@ -1235,7 +1260,7 @@ export default function LoginPage() { )} {/* Remember me */} - {rememberMeEnabled && ( + {showRememberMe && (