Files
webmail/scripts/lite/lib.mjs
T

1821 lines
80 KiB
JavaScript

// Shared, side-effect-free helpers for the Lite build scripts. Kept separate
// so lib/__tests__/lite-scripts.test.ts can exercise them without touching
// the filesystem or spawning `next build`.
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { crc32, deflateRawSync, inflateRawSync } from "node:zlib";
/**
* Repository root for a script under scripts/lite/. Falls back to the current
* directory when `import.meta.url` is not a file URL (vitest's transform).
*/
export function resolveRepoRoot(metaUrl) {
try {
return resolve(fileURLToPath(new URL("../..", metaUrl)));
} catch {
return process.cwd();
}
}
/** True when the module was started directly (`node scripts/lite/x.mjs`). */
export function isMainModule(metaUrl) {
try {
return !!process.argv[1] && resolve(process.argv[1]) === fileURLToPath(metaUrl);
} catch {
return false;
}
}
/** Server-only trees the static export cannot contain. */
export const LITE_REMOVED_PATHS = [
"proxy.ts",
"app/api",
"app/(main)/admin",
"app/(main)/setup",
"app/(sandbox)",
"app/manifest.ts",
"instrumentation.ts",
"instrumentation.node.ts",
"app/(main)/[...rest]",
"app/(main)/[locale]/[...rest]",
"app/(main)/protocol",
"e2e",
"integration",
];
/** Test trees are pruned too: they import the routes removed above. */
export const LITE_TEST_DIR_NAME = "__tests__";
export const LITE_TEST_FILE_PATTERN = /\.test\.[cm]?[jt]sx?$/;
export const LITE_PRUNE_SKIP_DIRS = new Set(["node_modules", ".git", ".next", "out", "repos", "scripts"]);
/** The client shells a deployer's host must SPA-fallback to. */
export const LITE_SURFACES = ["mail", "calendar", "contacts", "files", "settings"];
/** Locales written right-to-left; the root shim sets `dir` for them. */
export const RTL_LOCALES = ["ar", "fa", "he"];
/**
* sessionStorage key the 404 shim parks a deep link under. Mirrors
* LITE_PENDING_PATH_KEY in lib/lite.ts (a TS module this script cannot
* import); lib/__tests__/lite-scripts.test.ts pins the two together.
*/
export const LITE_PENDING_PATH_KEY = "bulwark-lite:pending-path";
/** zustand persist key of stores/locale-store.ts (the user's language choice). */
export const LOCALE_STORAGE_KEY = "locale-storage";
/**
* `/api/` strings that legitimately survive in the Lite client chunks. Each
* one is either gated at call time (`IS_LITE`, a policy flag, a config flag
* the Lite config pins off) or tolerates a 404 by design. Anything else is a
* new server dependency and fails `verify.mjs`.
*/
export const LITE_API_STRING_ALLOWLIST = [
// Path-independent generic prefix used by `apiFetch` docs/comments.
"/api/",
// stores/update-store.ts - startPolling() is a no-op in Lite.
"/api/system/update-status",
// stores/plugin-store.ts + theme-store.ts - initializePlugins()/syncServerThemes() return early in Lite.
"/api/plugins",
"/api/plugin-approval-status",
"/api/plugin-signing-pubkey",
"/api/admin/plugins",
"/api/admin/themes",
// lib/plugin-sandbox/proxy-client.ts + connect-dialog.ts - only called by
// loaded plugins and the plugins settings tab; pluginsEnabled is pinned off.
"/api/plugin-proxy",
"/api/plugin-secrets",
// stores/settings-store.ts - settingsSyncEnabled is pinned off.
"/api/settings",
// Stalwart's own account discovery (lib/auth/lite-oauth.ts), not a route of this app.
"/api/discover",
// OAuth / SSO / pairing - Lite discovers and redeems codes in the browser
// (lib/auth/lite-oauth.ts); stalwartFeaturesEnabled is pinned off.
"/api/auth/token",
"/api/auth/oauth/metadata",
"/api/auth/sso/start",
"/api/auth/sso/complete",
"/api/auth/reauth/sso/complete",
"/api/auth/pair/create",
"/api/auth/pair/status",
"/api/auth/totp-token-exchange",
"/api/auth/session",
"/api/auth/verify",
"/api/auth/stalwart-context",
"/api/account/stalwart/jmap",
// Admin shield probe - skipped in Lite (components/layout/navigation-rail.tsx).
"/api/admin/auth",
// Settings/policy - replaced by config.json / policy.json.
"/api/config",
"/api/admin/policy",
// lib/account-archive-client.ts - the feature is pinned off in Lite.
"/api/account-archive",
// WOPI (#425) - use-wopi-status returns disabled in Lite, so "New file"
// never fetches a document template either (#1393).
"/api/wopi/status",
"/api/wopi/launch",
"/api/wopi/template",
// Calendar: ICS URL import/subscriptions + CalDAV discovery are hidden in Lite.
"/api/fetch-ical",
"/api/caldav/discover",
"/api/webdav",
// Sender favicons - disabled in Lite (initials fallback).
"/api/favicon",
// Web push relay paths (relative to the *relay* origin, not this host).
"/api/push/vapid-public-key",
"/api/push/register",
"/api/push/active",
"/api/push/verify",
"/api/push/jmap",
// Dev-only JMAP mock.
"/api/jmap",
"/api/dev-jmap",
];
export function parseBool(value, fallback = false) {
if (value === undefined || value === null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
export function normalizeBasePath(raw) {
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (!trimmed.startsWith("/")) throw new Error(`base path must start with "/" (got ${JSON.stringify(raw)})`);
return trimmed;
}
/**
* config.json contents from the LITE_* build inputs. The Stalwart target is
* served by the mail server itself: an empty server URL means "this origin"
* there, so the server field stays hidden unless a build asks for it.
*
* The static target leaves `allowCustomJmapEndpoint` out unless the build sets
* it: the app then derives it from `jmapServerUrl` at runtime. Writing the
* derived `true` of an empty server URL kept the server field on the login
* page after a deployer filled in `jmapServerUrl` and nothing else (#1087).
*/
export function buildLiteConfig(env = {}, { target = "static" } = {}) {
const jmapServerUrl = (env.LITE_JMAP_SERVER_URL ?? "").trim().replace(/\/+$/, "");
const stalwart = target === "stalwart";
const customEndpointSet = (env.LITE_ALLOW_CUSTOM_ENDPOINT ?? "") !== "";
return {
_comment: stalwart
? "Bulwark Lite for Stalwart. Read-only inside the Application bundle: to change it, build your own zip (see LITE-README.md). An empty jmapServerUrl means the Stalwart server that serves this page."
: "Bulwark Lite runtime configuration. Edit and re-upload; no rebuild needed. See LITE-README.md.",
appName: (env.LITE_APP_NAME ?? "").trim() || "Bulwark Webmail",
jmapServerUrl,
...(stalwart || customEndpointSet
? { allowCustomJmapEndpoint: parseBool(env.LITE_ALLOW_CUSTOM_ENDPOINT, false) }
: {}),
rememberMeEnabled: parseBool(env.LITE_REMEMBER_ME, true),
demoMode: parseBool(env.LITE_DEMO_MODE, false),
// Written out so deployers find the switch (#903; lib/outgoing-hosts.ts).
noThirdPartyTraffic: parseBool(env.LITE_NO_THIRD_PARTY_TRAFFIC, false),
loginShowTotp: true,
loginShowVersion: true,
};
}
/** policy.json: only the gates Lite must pin; the app merges the rest over its defaults. */
/**
* What `/api/connector/capabilities` answers on a server build, as a file.
*
* Lite is a static export with no route handlers, so a connector probing this
* instance gets this instead - same document, same meaning. The connector
* tries the API path first and falls back to `/connector.json`.
*
* The target names are read out of lib/connector/registry.ts rather than
* duplicated here: a list that drifts would have this build telling connectors
* it can resolve links it cannot.
*/
export function buildConnectorCapabilities({ appName, version, basePath = "" }) {
const source = readFileSync(
join(resolveRepoRoot(import.meta.url), "lib", "connector", "registry.ts"),
"utf8",
);
const targets = [...source.matchAll(/^\s{4}name: '([a-z0-9_]+)',$/gm)].map((m) => m[1]);
if (targets.length === 0) {
throw new Error("no connector targets found in lib/connector/registry.ts");
}
return (
JSON.stringify(
{
product: "bulwark-webmail",
appName: appName || "Bulwark",
version: version || null,
connectorPath: `${basePath || ""}/connector`,
targets,
},
null,
2,
) + "\n"
);
}
export function buildLitePolicy() {
return {
_comment: "Optional admin policy for Bulwark Lite (same shape as the admin dashboard's policy). Plugins and sidebar apps stay off in Lite.",
features: {
pluginsEnabled: false,
sidebarAppsEnabled: false,
},
};
}
export function buildManifest({ appName, basePath = "" }) {
const p = (path) => `${basePath}${path}`;
return {
name: appName,
short_name: appName,
description: `${appName} - webmail for JMAP servers`,
start_url: p("/"),
scope: p("/"),
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: p("/icon-192x192.png"), sizes: "192x192", type: "image/png" },
{ src: p("/icon-512x512.png"), sizes: "512x512", type: "image/png" },
{ src: p("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: p("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
/** Root index.html: pick the visitor's locale in the browser and jump to it. */
export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en" }) {
const list = JSON.stringify(locales);
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<title>Loading</title>
<script>
(function () {
var locales = ${list};
var fallback = ${JSON.stringify(fallback)};
var base = ${JSON.stringify(basePath)};
function pick(tag) {
if (!tag) return null;
tag = String(tag);
if (locales.indexOf(tag) !== -1) return tag;
var lower = tag.toLowerCase();
if (lower.indexOf("zh") === 0) {
var traditional = /zh-(tw|hk|mo|hant)/.test(lower);
if (traditional && locales.indexOf("zh-TW") !== -1) return "zh-TW";
if (locales.indexOf("zh") !== -1) return "zh";
}
var short = lower.split("-")[0];
for (var i = 0; i < locales.length; i++) if (locales[i].toLowerCase() === short) return locales[i];
return null;
}
var chosen = null;
// The language picked in Settings (stores/locale-store.ts) wins over the browser's list.
try {
var stored = JSON.parse(localStorage.getItem(${JSON.stringify(LOCALE_STORAGE_KEY)}) || "null");
chosen = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !chosen && j < langs.length; j++) chosen = pick(langs[j]);
location.replace(base + "/" + (chosen || fallback) + "/" + location.search + location.hash);
})();
</script>
</head>
<body><noscript>This app needs JavaScript. <a href="${basePath}/${fallback}/">Continue</a></noscript></body>
</html>
`;
}
/**
* The not-found texts of each built locale for the 404 shim: the title and
* text of app/(main)/[locale]/not-found.tsx and its button.
*
* @param {string} root
* @param {string[]} locales
* @returns {Record<string, { title: string, description: string, home: string }>}
*/
export function readNotFoundMessages(root, locales) {
/** @type {Record<string, { title: string, description: string, home: string }>} */
const messages = {};
for (const locale of locales) {
try {
const table = JSON.parse(readFileSync(join(root, "locales", locale, "common.json"), "utf8"));
const title = table?.not_found?.title;
const description = table?.not_found?.description;
const home = table?.errors?.go_home;
if (typeof title === "string" && typeof description === "string" && typeof home === "string") {
messages[locale] = { title, description, home };
}
} catch {
// A locale without a table keeps the English page.
}
}
return messages;
}
/**
* 404.html for hosts without rewrite rules (GitHub Pages, S3 website hosting).
*
* `next build` exports Next's default not-found page as 404.html: the
* `[...rest]` catch-all that renders app/(main)/not-found.tsx in the server
* build is deleted for the export, and a route group's not-found.tsx only
* serves `notFound()` calls inside that group. So the park-and-replay logic
* lives in this dependency-free shim instead: a deep link below a known
* surface is parked in sessionStorage and the surface shell is loaded, which
* hands the link over (hooks/use-lite-link-segments.ts). Only `<locale>` and
* `<surface>` from the allowlists ever reach `location.replace`, so a crafted
* URL cannot turn this into an open redirect.
*
* A path without a locale (/login, /mail/...) goes to the same path under
* the reader's locale, picked as the root index.html picks it; the target
* stays on this origin and starts with an allowlisted locale.
*
* A page that stays a 404 speaks the language of its URL (/de/gibtsnicht in
* German), else the reader's: `messages` holds each built locale's texts,
* which are set as text, never as markup.
*/
export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURFACES, defaultLocale = "en", messages = {} }) {
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
const english = messages.en ?? {
title: "Page not found",
description: "This page doesn't exist or has moved. Check the address, or go back to your inbox.",
home: "Go to inbox",
};
const built = {};
for (const locale of locales) if (messages[locale]) built[locale] = messages[locale];
const escapeHtml = (text) => String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>404</title>
<script>
(function () {
var base = ${JSON.stringify(basePath)};
var locales = ${JSON.stringify(locales)};
var surfaces = ${JSON.stringify(surfaces)};
var path = location.pathname;
if (base) {
if (path !== base && path.indexOf(base + "/") !== 0) return;
path = path.slice(base.length) || "/";
}
var parts = path.split("/").filter(Boolean);
if (parts.length > 0 && locales.indexOf(parts[0]) === -1) {
var fallback = ${JSON.stringify(fallback)};
var pick = function (tag) {
if (!tag) return null;
tag = String(tag);
if (locales.indexOf(tag) !== -1) return tag;
var lower = tag.toLowerCase();
if (lower.indexOf("zh") === 0) {
if (/zh-(tw|hk|mo|hant)/.test(lower) && locales.indexOf("zh-TW") !== -1) return "zh-TW";
if (locales.indexOf("zh") !== -1) return "zh";
}
var short = lower.split("-")[0];
for (var i = 0; i < locales.length; i++) if (locales[i].toLowerCase() === short) return locales[i];
return null;
};
var chosen = null;
try {
var stored = JSON.parse(localStorage.getItem(${JSON.stringify(LOCALE_STORAGE_KEY)}) || "null");
chosen = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !chosen && j < langs.length; j++) chosen = pick(langs[j]);
location.replace(base + "/" + (chosen || fallback) + "/" + parts.join("/") + location.search + location.hash);
return;
}
if (parts.length < 2 || locales.indexOf(parts[0]) === -1 || surfaces.indexOf(parts[1]) === -1) return;
var target = base + "/" + parts[0] + "/" + parts[1] + "/";
// The shell itself is missing (host misconfigured): show the 404 instead of looping.
if (location.pathname === target) return;
try { sessionStorage.setItem(${JSON.stringify(LITE_PENDING_PATH_KEY)}, location.pathname + location.search); } catch (e) {}
location.replace(target);
})();
</script>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #fff; color: #0a0a0a; }
@media (prefers-color-scheme: dark) { body { background: #0a0a0a; color: #fafafa; } a { background: #fafafa; color: #0a0a0a; } }
main { text-align: center; padding: 1rem; max-width: 28rem; }
.code { margin: 0 0 .5rem; font-size: .875rem; font-weight: 600; color: #737373; }
h1 { font-size: 1.5rem; font-weight: 700; margin: 0 0 .5rem; }
p { margin: 0 0 1.5rem; color: #737373; }
a { display: inline-block; padding: .5rem 1rem; border-radius: .375rem; background: #0a0a0a; color: #fafafa; font-size: .875rem; font-weight: 500; text-decoration: none; }
</style>
</head>
<body>
<main>
<p class="code">404</p>
<h1 id="not-found-title">${escapeHtml(english.title)}</h1>
<p id="not-found-text">${escapeHtml(english.description)}</p>
<a id="not-found-home" href="${basePath}/">${escapeHtml(english.home)}</a>
</main>
<script>
(function () {
var base = ${JSON.stringify(basePath)};
var messages = ${inlineJson(built)};
var fallback = ${JSON.stringify(fallback)};
var rtl = ${JSON.stringify(RTL_LOCALES)};
var pick = function (tag) {
if (!tag) return null;
tag = String(tag);
if (messages[tag]) return tag;
var lower = tag.toLowerCase();
if (lower.indexOf("zh") === 0) {
if (/zh-(tw|hk|mo|hant)/.test(lower) && messages["zh-TW"]) return "zh-TW";
if (messages.zh) return "zh";
}
var short = lower.split("-")[0];
for (var key in messages) if (Object.prototype.hasOwnProperty.call(messages, key) && key.toLowerCase() === short) return key;
return null;
};
var path = location.pathname;
if (base && path.indexOf(base + "/") === 0) path = path.slice(base.length);
// The locale of the URL first, then the one picked in Settings, then the browser's.
var first = path.split("/").filter(Boolean)[0];
var locale = first && Object.prototype.hasOwnProperty.call(messages, first) ? first : null;
if (!locale) {
try {
var stored = JSON.parse(localStorage.getItem(${JSON.stringify(LOCALE_STORAGE_KEY)}) || "null");
locale = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !locale && j < langs.length; j++) locale = pick(langs[j]);
if (!locale) locale = messages[fallback] ? fallback : null;
if (!locale) return;
var text = messages[locale];
document.documentElement.lang = locale;
document.documentElement.dir = rtl.indexOf(locale) !== -1 ? "rtl" : "ltr";
document.title = "404 - " + text.title;
document.getElementById("not-found-title").textContent = text.title;
document.getElementById("not-found-text").textContent = text.description;
var home = document.getElementById("not-found-home");
home.textContent = text.home;
home.setAttribute("href", base + "/" + locale + "/");
})();
</script>
</body>
</html>
`;
}
/** Netlify / Cloudflare Pages rewrite rules. */
export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACES }) {
const lines = [
"# Bulwark Lite SPA fallback (Netlify / Cloudflare Pages). Deep links below a",
"# surface serve that surface's shell; everything else keeps its own file.",
];
for (const locale of locales) {
for (const surface of surfaces) {
lines.push(`${basePath}/${locale}/${surface}/* ${basePath}/${locale}/${surface}/index.html 200`);
}
// /<locale>/login etc. resolve directly; a bare locale root goes to mail.
}
return lines.join("\n") + "\n";
}
/** The Content-Security-Policy every static host should send (`_headers`, the container image). */
export function buildCsp({ connectSrc = "*", noThirdParty = false } = {}) {
if (noThirdParty) {
const strict = noThirdPartyDirectives(connectSrc);
const byName = new Map(strict.map((d) => [d.split(" ")[0], d]));
return buildCsp({ connectSrc }).split("; ").map((d) => byName.get(d.split(" ")[0]) ?? d).join("; ");
}
return [
"default-src 'self'",
// The static export ships inline hydration scripts, so no nonce is possible
// and one header cannot list every page's hashes. Each page of the static
// target carries its own <meta> policy that allows exactly its inline
// scripts (withScriptHashCsp); both policies apply, so this only matters
// where that meta is missing.
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' https: data:",
`connect-src 'self' ${connectSrc}`,
"frame-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
// 'self' because the PDF preview frames a blob: document, and Safari
// applies this policy to that document as well (#1301).
"frame-ancestors 'self'",
"media-src 'self' blob:",
].join("; ");
}
/** The `'sha256-...'` source for every inline script in an HTML document. */
export function inlineScriptHashes(html) {
const hashes = new Set();
for (const [, attrs, body] of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) {
if (/\bsrc\s*=/i.test(attrs)) continue;
// Data blocks (JSON, templates) never execute.
const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
if (type && type !== "module" && !/^(text|application)\/(x-)?(java|ecma)script$/.test(type)) continue;
// Browsers normalise newlines before the script text is hashed.
const text = body.replace(/\r\n?/g, "\n");
hashes.add(`'sha256-${createHash("sha256").update(text, "utf8").digest("base64")}'`);
}
return [...hashes];
}
/**
* The page-level policy of a static-target HTML file: its own inline scripts
* and scripts from this origin, nothing else. Enforced together with the
* host's header policy, it takes away the header's 'unsafe-inline'.
*/
export function buildScriptHashCsp(hashes, { noThirdParty = false, connectSrc = "*" } = {}) {
return [
`script-src 'self'${hashes.map((h) => ` ${h}`).join("")}`,
"object-src 'none'",
"base-uri 'self'",
...(noThirdParty ? noThirdPartyDirectives(connectSrc) : []),
].join("; ");
}
/**
* A build made with LITE_NO_THIRD_PARTY_TRAFFIC=true (#903) fences the page
* like the server build's header does: its own origin and the JMAP server
* only, so a link the in-app switches miss still can't load anything from
* an outside host. `connectSrc` is the JMAP origin, or `*` when users may
* type any server (then only connect-src stays open).
*/
export function noThirdPartyDirectives(connectSrc = "*") {
const own = connectSrc === "*" ? "" : ` ${connectSrc}`;
return [
`connect-src 'self' ${connectSrc}`,
`img-src 'self' data: blob:${own}`,
"font-src 'self' data:",
"media-src 'self' blob:",
];
}
/**
* `html` with a <meta> CSP naming its inline script hashes, ahead of every
* script: right after <meta charset> (which has to stay within the first
* 1024 bytes), else as early in the document as possible.
*/
export function withScriptHashCsp(html, options = {}) {
const meta = `<meta http-equiv="Content-Security-Policy" content="${buildScriptHashCsp(inlineScriptHashes(html), options)}"/>`;
// Without a <head> the parser still places a leading <meta> in the implied one.
const anchor = /<meta\s+charset\s*=[^>]*>/i.exec(html) ?? /<head\b[^>]*>/i.exec(html)
?? /<html\b[^>]*>/i.exec(html) ?? /<!doctype[^>]*>/i.exec(html);
const at = anchor ? anchor.index + anchor[0].length : 0;
return html.slice(0, at) + meta + html.slice(at);
}
/**
* The policy of the Stalwart target's entry document. Stalwart sends no
* security headers for an Application, so this <meta> is all there is. The
* shells it writes in are rewritten for the mount at runtime, which rules
* out build-time hashes; it still forbids plugins, eval, foreign scripts and
* a moved <base>. frame-ancestors cannot be set from a <meta>: the entry
* refuses to run in a frame of another origin instead.
*/
export function buildStalwartEntryCsp() {
return buildCsp({ connectSrc: "*" })
.split("; ")
.filter((directive) => !directive.startsWith("frame-ancestors"))
.join("; ");
}
/** Security headers for hosts that read a `_headers` file. */
export function buildHeaders({ basePath = "", connectSrc = "*", noThirdParty = false }) {
const csp = buildCsp({ connectSrc, noThirdParty });
return `${basePath || ""}/*
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Content-Security-Policy: ${csp}
${basePath || ""}/_next/static/*
Cache-Control: public, max-age=31536000, immutable
${basePath || ""}/connector.json
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=300
`;
}
/**
* Where the unzipped folder has to live for the nginx/Caddy examples: every
* rule below is root-relative, so a sub-path mount means the files sit in a
* folder named after the mount path inside the web root (no `alias` needed,
* which does not work inside a regex `location`).
*/
export function exampleDocRoot(basePath = "") {
return { root: "/var/www/bulwark-lite", files: `/var/www/bulwark-lite${basePath}` };
}
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*", noThirdParty = false }) {
const location = basePath ? `${basePath}/` : "/";
const csp = buildCsp({ connectSrc, noThirdParty });
const { root, files } = exampleDocRoot(basePath);
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - nginx example. Unzip the archive into ${files}
# so that ${basePath}/index.html is served at ${location}; deep links below a surface
# are rewritten to that surface's shell.
server {
listen 80;
server_name webmail.example.com;
root ${root};
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# connect-src names the JMAP server this bundle was built for. Every page
# also carries a <meta> policy that allows only its own inline scripts.
add_header Content-Security-Policy "${csp}" always;
# Hashed assets never change. (add_header inside a location replaces the
# inherited set, so the security headers are repeated here.)
location ${basePath}/_next/static/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "${csp}" always;
}
# Older mime.types files do not know the PWA manifest extension.
location = ${basePath}/manifest.webmanifest {
types { }
default_type application/manifest+json;
}
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
location ~ ^${basePath}/(?<locale>[a-zA-Z-]+)/(?<surface>${surfaceAlternation})(/.*)?$ {
try_files $uri $uri/ $uri/index.html ${basePath}/$locale/$surface/index.html;
}
location ${location} {
try_files $uri $uri/ $uri/index.html =404;
error_page 404 ${basePath}/404.html;
}
}
`;
}
/** Env var the container image substitutes into the CSP's connect-src. */
export const CONTAINER_CONNECT_SRC_ENV = "LITE_CSP_CONNECT_SRC";
/** Set by the image's entrypoint hook: the IPv6 `listen` line, or empty on hosts without IPv6. */
export const CONTAINER_LISTEN_IPV6_ENV = "LITE_LISTEN_IPV6";
export const CONTAINER_PORT = 8080;
/** Static-host helpers that mean nothing inside the image. */
export const CONTAINER_EXCLUDED_FILES = ["_redirects", "_headers", "nginx.conf.example", "Caddyfile.example", "LITE-README.md"];
/**
* nginx config for the container image (Dockerfile.lite), as a template for
* the nginx image's envsubst hook (/etc/nginx/templates/*.template): the same
* routing as the nginx example, plus the full security headers - nginx does
* not read `_headers` - with connect-src left to the deployer, because the
* JMAP server is only known once config.json is mounted. Root mount only.
*/
export function buildNginxContainerTemplate({ surfaces = LITE_SURFACES } = {}) {
const surfaceAlternation = surfaces.join("|");
const csp = buildCsp({ connectSrc: `\${${CONTAINER_CONNECT_SRC_ENV}}` });
// add_header inside a location replaces the inherited set, so every
// location with headers of its own repeats these.
const securityHeaders = (indent) => [
"add_header X-Content-Type-Options nosniff always;",
"add_header X-Frame-Options SAMEORIGIN always;",
"add_header Referrer-Policy strict-origin-when-cross-origin always;",
'add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;',
`add_header Content-Security-Policy "${csp}" always;`,
].map((line) => indent + line).join("\n");
return `# Bulwark Lite - nginx config of the container image. Generated by
# scripts/lite/container.mjs; the ${CONTAINER_CONNECT_SRC_ENV} and ${CONTAINER_LISTEN_IPV6_ENV} placeholders
# are filled in when the container starts.
server {
listen ${CONTAINER_PORT};
\${${CONTAINER_LISTEN_IPV6_ENV}}
server_name _;
root /usr/share/nginx/html;
server_tokens off;
# The published port rarely is ${CONTAINER_PORT}: never put it into a redirect.
absolute_redirect off;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css text/plain application/javascript application/json application/manifest+json image/svg+xml;
${securityHeaders(" ")}
# Shells, config.json and policy.json are revalidated, so an image update
# or an edited mount shows up on the next load.
add_header Cache-Control "no-cache" always;
# Hashed assets never change.
location /_next/static/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
${securityHeaders(" ")}
}
# Older mime.types files do not know the PWA manifest extension.
location = /manifest.webmanifest {
types { }
default_type application/manifest+json;
}
# Connectors probe this cross-origin.
location = /connector.json {
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=300" always;
${securityHeaders(" ")}
}
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
location ~ ^/(?<locale>[a-zA-Z-]+)/(?<surface>${surfaceAlternation})(/.*)?$ {
try_files $uri $uri/ $uri/index.html /$locale/$surface/index.html;
}
location / {
try_files $uri $uri/ $uri/index.html =404;
error_page 404 /404.html;
}
}
`;
}
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*", noThirdParty = false }) {
const { root, files } = exampleDocRoot(basePath);
const csp = buildCsp({ connectSrc, noThirdParty });
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - Caddy example. Unzip the archive into ${files}
# so that ${basePath}/index.html is served at ${basePath || ""}/.
webmail.example.com {
root * ${root}
encode gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
# connect-src names the JMAP server this bundle was built for.
Content-Security-Policy "${csp}"
}
@manifest path ${basePath}/manifest.webmanifest
header @manifest Content-Type application/manifest+json
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
@surface path_regexp surface ^${basePath}/([a-zA-Z-]+)/(${surfaceAlternation})(/.*)?$
handle @surface {
try_files {path} {path}/ {path}/index.html ${basePath}/{re.surface.1}/{re.surface.2}/index.html
}
handle {
try_files {path} {path}/ {path}/index.html
}
file_server
# Anything else is a real 404, answered by the shim that replays parked deep links.
handle_errors {
@notfound expression {http.error.status_code} == 404
rewrite @notfound ${basePath}/404.html
file_server
}
}
`;
}
export function buildReadme({ version, commit, basePath = "", locales, jmapServerUrl = "", demoMode = false }) {
return `# Bulwark Lite ${version} (${commit})
A static build of Bulwark Webmail: the same mail, calendar, contacts and files
client, without the Node.js server. Upload this folder to any static host and
point it at your Stalwart (or other JMAP) server.
Built for mount path: ${basePath || "/ (site root)"}
Locales included: ${locales.join(", ")}
${demoMode ? "Demo mode is ON: the login page offers a built-in demo account and no server is needed.\n" : ""}
## Three steps
1. Unzip this archive and upload the whole folder to your web host, so that
\`${basePath || ""}/index.html\` and \`${basePath || ""}/config.json\` are served from the mount path above.
2. Edit \`config.json\`:
- \`jmapServerUrl\`: your mail server, e.g. \`https://mail.example.com\`${jmapServerUrl ? ` (currently \`${jmapServerUrl}\`)` : ""}.
- \`appName\`: the name shown in the tab and on the login page.
- \`allowCustomJmapEndpoint\` (optional): \`true\` always shows a server field
on the login page, \`false\` never does. Left out, the field shows only
while \`jmapServerUrl\` is empty.
- \`rememberMeEnabled\`: \`false\` never remembers a sign-in (sessions then end with the tab).
Sign-ins are otherwise remembered; \`loginShowRememberMe: true\` lets people choose.
- \`noThirdPartyTraffic\`: \`true\` keeps the browser off every built-in outside host
(no Google Maps links on event addresses; see the third-party traffic docs).
- \`oauthRevokeOnSignOut\`: \`false\` keeps the refresh token valid at the server on
sign-out, so Stalwart 1.0 does not ask for consent again on the next sign-in.
Optional keys: \`demoMode\`, \`jmapServers\`, \`jmapServerAutoPickByDomain\`, the login logo/company/link keys and \`loginShow*\` toggles (same names as the Docker env vars, camelCased).
3. Allow the browser to talk to the mail server (CORS). In Stalwart, turn on
the \`usePermissiveCors\` setting of the \`Http\` object: in the web admin
under **Settings > Network > HTTP > Security** ("Permissive CORS policy"),
or with
\`\`\`bash
stalwart-cli update Http --field usePermissiveCors=true
stalwart-cli create Action/ReloadSettings
\`\`\`
or over JMAP as an administrator: \`x:Http/set\` with
\`{"update": {"singleton": {"usePermissiveCors": true}}}\`. It applies once
the settings are reloaded (the \`ReloadSettings\` action) or Stalwart
restarts. On the current 1.0 pre-releases \`ReloadSettings\` fails while the
stock spam-filter rules are loaded, so restart Stalwart there instead.
Stalwart 0.16 and later ignore the \`[http] permissive-cors\` key of the old
TOML configuration.
A reverse-proxy rule works too if it allows your Lite origin with the
\`Authorization\` and \`Content-Type\` headers on \`/.well-known/jmap\`, \`/jmap/*\`,
\`/api/auth\`, \`/auth/token\` and, for Stalwart 1.0, \`/auth/register\`.
## Host configuration
Deep links such as \`${basePath}/en/mail/thread/abc\` are served by the shell at
\`${basePath}/en/mail/index.html\`. Configure your host to fall back to it:
- Netlify / Cloudflare Pages: the shipped \`_redirects\` and \`_headers\` files do this.
- nginx: see \`nginx.conf.example\`.
- Caddy: see \`Caddyfile.example\`.
${basePath ? ` Both examples serve root-relative paths, so unzip into \`<web root>${basePath}\`\n (e.g. \`${exampleDocRoot(basePath).files}\`), not into the web root itself.\n` : ""}- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link
in the browser. It works, with one extra page load.
\`_headers\` also carries the recommended security headers. Adjust
\`connect-src\` to your JMAP server's origin if you prefer a strict policy.
## What is different from the full Bulwark Webmail
Everything that runs in the browser works: mail, threads, search, compose,
multiple accounts, calendar, contacts, files, themes, settings export/import,
password and TOTP login, demo mode, deep links.
Not available in Lite (they need the Node.js server): the admin console and
setup wizard, plugins and sidebar apps, settings sync across devices, OAuth /
SSO login, the account security tab (app passwords, 2FA setup), ICS URL
subscriptions and CalDAV discovery, sender favicons, office (WOPI) editing,
web push notifications, the update banner, device pairing, and the
"Default apps" page (registering Lite as the mailto:/webcal: handler).
## Security notes
- No cookies and no server session. Requests to the mail server carry the
token or credentials of the signed-in account only.
- "Remember me" keeps a Stalwart refresh token in the browser's localStorage
(a plain token, never the password). Without "remember me" the token lives
in sessionStorage and the session ends with the tab. Both are readable by
any script running on your Lite origin, so serve Lite from an origin you
control and keep the Content-Security-Policy from \`_headers\`.
- Servers without Stalwart's token login (\`/api/auth\`) fall back to Basic
auth. "Remember me" is then hidden; the credentials stay in sessionStorage
for the lifetime of the tab so a reload does not sign you out. The same
fallback applies when a reverse proxy in front of Stalwart answers
\`/api/auth\` without CORS headers: the login still works, but "remember me"
then only lasts for the tab, so add the CORS rule from step 3 there too.
- Because the export contains inline scripts, \`script-src\` must allow
\`'unsafe-inline'\`. Email HTML is still rendered sanitised in a sandboxed
frame, exactly as in the full build.
`;
}
/** Locales actually present in the export (dirs that hold a mail shell). */
export function discoverBuiltLocales(outDir) {
if (!existsSync(outDir)) return [];
return readdirSync(outDir)
.filter((name) => {
const dir = join(outDir, name);
return statSync(dir).isDirectory() && existsSync(join(dir, "mail", "index.html"));
})
.sort();
}
/** Every `"/api/...` string literal found in the client chunks, deduplicated. */
export function collectApiStrings(chunksDir) {
const found = new Set();
if (!existsSync(chunksDir)) return [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full);
else if (entry.endsWith(".js")) {
const text = readFileSync(full, "utf8");
for (const match of text.matchAll(/["'`](\/api\/[A-Za-z0-9/_-]*)/g)) found.add(match[1]);
}
}
};
walk(chunksDir);
return [...found].sort();
}
export function unexpectedApiStrings(strings, allowlist = LITE_API_STRING_ALLOWLIST) {
return strings.filter((s) => !allowlist.some((allowed) => s === allowed || s.startsWith(`${allowed}/`) || (allowed.endsWith("/") && s === allowed.slice(0, -1))));
}
// ---------------------------------------------------------------------------
// Stalwart `Application` target (`npm run build:lite -- --target=stalwart`)
// ---------------------------------------------------------------------------
//
// Stalwart 0.16 downloads a zip from the Application's `resourceUrl` and
// serves it under `urlPrefix` (crates/common/src/manager/application.rs,
// crates/http/src/request.rs). The rules that shape this target:
//
// - lookup is an exact match of the path after `/<prefix>/` against a zip
// entry name; everything else (directories, deep links, typos) gets the
// root index.html - there is no directory index and no 404;
// - only that root index.html is rewritten (`<base href="/"` -> the prefix,
// `<meta name="oauth-client-id" content=""` -> the Application's client
// id) and sent `no-cache`; every other entry is `immutable` for a year;
// - content types come from a fixed extension map (below), the rest is
// application/octet-stream;
// - the bundle is same-origin with /jmap, /api/auth and /auth/token.
export const LITE_TARGETS = ["static", "stalwart"];
/** `--target=stalwart` (or `--target stalwart`) wins over LITE_TARGET. */
export function parseLiteTarget(argv = [], env = {}) {
let value = env.LITE_TARGET;
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith("--target=")) value = argv[i].slice("--target=".length);
else if (argv[i] === "--target" && argv[i + 1]) value = argv[i + 1];
}
const target = (value ?? "").trim().toLowerCase() || "static";
if (!LITE_TARGETS.includes(target)) throw new Error(`unknown Lite target ${JSON.stringify(value)} (expected ${LITE_TARGETS.join(" or ")})`);
return target;
}
/**
* Release asset name. It carries no version, so `releases/latest/download/<this>`
* exists too, but the README points `resourceUrl` at a tagged release.
*/
export const STALWART_ZIP_NAME = "bulwark-lite-stalwart.zip";
/** The mount prefix the docs and the one-line install use. */
export const STALWART_DEFAULT_PREFIX = "/webmail";
/**
* First path segments Stalwart routes itself before it ever looks at an
* Application (crates/http/src/request.rs, 0.16.22), plus the WebUI's own
* default Applications. An Application mounted at one of these is
* unreachable (`mail` and `calendar` included: Stalwart answers
* /mail/config-v1.1.xml and /calendar/rsvp and 404s everything else there).
*/
export const STALWART_RESERVED_SEGMENTS = [
"jmap", "dav", ".well-known", "auth", "scim", "api", "mail", "calendar",
"autodiscover", "robots.txt", "healthz", "metrics", "logo", "form", "login", "device",
"admin", "account",
];
/** Case-insensitive for autodiscover, which Stalwart matches in three spellings. */
export function isReservedStalwartSegment(segment) {
const s = String(segment ?? "");
return STALWART_RESERVED_SEGMENTS.includes(s) || s.toLowerCase() === "autodiscover";
}
/** Problems with an admin-chosen `urlPrefix` (empty list = fine). */
export function stalwartPrefixProblems(prefix) {
const raw = String(prefix ?? "");
const trimmed = raw.replace(/^\/+/, "").replace(/\/+$/, "");
if (!trimmed) return ["the prefix is empty (Stalwart needs one path segment, e.g. /webmail)"];
if (trimmed.includes("/")) return [`${raw} has more than one segment; Stalwart matches the first segment only`];
if (!/^[A-Za-z0-9._~-]+$/.test(trimmed)) return [`${raw} contains characters outside [A-Za-z0-9._~-]`];
if (isReservedStalwartSegment(trimmed)) return [`/${trimmed} is routed by Stalwart itself and would never reach the Application`];
return [];
}
/** Stalwart's extension -> content type map (application.rs). No charset. */
export const STALWART_CONTENT_TYPES = {
html: "text/html",
css: "text/css",
wasm: "application/wasm",
js: "application/javascript",
json: "application/json",
png: "image/png",
svg: "image/svg+xml",
ico: "image/x-icon",
};
export function stalwartContentType(name) {
const text = String(name);
const at = text.lastIndexOf(".");
const ext = at === -1 ? "" : text.slice(at + 1);
return STALWART_CONTENT_TYPES[ext] ?? "application/octet-stream";
}
/** Stalwart refuses bundles above this (MAX_APP_SIZE, compressed). */
export const STALWART_MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
/** verify.mjs fails the build above this, leaving headroom for growth. */
export const STALWART_SAFE_BUNDLE_BYTES = 90 * 1024 * 1024;
/** The two literals Stalwart replaces in the root index.html. */
export const STALWART_BASE_HREF_LITERAL = '<base href="/"';
export const STALWART_OAUTH_META_LITERAL = '<meta name="oauth-client-id" content=""';
/** Marks the entry document so it never writes itself into the page. */
export const STALWART_ENTRY_MARKER = "data-bulwark-lite-entry";
/** Files of the static-host target that make no sense inside a Stalwart bundle. */
export const STALWART_EXCLUDED_FILES = ["404.html", "_redirects", "_headers", "nginx.conf.example", "Caddyfile.example", "manifest.webmanifest"];
export const STALWART_EXCLUDED_DIRS = ["404"];
/** A build id that changes with every build: version, commit, build time. */
export function makeBuildId({ version = "0.0.0", commit = "local", now = Date.now() } = {}) {
const safe = (s) => String(s).replace(/[^A-Za-z0-9.-]/g, "");
return `${safe(version)}-${safe(commit).slice(0, 7) || "local"}-${Math.floor(now / 1000).toString(36)}`;
}
/**
* Directories below `<outDir>/<locale>/` that hold an index.html: the shells
* the entry can boot ('' is the locale root, i.e. the mail client).
*/
export function discoverShellDirs(outDir, locale) {
const root = join(outDir, locale);
const found = [];
const walk = (dir, rel) => {
if (existsSync(join(dir, "index.html"))) found.push(rel);
for (const entry of readdirSync(dir)) {
if (entry.startsWith("__next") || entry.startsWith("_")) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, rel ? `${rel}/${entry}` : entry);
}
};
if (existsSync(root)) walk(root, "");
return found.sort();
}
/*
* The three functions below run in the browser: buildStalwartEntry embeds
* them with Function#toString, and lib/__tests__/lite-scripts.test.ts tests
* the very same code. Keep them self-contained (no imports, no module scope).
*/
/**
* Which shell to boot for `pathname` under `mount`. Returns
* `{ locale, shell, canonical }`: `shell` is the zip entry to fetch
* (`en/calendar/index.html`), `canonical` a URL to `replaceState` to first
* (no locale in the address, or a path no shell owns) or null to keep the
* address. Returns null when `pathname` is not below `mount`.
*/
export function resolveStalwartBoot(pathname, mount, locales, shells, preferredLocale) {
var rest = pathname;
if (mount) {
if (rest !== mount && rest.indexOf(mount + "/") !== 0) return null;
rest = rest.slice(mount.length) || "/";
}
var parts = rest.split("/").filter(function (p) { return p.length > 0; });
var locale = locales.indexOf(parts[0]) !== -1 ? parts[0] : null;
if (!locale) {
// The OAuth redirect URI (lib/auth/lite-oauth.ts) names no locale, so one
// registration covers every language: boot the callback in the preferred
// one. The entry keeps the query string, which holds the code.
if (parts.length === 2 && parts[0] === "oauth" && parts[1] === "callback" && shells.indexOf("auth/callback") !== -1) {
return { locale: preferredLocale, shell: preferredLocale + "/auth/callback/index.html", canonical: mount + "/" + preferredLocale + "/auth/callback/" };
}
return { locale: preferredLocale, shell: preferredLocale + "/index.html", canonical: mount + "/" + preferredLocale + "/" };
}
var best = null;
for (var i = 0; i < shells.length; i++) {
var dir = shells[i];
if (!dir) continue;
var segs = dir.split("/");
var match = true;
for (var j = 0; j < segs.length; j++) if (parts[1 + j] !== segs[j]) { match = false; break; }
if (match && (!best || segs.length > best.split("/").length)) best = dir;
}
if (best) return { locale: locale, shell: locale + "/" + best + "/index.html", canonical: null };
if (parts.length === 1) return { locale: locale, shell: locale + "/index.html", canonical: null };
return { locale: locale, shell: locale + "/index.html", canonical: mount + "/" + locale + "/" };
}
/**
* Points a prerendered shell at the runtime mount. Next wrote root-absolute
* URLs (`/_next/...`, the manifest, the favicon) into the HTML and into the
* inline RSC payload; both spellings (`"/_next/` and `\"/_next/`) contain
* `"/_next/`, so one split/join covers them. `bootScript` goes first into
* <head> so the globals exist before any chunk runs.
*/
export function rewriteShellForMount(html, mount, buildId, bootScript) {
// The needles are assembled so the entry itself holds no root-absolute
// literal (verify.mjs checks for them).
var q = '"';
var out = html
.split(q + "/_next/").join(q + mount + "/_next/")
.split(q + "/manifest.webmanifest" + q).join(q + mount + "/manifest.json?v=" + encodeURIComponent(buildId) + q)
.split(q + "/branding/").join(q + mount + "/branding/");
var at = out.indexOf("<head>");
return at === -1 ? bootScript + out : out.slice(0, at + 6) + bootScript + out.slice(at + 6);
}
/**
* The same mount rewrite for a prerendered RSC payload (`*.txt`), which the
* router fetches on client-side navigation and whose module rows name their
* chunks as `/_next/static/chunks/...` (Turbopack loads those URLs as they
* are). Rows are `<id>:<json>\n`, except `<id>:T<hex byte length>,<text>`
* rows, which are length-prefixed and copied untouched so the lengths stay
* right (verify.mjs checks that no text row ever holds one of the needles).
*/
export function rewriteFlightForMount(text, mount, buildId) {
var q = '"';
function fix(part) {
return part
.split(q + "/_next/").join(q + mount + "/_next/")
.split(q + "/manifest.webmanifest" + q).join(q + mount + "/manifest.json?v=" + encodeURIComponent(buildId) + q)
.split(q + "/branding/").join(q + mount + "/branding/");
}
var out = "";
var i = 0;
var n = text.length;
while (i < n) {
var colon = text.indexOf(":", i);
var newline = text.indexOf("\n", i);
if (colon !== -1 && (newline === -1 || colon < newline) && /^[0-9a-f]*$/.test(text.slice(i, colon)) && text.charAt(colon + 1) === "T") {
var comma = text.indexOf(",", colon);
var bytes = comma === -1 ? NaN : parseInt(text.slice(colon + 2, comma), 16);
if (!(bytes >= 0)) {
out += text.slice(i);
break;
}
var j = comma + 1;
var seen = 0;
while (j < n && seen < bytes) {
var code = text.charCodeAt(j);
if (code < 0x80) seen += 1;
else if (code < 0x800) seen += 2;
else if (code >= 0xd800 && code <= 0xdbff) { seen += 4; j++; }
else seen += 3;
j++;
}
out += text.slice(i, j);
i = j;
continue;
}
var end = newline === -1 ? n : newline + 1;
out += fix(text.slice(i, end));
i = end;
}
return out;
}
/**
* Makes Next's client-side navigation work on Stalwart. For RSC payloads
* (same origin, below the mount, `.txt`):
*
* - Stalwart labels them application/octet-stream, and Next's router only
* accepts text/plain: anything else turns every navigation into a full
* page load. They are relabeled.
* - their chunk URLs are pointed at the mount (`rewriteFlight`).
*
* HTML (Stalwart's answer for a payload the bundle lacks) and everything
* else pass through untouched.
*/
export function installStalwartFetchShim(win, mount, buildId, rewriteFlight) {
if (!win || !win.fetch || win.__bulwarkLiteFetchShim) return;
var original = win.fetch;
win.__bulwarkLiteFetchShim = true;
win.fetch = function () {
return original.apply(this, arguments).then(function (res) {
var url;
try {
var type = res.headers.get("content-type") || "";
if (type.indexOf("application/octet-stream") !== 0 && type.indexOf("text/plain") !== 0) return res;
url = new URL(res.url);
if (url.origin !== win.location.origin || !/\.txt$/.test(url.pathname)) return res;
if (mount && url.pathname.indexOf(mount + "/") !== 0) return res;
} catch {
return res;
}
return res.text().then(function (body) {
var headers = new Headers(res.headers);
headers.set("content-type", "text/plain; charset=utf-8");
headers.delete("content-length");
var relabeled = new Response(rewriteFlight(body, mount, buildId), { status: res.status, statusText: res.statusText, headers: headers });
Object.defineProperty(relabeled, "url", { value: res.url });
Object.defineProperty(relabeled, "redirected", { value: res.redirected });
return relabeled;
});
});
};
}
// Built from char codes: the literal escapes would be unescaped by editors and tools.
const BACKSLASH = String.fromCharCode(92);
const LINE_SEPARATOR = new RegExp(String.fromCharCode(0x2028), "g");
const PARAGRAPH_SEPARATOR = new RegExp(String.fromCharCode(0x2029), "g");
/** Serialises a value for an inline <script> (no `</script>` breakout). */
export function inlineJson(value) {
return JSON.stringify(value)
.replace(/</g, BACKSLASH + "u003c")
.replace(LINE_SEPARATOR, BACKSLASH + "u2028")
.replace(PARAGRAPH_SEPARATOR, BACKSLASH + "u2029");
}
/**
* The root index.html of the Stalwart bundle, the only document Stalwart
* ever serves for a route. It reads the mount from the rewritten
* `<base href>` and the client id from the rewritten meta tag, fetches the
* matching prerendered shell (build-id URL, since shells are cached
* immutably), points it at the mount and writes it into the page in place:
* the address bar keeps the deep link and the shell hydrates exactly as it
* would behind an nginx SPA fallback.
*/
export function buildStalwartEntry({ locales, shells, defaultLocale = "en", buildId, appName = "Bulwark Webmail" }) {
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const script = `
(function () {
var BUILD_ID = ${inlineJson(buildId)};
var LOCALES = ${inlineJson(locales)};
var SHELLS = ${inlineJson(shells)};
var FALLBACK = ${inlineJson(fallback)};
var MOUNT_GLOBAL = "__BULWARK_LITE_MOUNT__";
var CLIENT_ID_GLOBAL = "__BULWARK_LITE_OAUTH_CLIENT_ID__";
var ENTRY_MARKER = ${inlineJson(STALWART_ENTRY_MARKER)};
var resolveStalwartBoot = ${resolveStalwartBoot.toString()};
var rewriteShellForMount = ${rewriteShellForMount.toString()};
var rewriteFlightForMount = ${rewriteFlightForMount.toString()};
var installStalwartFetchShim = ${installStalwartFetchShim.toString()};
try {
var theme = JSON.parse(localStorage.getItem("theme-storage") || "null");
var chosen = theme && theme.state && theme.state.theme;
if (chosen === "dark" || chosen === "light") document.documentElement.className = chosen;
} catch (e) {}
// Stalwart sends no X-Frame-Options or frame-ancestors for an Application:
// inside another origin's frame the app would be open to clickjacking.
var foreignFrame = false;
try { foreignFrame = window.top !== window.self && window.top.location.origin !== location.origin; } catch (e) { foreignFrame = true; }
if (foreignFrame) return fail("Bulwark Webmail cannot be shown inside another site's page. Open it directly.");
function fail(message) {
function show() {
var box = document.getElementById("bulwark-lite-boot");
if (!box) return;
var p = document.createElement("p");
p.textContent = message;
box.innerHTML = "";
box.appendChild(p);
box.removeAttribute("aria-busy");
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", show); else show();
}
function pick(tag) {
if (!tag) return null;
tag = String(tag);
if (LOCALES.indexOf(tag) !== -1) return tag;
var lower = tag.toLowerCase();
if (lower.indexOf("zh") === 0) {
if (/zh-(tw|hk|mo|hant)/.test(lower) && LOCALES.indexOf("zh-TW") !== -1) return "zh-TW";
if (LOCALES.indexOf("zh") !== -1) return "zh";
}
var short = lower.split("-")[0];
for (var i = 0; i < LOCALES.length; i++) if (LOCALES[i].toLowerCase() === short) return LOCALES[i];
return null;
}
var preferred = null;
try {
var stored = JSON.parse(localStorage.getItem(${inlineJson(LOCALE_STORAGE_KEY)}) || "null");
preferred = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !preferred && j < langs.length; j++) preferred = pick(langs[j]);
preferred = preferred || FALLBACK;
// Stalwart rewrote the base element to point at the Application's prefix.
var mount = new URL(document.baseURI).pathname.replace(/[/]+$/, "");
if (!/^([/][A-Za-z0-9._~-]+)?$/.test(mount)) {
return fail("Bulwark Webmail cannot run under " + mount + "/: the mount prefix must be a single path segment such as /webmail.");
}
var metaTag = document.querySelector('meta[name="oauth-client-id"]');
var clientId = (metaTag && metaTag.getAttribute("content")) || "";
var boot = resolveStalwartBoot(location.pathname, mount, LOCALES, SHELLS, preferred);
if (!boot) {
return fail("This page was opened at " + location.pathname + ", but the bundle is mounted at " + (mount || "/") + ".");
}
if (boot.canonical) {
try { history.replaceState(history.state, "", boot.canonical + location.search + location.hash); } catch (e) {}
}
window[MOUNT_GLOBAL] = mount;
window[CLIENT_ID_GLOBAL] = clientId;
window.TURBOPACK_CHUNK_BASE_PATH = mount + "/_next/";
installStalwartFetchShim(window, mount, BUILD_ID, rewriteFlightForMount);
// Repeated inside the written shell, in case a browser gives it a fresh global.
var bootScript = "<scr" + "ipt>(function(w){w[" + JSON.stringify(MOUNT_GLOBAL) + "]=" + JSON.stringify(mount) +
";w[" + JSON.stringify(CLIENT_ID_GLOBAL) + "]=" + JSON.stringify(clientId).replace(/</g, "\\\\u003c") +
";w.TURBOPACK_CHUNK_BASE_PATH=" + JSON.stringify(mount + "/_next/") +
";(" + installStalwartFetchShim.toString() + ")(w," + JSON.stringify(mount) + "," + JSON.stringify(BUILD_ID) + "," + rewriteFlightForMount.toString() + ");})(window)</scr" + "ipt>";
fetch(mount + "/" + boot.shell + "?v=" + encodeURIComponent(BUILD_ID), { credentials: "same-origin" })
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
})
.then(function (html) {
// A shell missing from the zip is answered with this very document.
if (html.indexOf(ENTRY_MARKER) !== -1) throw new Error(boot.shell + " is not part of this bundle");
var page = rewriteShellForMount(html, mount, BUILD_ID, bootScript);
document.open();
document.write(page);
document.close();
})
.catch(function (err) {
fail("Bulwark Webmail could not start (" + (err && err.message ? err.message : err) + "). Reload the page; if this keeps happening, the installed bundle may be incomplete.");
});
})();
`;
return `<!doctype html>
<html lang="en" ${STALWART_ENTRY_MARKER}="">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="${buildStalwartEntryCsp()}" />
${STALWART_BASE_HREF_LITERAL} />
${STALWART_OAUTH_META_LITERAL} />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>${esc(appName)}</title>
<style>
html, body { margin: 0; min-height: 100vh; background: #fff; color: #111; font-family: system-ui, sans-serif; }
html.dark, html.dark body { background: #0a0a0a; color: #ededed; }
@media (prefers-color-scheme: dark) { html:not(.light), html:not(.light) body { background: #0a0a0a; color: #ededed; } }
#bulwark-lite-boot { display: flex; min-height: 100vh; align-items: center; justify-content: center; padding: 1rem; box-sizing: border-box; text-align: center; }
#bulwark-lite-boot p { max-width: 36rem; opacity: .8; line-height: 1.5; }
</style>
<script>${script}</script>
</head>
<body>
<div id="bulwark-lite-boot" aria-busy="true"><noscript><p>Bulwark Webmail needs JavaScript.</p></noscript></div>
</body>
</html>
`;
}
/**
* The bundle manifest Stalwart 1.0 requires at the root of an Application
* zip (`stalwart-app.json`, checked before it unpacks anything): without it,
* it refuses the bundle with "Application bundle does not contain a manifest"
* and serves nothing. 0.16 serves the file like any other.
*/
export function buildStalwartAppManifest({ version }) {
return { id: "bulwark-webmail", version };
}
/** manifest.json with relative URLs: they resolve against the manifest's own (mounted) URL. */
export function buildStalwartManifest({ appName }) {
return {
name: appName,
short_name: appName,
description: `${appName} - webmail for Stalwart`,
start_url: "./",
scope: "./",
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: "icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512x512.png", sizes: "512x512", type: "image/png" },
{ 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" },
],
};
}
/** Problems with the root index.html of a Stalwart bundle (empty = fine). */
export function stalwartEntryProblems(html) {
const problems = [];
const count = (needle) => html.split(needle).length - 1;
if (count(STALWART_BASE_HREF_LITERAL) !== 1) problems.push(`index.html must contain ${STALWART_BASE_HREF_LITERAL} exactly once (found ${count(STALWART_BASE_HREF_LITERAL)})`);
if (count(STALWART_OAUTH_META_LITERAL) !== 1) problems.push(`index.html must contain ${STALWART_OAUTH_META_LITERAL} exactly once (found ${count(STALWART_OAUTH_META_LITERAL)})`);
if (!html.includes(STALWART_ENTRY_MARKER)) problems.push("index.html is not the Stalwart entry document");
// Root-absolute URLs would escape the mount prefix Stalwart put into <base>.
for (const attr of html.replace(STALWART_BASE_HREF_LITERAL, "").match(/\b(?:src|href)="\/(?!\/)[^"]*"/g) ?? []) {
problems.push(`index.html has a root-absolute ${attr}; it must follow the runtime mount`);
}
for (const absolute of ['"/_next/static', '"/config.json', '"/policy.json']) {
if (html.includes(absolute)) problems.push(`index.html references ${absolute.slice(1)} absolutely; it must follow the runtime mount`);
}
return problems;
}
/** Problems with the entry list of a Stalwart zip (empty = fine). */
export function stalwartZipEntryProblems(names) {
const problems = [];
if (!names.includes("index.html")) problems.push("index.html is not at the archive root (no wrapping folder allowed)");
if (names.length >= 0xffff) problems.push(`${names.length} entries need ZIP64, which this writer does not produce`);
const reserved = new Set();
for (const name of names) {
const first = name.split("/")[0];
if (isReservedStalwartSegment(first)) reserved.add(first);
if (STALWART_EXCLUDED_FILES.includes(name) || (name.includes("/") && STALWART_EXCLUDED_DIRS.includes(first))) {
problems.push(`${name} belongs to the static-host target and must not ship in the Stalwart bundle`);
}
if (name.includes("\\") || name.startsWith("/") || name.split("/").includes("..")) problems.push(`unsafe entry name ${JSON.stringify(name)}`);
}
for (const first of reserved) problems.push(`top-level entry ${first} shares its name with a Stalwart route; keep top-level names app-specific`);
return problems;
}
// --- zip ---------------------------------------------------------------------
function dosDateTime(date) {
const d = new Date(date);
const year = Math.max(1980, d.getFullYear());
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
day: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
/**
* Minimal zip writer (no dependencies, no ZIP64): deflate when it helps,
* store otherwise, UTF-8 names, entries in the given order, a fixed
* timestamp so identical inputs give identical archives.
*/
export function buildZip(entries, { date = new Date(2000, 0, 1) } = {}) {
const { time, day } = dosDateTime(date);
const locals = [];
const centrals = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, "utf8");
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
const deflated = deflateRawSync(raw, { level: 9 });
const useDeflate = deflated.length < raw.length;
const body = useDeflate ? deflated : raw;
const crc = crc32(raw) >>> 0;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(day, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
locals.push(local, nameBuf, body);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE((3 << 8) | 20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
central.writeUInt16LE(time, 12);
central.writeUInt16LE(day, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(raw.length, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
central.writeUInt32LE(offset, 42);
centrals.push(central, nameBuf);
offset += 30 + nameBuf.length + body.length;
}
const centralDir = Buffer.concat(centrals);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, centralDir, end]);
}
/** Reads a zip's central directory: `[{ name, method, compressedSize, size, offset }]`. */
export function listZipEntries(buffer) {
let eocd = -1;
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 22 - 0xffff); i--) {
if (buffer.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd === -1) throw new Error("not a zip archive (no end-of-central-directory record)");
const count = buffer.readUInt16LE(eocd + 10);
let at = buffer.readUInt32LE(eocd + 16);
const entries = [];
for (let n = 0; n < count; n++) {
if (buffer.readUInt32LE(at) !== 0x02014b50) throw new Error("corrupt central directory");
const nameLen = buffer.readUInt16LE(at + 28);
const extraLen = buffer.readUInt16LE(at + 30);
const commentLen = buffer.readUInt16LE(at + 32);
entries.push({
name: buffer.toString("utf8", at + 46, at + 46 + nameLen),
method: buffer.readUInt16LE(at + 10),
compressedSize: buffer.readUInt32LE(at + 20),
size: buffer.readUInt32LE(at + 24),
offset: buffer.readUInt32LE(at + 42),
});
at += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** Extracts one entry listed by `listZipEntries`. */
export function readZipEntry(buffer, entry) {
const nameLen = buffer.readUInt16LE(entry.offset + 26);
const extraLen = buffer.readUInt16LE(entry.offset + 28);
const start = entry.offset + 30 + nameLen + extraLen;
const body = buffer.subarray(start, start + entry.compressedSize);
if (entry.method === 0) return Buffer.from(body);
if (entry.method === 8) return inflateRawSync(body);
throw new Error(`unsupported compression method ${entry.method} for ${entry.name}`);
}
/** Every file below `dir` as a zip entry, `/`-separated and sorted. */
export function collectZipEntries(dir) {
const out = [];
const walk = (full, rel) => {
for (const entry of readdirSync(full).sort()) {
const path = join(full, entry);
const name = rel ? `${rel}/${entry}` : entry;
if (statSync(path).isDirectory()) walk(path, name);
else out.push({ name, data: readFileSync(path) });
}
};
walk(dir, "");
return out;
}
/** LITE-README.md for the Stalwart bundle. */
export function buildStalwartReadme({ version, commit, locales, buildId, demoMode = false }) {
// Release tags carry no "v" prefix (since 1.3.0); a v-URL 404s.
const url = `https://github.com/bulwarkmail/webmail/releases/download/${version}/${STALWART_ZIP_NAME}`;
const latestUrl = `https://github.com/bulwarkmail/webmail/releases/latest/download/${STALWART_ZIP_NAME}`;
return `# Bulwark Lite ${version} (${commit}) for Stalwart
Bulwark Webmail as a Stalwart \`Application\`: Stalwart downloads this zip,
serves it under a path of its own HTTP listener, and the webmail talks to the
same server's JMAP, login and token endpoints. There is nothing to configure:
no server URL, no CORS, no second web server.
Build: ${buildId}
Locales: ${locales.join(", ")}
Requires: Stalwart 0.16.6 or later (0.16.19 for \`oauthClientId\`), or 1.0.
${demoMode ? "\nDemo mode is ON in this build: the login page offers the built-in demo account.\n" : ""}
## Install
An administrator creates one Application and then runs the \`UpdateApps\`
action: Stalwart only downloads and mounts Applications on that action or at
startup, so creating the object alone serves nothing. Permissions:
\`sysApplicationCreate\` (plus \`sysApplicationGet\`/\`sysApplicationQuery\` to
list, \`sysApplicationUpdate\` to change and \`sysApplicationDestroy\` to remove
it) and \`actionUpdateApps\`. Any of these is equivalent:
- Web admin: **Settings > Web Applications > Create**, description
\`Bulwark Webmail\`, resource URL \`${url}\`, URL prefix \`/webmail\`; then run
**Actions > Update applications** (or restart Stalwart).
- CLI:
\`\`\`bash
stalwart-cli create Application \\
--field description='Bulwark Webmail' \\
--field resourceUrl='${url}' \\
--field 'urlPrefix={"/webmail":true}'
stalwart-cli create Action/UpdateApps
\`\`\`
- JMAP (\`urn:stalwart:jmap\`), as an administrator:
\`\`\`json
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [
["x:Application/set", { "create": { "webmail": {
"description": "Bulwark Webmail",
"resourceUrl": "${url}",
"urlPrefix": { "/webmail": true }
} } }, "0"],
["x:Action/set", { "create": { "u": { "@type": "UpdateApps" } } }, "1"]
]
}
\`\`\`
Then open \`https://<your stalwart host>/webmail/\` and sign in with a mail
account.
## Mount prefix
\`urlPrefix\` is matched against the first path segment only (\`/webmail\`,
not \`/apps/webmail\`), and several prefixes may serve the same bundle. The
bundle works under any prefix: it reads it from the \`<base href>\` Stalwart
writes into index.html. Stalwart routes these first segments itself, so an
Application mounted there is never reached: \`jmap\`, \`dav\`, \`.well-known\`,
\`auth\`, \`api\`, \`scim\`, \`mail\`, \`calendar\`, \`autodiscover\`, \`login\`,
\`device\`, \`metrics\`, \`healthz\`, \`logo\`, \`form\`, \`robots.txt\`, and the web
admin's own \`admin\` and \`account\`.
## Updates
The resource URL above names one release, so what Stalwart serves only
changes when you change it: to update, point \`resourceUrl\` at the new
release's tag and run \`UpdateApps\`, which always downloads again.
What a failed download does depends on the Stalwart version. On 0.16.23 the
running bundle stays online, as on later 0.16 releases and on 1.0.
Older 0.16 releases and the 1.0 pre-releases
unmount the Application and drop the cached copy. The next restart,
\`UpdateApps\` or reload of the Applications (the other nodes of a cluster
reload them after \`UpdateApps\`) downloads \`resourceUrl\` again and mounts the
bundle once that works. On every version, a restart after the cached copy
has expired, with \`resourceUrl\` unreachable, leaves the Application
unmounted. So check that \`resourceUrl\` downloads before running
\`UpdateApps\` or restarting. Every release also carries
\`${STALWART_ZIP_NAME}.sha256\`; check a download with
\`sha256sum -c ${STALWART_ZIP_NAME}.sha256\` before you switch.
The bundle runs as script on your Stalwart origin, next to the web admin.
Stalwart checks no hash or signature when it downloads, so a floating URL
such as \`${latestUrl}\` would run whatever the newest release holds, at the
next restart or \`UpdateApps\` after \`autoUpdateFrequency\` (on 1.0
\`autoUpdateInterval\`, default 90 days). Use it only if you accept that in exchange for automatic updates.
Open tabs move to the new build on their next navigation (one full page
load); a freshly opened tab gets it immediately. Nothing stays stuck on old
files although Stalwart sends a year-long cache header for every file but
index.html: every file the app fetches without a content hash carries the
build id.
## Sign-in
Passwords (and TOTP codes) go to Stalwart's \`/api/auth\` and are exchanged at
\`/auth/token\` for an access token and a refresh token, all on the same
origin. "Remember me" keeps the refresh token in localStorage, otherwise it
lives with the tab.
Serve Stalwart over HTTPS. On a plain \`http://\` address other than
\`localhost\`, browsers withhold the Web Crypto API this sign-in needs, so the
webmail falls back to a password sign-in that lasts only as long as the tab:
"Remember me" has no effect and single sign-on does not work.
The OAuth client id is the Application's \`oauthClientId\` (Stalwart writes it
into index.html) or \`bulwark-webmail\` when that field is empty. If your
server requires registered OAuth clients, register that client id with the
redirect URI \`https://<your stalwart host>/<prefix>/\` (for example
\`https://mail.example.com/webmail/\`), once per prefix you mount.
Stalwart 1.0 always requires registered clients. When it knows no client by
the default name, the app registers one of its own and signs in with that:
mail, contacts, calendars and files work, but the account's Stalwart
settings (password, two-factor authentication, app passwords) do not, since
1.0 only lets a first-party client manage those. For them, create an
\`OAuthClient\` with client id \`bulwark-webmail\` (or the \`oauthClientId\`
you set), \`firstParty\` on, and the redirect URI above.
### Single sign-on (OpenID Connect)
An account whose directory is an external OpenID provider has no password
Stalwart could check, so the form above cannot sign it in. The app asks
Stalwart who signs an address in (\`/api/discover/<address>\`, the lookup
Stalwart's own web admin uses): for such an account the password field gives
way to **Sign in with SSO**, which sends the user to the provider
(authorization code with PKCE) and redeems the code in the browser. Accounts
Stalwart authenticates itself keep the password form.
At the provider, register a **public** client (no secret, PKCE \`S256\`) with:
- the client id you put into the Application's \`oauthClientId\` (or
\`bulwark-webmail\` if you leave that empty);
- the redirect URI \`https://<your stalwart host>/<prefix>/oauth/callback\`
(for example \`https://mail.example.com/webmail/oauth/callback\`). It names
no language, so one URI per prefix covers every locale;
- CORS on its token endpoint for \`https://<your stalwart host>\`, because the
browser redeems and renews tokens there directly. Stalwart's web admin needs
the same, so a provider already set up for it only needs the extra client.
Setting \`oauthClientId\` also shows **Sign in with SSO** next to the password
form for accounts Stalwart authenticates itself; that route goes through
Stalwart's own login page and needs the \`.../oauth/callback\` URI registered
as well if your server requires registered clients. Per-domain directories
are a Stalwart Enterprise feature; the community edition has one directory
for every account.
## Branding and settings
The bundle is read-only once Stalwart has unpacked it. To change the app
name, turn "remember me" off or build any other variant, run the
**Build Static Lite** workflow (or, in a throwaway checkout,
\`npm run build:lite -- --in-place --target=stalwart\`) with your settings, publish the resulting \`${STALWART_ZIP_NAME}\` at a URL
you control, and use that URL as \`resourceUrl\`. \`config.json\` and
\`policy.json\` inside the zip take the same keys as in the static-host build.
## Differences from the static-host zip
- One entry document (\`index.html\`) boots every route: Stalwart has no
rewrite rules and answers every unknown path with it.
- No \`_redirects\`, \`_headers\`, nginx/Caddy examples or \`404.html\`.
- \`config.json\` defaults to the serving Stalwart and hides the server field;
no CORS settings are needed.
- The mount prefix is detected at runtime instead of being baked in.
- \`manifest.json\` replaces \`manifest.webmanifest\` (Stalwart would serve the
latter as \`application/octet-stream\`).
Everything else the static-host build leaves out (admin console, plugins,
settings sync, web push, office editing, ICS subscriptions) is also missing
here. Single sign-on is the exception: it works in this bundle, as described
above.
`;
}
/** Stalwart's escaping of the client id for the meta attribute (application.rs). */
export function stalwartOauthMeta(clientId) {
const escaped = String(clientId).replace(/[&"<>]/g, (ch) => ({ "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" })[ch]);
return `<meta name="oauth-client-id" content="${escaped}"`;
}
/**
* Stalwart's rewrite of the root index.html (0.16.19+: first occurrence of
* each literal; 0.16.0-0.16.18 replaced every `<base href="/"` and had no
* client id support - `legacy: true` emulates that).
*/
export function stalwartRewriteIndex(html, prefix, oauthClientId, { legacy = false } = {}) {
const base = `<base href="/${prefix}/"`;
if (legacy) return html.split(STALWART_BASE_HREF_LITERAL).join(base);
let out = html.replace(STALWART_BASE_HREF_LITERAL, () => base);
if (oauthClientId) out = out.replace(STALWART_OAUTH_META_LITERAL, () => stalwartOauthMeta(oauthClientId));
return out;
}
/**
* One request as Stalwart's HTTP listener answers it for Applications
* (the `external =>` arm of crates/http/src/request.rs plus
* WebApplications::serve). `entries` maps zip entry names to contents,
* `prefixes` are the mounted urlPrefix values.
*
* @param {{ pathname: string, entries: Map<string, Buffer>, prefixes: string[], oauthClientId?: string, legacy?: boolean }} request
* @returns {{ status: number, headers: Record<string, string>, body: Buffer }}
*/
export function emulateStalwartRequest({ pathname, entries, prefixes, oauthClientId = "", legacy = false }) {
const mounted = prefixes.map((p) => String(p).replace(/^\/+|\/+$/g, ""));
const segments = pathname.split("/");
const first = segments[1] ?? "";
const notFound = { status: 404, headers: { "content-type": "application/problem+json" }, body: Buffer.from('{"type":"about:blank","status":404,"title":"Not Found"}') };
if (!first || isReservedStalwartSegment(first) || !mounted.includes(first)) return notFound;
// HttpResponse::redirect answers 302 (verified against 0.16.22).
if (segments.length === 2) return { status: 302, headers: { location: `/${first}/` }, body: Buffer.alloc(0) };
const path = pathname.slice(first.length + 2);
const direct = entries.get(path);
const isIndex = direct ? path === "index.html" : true;
const name = direct ? path : "index.html";
const contents = direct ?? entries.get("index.html");
if (!contents) return notFound;
const body = isIndex ? Buffer.from(stalwartRewriteIndex(contents.toString("utf8"), first, oauthClientId, { legacy })) : contents;
return {
status: 200,
headers: {
"content-type": stalwartContentType(name),
"cache-control": isIndex ? "no-cache" : "public, max-age=31536000, immutable",
},
body,
};
}
/**
* RSC text rows (`<id>:T<hex length>,...`) that contain a string the runtime
* mount rewrite replaces. Those rows are length-prefixed, so
* rewriteFlightForMount leaves them alone; a hit here would mean a URL that
* is never pointed at the mount. Returns the offending row ids.
*/
export function flightTextRowsWithNeedles(text) {
const needles = ['"/_next/', '"/manifest.webmanifest"', '"/branding/'];
const hits = [];
const buffer = Buffer.from(text, "utf8");
const pattern = /(?:^|\n)([0-9a-f]+):T([0-9a-f]+),/g;
let match;
while ((match = pattern.exec(text))) {
const start = Buffer.byteLength(text.slice(0, match.index + match[0].length), "utf8");
const body = buffer.subarray(start, start + parseInt(match[2], 16)).toString("utf8");
if (needles.some((needle) => body.includes(needle))) hits.push(match[1]);
}
return hits;
}
/**
* Next 16.3 exports segment-prefetch payloads as nested directories
* (`<route>/__next.<a>/<b>/__PAGE__.txt`) but requests them with dots
* (`<route>/__next.<a>.<b>.__PAGE__.txt`). On Stalwart the dotted request is
* answered with the entry index.html, which the router ignores before
* fetching `index.txt`, so the nested trees are never read: the Stalwart
* target drops them (about 40% of the zip). `__next._tree.txt` and
* `__next._full.txt` are plain files and stay.
*/
export function findSegmentPrefetchDirs(outDir) {
const found = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (!statSync(full).isDirectory()) continue;
if (entry.startsWith("__next.")) found.push(full);
else if (entry !== "_next") walk(full);
}
};
if (existsSync(outDir)) walk(outDir);
return found;
}