Docs / DeploymentEdit on GitHub
Lite only. This page applies to Bulwark Lite and has no counterpart in the full edition.

Install on Stalwart

Stalwart 0.16 can host web apps. An Application is a zip that Stalwart downloads, unpacks and serves under a path of its own HTTP listener, the same way it serves its web admin. Bulwark Lite ships a bundle for exactly that, bulwark-lite-stalwart.zip, so the webmail can live at https://mail.example.com/webmail/ with nothing else to run.

Compared with putting the static zip on a web server:

  • No web server. Stalwart serves the files.
  • No CORS. The webmail and JMAP share an origin, so Stalwart's permissive CORS policy stays off.
  • No server URL. The bundle talks to the Stalwart that serves it; the login page has no server field.
  • Updates are one action. Stalwart downloads the zip again, and open tabs pick up the new build on their next navigation.

What works and what is off is the same as for any Bulwark Lite install.

Requirements

  • Stalwart 0.16.6 or later. A custom OAuth client id (oauthClientId, see below) needs 0.16.19.
  • An administrator account with sysApplicationCreate and actionUpdateApps. Managing the Application later also needs sysApplicationGet, sysApplicationQuery, sysApplicationUpdate and sysApplicationDestroy.
  • Stalwart must be able to fetch the zip from resourceUrl (GitHub, or wherever you host your own build).

Install

Two steps: create the Application, then ask Stalwart to fetch it. Creating it alone mounts nothing. Stalwart only downloads bundles when the "update applications" action runs, or when it restarts.

With stalwart-cli

stalwart-cli create Application \
  --field description='Bulwark Webmail' \
  --field resourceUrl='https://github.com/bulwarkmail/webmail/releases/latest/download/bulwark-lite-stalwart.zip' \
  --field 'urlPrefix={"/webmail":true}'

stalwart-cli create Action/UpdateApps

urlPrefix is a JSON map of prefixes to true. {"/webmail"} and ["/webmail"] are rejected.

In the web admin

Under Settings > Web Applications, create an entry with the description, the resourceUrl and the prefix /webmail. Then run Update applications under Actions (the Application Management group), or restart Stalwart.

Over JMAP

As an administrator, in one request:

{
  "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
  "methodCalls": [
    ["x:Application/set", { "create": { "webmail": {
      "description": "Bulwark Webmail",
      "resourceUrl": "https://github.com/bulwarkmail/webmail/releases/latest/download/bulwark-lite-stalwart.zip",
      "urlPrefix": { "/webmail": true }
    } } }, "0"],
    ["x:Action/set", { "create": { "u": { "@type": "UpdateApps" } } }, "1"]
  ]
}

Then open https://<your stalwart host>/webmail/ and sign in with a mail account.

Choosing the prefix

urlPrefix matches the first path segment only, so /webmail works and /apps/webmail does not. The bundle reads its prefix at runtime, so any free segment works without a rebuild, and one Application may be mounted under several prefixes at once.

Stalwart routes some segments itself, before it looks at Applications. A bundle mounted on one of them 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 admin and account.

/mail and /calendar are on that list, so use /webmail, /bulwark or similar. A request for /webmail is redirected to /webmail/.

Signing in and OAuth

Login goes through Stalwart's own token endpoints on the same origin (/api/auth and /auth/token), with the client id from the Application's oauthClientId, or bulwark-webmail when that is empty. "Remember me" keeps a Stalwart refresh token in the browser, exactly as on a static host.

If your Stalwart requires OAuth clients to be registered, register that client id with the redirect URI https://<your stalwart host>/<prefix>/, one per prefix you mount.

The app runs entirely in the browser, so its client must be a public client with no secret. Stalwart checks a stored secret even when registration is not required: the password is accepted, then /auth/token answers invalid_client. A sign-in with a two-factor code then fails, and a password-only sign-in falls back to a session that lasts only as long as the tab. If a server-hosted Bulwark already uses bulwark-webmail with a secret, register a separate client for the app, for example bulwark-lite, and set it as the Application's oauthClientId. That needs Stalwart 0.16.19 and also shows the SSO button, so give the client the .../oauth/callback redirect URI from the next section as well.

Single sign-on

Accounts whose directory is an external OpenID provider cannot use the password form, because Stalwart has no password to check. The app asks Stalwart who signs an address in and shows Sign in with SSO for those accounts instead: an authorization code flow with PKCE, redeemed in the browser. At the provider:

  • Register a public client (no secret, PKCE S256) with the same id (the oauthClientId, or bulwark-webmail).
  • Add the redirect URI https://<your stalwart host>/<prefix>/oauth/callback. It carries no locale, so you need one per prefix.
  • Allow CORS from the Stalwart host on the token endpoint, as Stalwart's own web admin requires.

Setting oauthClientId on the Application also offers SSO next to the password form for everyone. That sign-in goes through Stalwart's own login page and uses the same .../oauth/callback redirect URI.

Warning. Serve Stalwart over HTTPS. On a plain http:// address other than localhost, browsers withhold the Web Crypto API that the sign-in needs. The webmail then 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.

Updating

Stalwart caches the downloaded zip for the Application's autoUpdateFrequency (90 days by default). This is only an expiry, not a timer: nothing is fetched on a schedule. After the expiry, the next restart or "update applications" run downloads resourceUrl again.

To update right away, run the action again:

stalwart-cli create Action/UpdateApps

It always downloads every Application again. Open tabs switch to the new build on their next navigation, with one full page load. Files the app fetches without a content hash carry the build id, so Stalwart's year-long cache headers never pin an old build.

Check every resourceUrl before running the action. It downloads and unpacks every Application again, Stalwart's own web admin included, whose bundle comes from:

https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip

A URL that can't be downloaded fails, and so does one that points at an HTML page, a GitHub Actions artifact page or a release listing instead of a zip. What a failure does depends on the Stalwart version:

  • 0.16.23 keeps serving the bundle it already had.
  • Older 0.16 releases unmount that Application and drop the cached zip. It stays unmounted, across restarts too, until a later restart or "update applications" downloads resourceUrl successfully. Open tabs get Stalwart's 404 response at their next navigation.
  • Every version leaves the Application unmounted after a restart if the cached zip has expired and resourceUrl can't be downloaded.

The action reports success either way. The failure shows up in Stalwart's log as a resource error, "Failed to unpack application for prefixes: …", with the URL.

Pinning a version

releases/latest/download/… follows every stable release. To stay on one version, point resourceUrl at that release's asset instead. Release tags have no v prefix:

https://github.com/bulwarkmail/webmail/releases/download/<version>/bulwark-lite-stalwart.zip

To move to another version, change resourceUrl and run "update applications".

Branding and configuration

The bundle is read-only: its config.json is inside the zip, so there is nothing to edit on the server. It defaults to the serving Stalwart and hides the server field on the login page.

To change the app name, logos or any other config.json key, build your own bundle and host it where Stalwart can fetch it:

  • Dispatch the Build Static Lite workflow in the repository with your inputs; the stalwart job produces bulwark-lite-stalwart.zip. Leave the server URL empty so it uses the serving Stalwart.

  • Or build locally in a disposable checkout, as on the static hosting page, with the Stalwart target:

    LITE_APP_NAME="Example Mail" npm run build:lite -- --in-place --target=stalwart
    

    NEXT_PUBLIC_BASE_PATH must stay unset for this target; the prefix comes from urlPrefix at runtime.

Then set resourceUrl to your zip's URL and run "update applications". A private http:// address works too, and so does a file:// path the Stalwart process can read. With the Docker image that path is inside the container: put the zip on a mounted volume, because files copied into the container are lost when it is recreated. Stalwart refuses bundles over 100 MB; the release bundle with all 27 locales is about 57 MB, and LITE_LOCALES makes it smaller.

Security notes

  • The webmail shares its origin with Stalwart's JMAP endpoint and web admin. Lite's session storage and "remember me" token work as on a static host, but they are now readable by any script on the Stalwart origin, so only install bundles you trust.
  • Stalwart serves Application files with a content type, length and cache headers only: no Content-Security-Policy and no nosniff. The _headers policy of the static zip does not apply here. If you want those headers, add them in the reverse proxy in front of Stalwart for the prefix you mounted.

How it differs from the static zip

bulwark-lite-<version>.zipbulwark-lite-stalwart.zip
Served byAny web hostStalwart
Mount pathSite root, or a sub-path fixed at build timeAny free first segment, read at runtime
Server URLjmapServerUrl in config.jsonThe serving Stalwart
CORS on StalwartRequiredNot needed
Deep linksHost rewrite rules or 404.html replayOne entry document boots every route
config.jsonEdit on the hostBaked in; rebuild to change
Host files_redirects, _headers, nginx and Caddy examples, 404.htmlNone
Manifestmanifest.webmanifestmanifest.json
UpdatingDownload and re-upload"Update applications"