Bulwark 1.18.0 works with Stalwart 1.0.

Read the upgrade notes
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 and 1.0 can both 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, or Stalwart 1.0 with Lite 1.18.0 or later (see Upgrading to Stalwart 1.0). A custom OAuth client id (oauthClientId, see below) needs 0.16.19.
  • Stalwart 1.0 only installs a bundle that contains a stalwart-app.json manifest. bulwark-lite-stalwart.zip carries it from 1.18.0 on; with an older zip, 1.0 logs "Application bundle does not contain a manifest" and serves nothing.
  • 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.

The examples point resourceUrl at release 1.18.0. Put in the newest tag from the releases page instead. Release tags have no v prefix, so a URL with /download/v1.18.0/ answers 404. Tagged release or latest explains why the URL names a release.

With stalwart-cli

stalwart-cli create Application \
  --field description='Bulwark Webmail' \
  --field resourceUrl='https://github.com/bulwarkmail/webmail/releases/download/1.18.0/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/download/1.18.0/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.

Stalwart 1.0 always requires registered clients. When it doesn't know the client id, the app registers a client 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) don't, because 1.0 only lets a first-party client manage those. For them, create an OAuthClient with the client id (bulwark-webmail, or the oauthClientId you set), firstParty turned on, and the redirect URI above.

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 (autoUpdateInterval on 1.0, 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 move to a new release, point resourceUrl at its tag and 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 and later, and 1.0, keep serving the bundle they 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.

Tagged release or latest

Point resourceUrl at a tagged release. Release tags have no v prefix:

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

What Stalwart serves then only changes when you change the URL. To move to another version, change resourceUrl and run "update applications".

releases/latest/download/bulwark-lite-stalwart.zip also works and follows every stable release. The bundle runs as script on your Stalwart origin, next to the web admin, and Stalwart checks no hash or signature when it downloads it. With the latest URL, whatever the newest release holds goes live at the next restart or "update applications" after the cache expires, without you looking at it. Use it only if you accept that in exchange for automatic updates. Every release also carries bulwark-lite-stalwart.zip.sha256, to check a download with sha256sum -c before you switch.

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"