Skip to content

Latest commit

History

1,581 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Bulwark Webmail

Bulwark Webmail

A self-hosted webmail client for Stalwart Mail Server, built with Next.js and the JMAP protocol.

License: AGPL v3DiscordVersionDocker


Installer

Since 1.6.4, a web-based setup wizard runs on first launch – no .env.local editing, no shelling into the container.

Point a browser at the running container and the wizard guides you through:

  • Server – probe one or more JMAP endpoints, optional auto-pick by email domain, Stalwart feature toggle
  • Auth – OAuth2 / OIDC discovery and validation, or basic-auth fallback
  • Security – generate or paste a SESSION_SECRET, opt into settings sync
  • Logging – text or JSON, level
  • Branding – upload favicon, app logos, login logos, and company / legal URLs
  • Review – grouped summary with an advanced toggle for the full config
  • Admin – set the initial admin password and optionally drop a .config-locked marker so the config volume can be remounted read-only

The wizard writes to ADMIN_CONFIG_DIR (./data/admin by default). Setting JMAP_SERVER_URL in the environment skips the wizard and uses env-managed configuration instead.


Screenshots

Mail view
CalendarContacts
Calendar – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.Contacts – multiple address books, groups, vCard import/export, and autocomplete in the composer.
ThemesPlugins
Themes – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.Plugins – extend the client with bundled or third-party plugins installed from a .zip file.
Light modeSettings
Light mode – full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable.Settings – appearance, identities, filters, templates, security, and more.

What Bulwark includes

Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting:

  • Mail – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
  • Calendar – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
  • Contacts – multiple address books, groups, vCard import/export
  • Files – Stalwart's JMAP FileNode storage with previews and folder upload

They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 24 languages, PWA install, themes, and plugins apply across all four.

Full feature list: FEATURES.md.


Quick start

Docker

docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest

Or with Docker Compose:

docker compose up -d

On first launch, open http://localhost:3000 and the setup wizard takes over. Installs that already define JMAP_SERVER_URL skip it and keep the env-managed flow under Configuration.

From source

git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
npm run build && npm start
# Then open http://localhost:3000 to run the setup wizard

Development

cp .env.dev.example .env.local # Built-in mock JMAP server, no mail server needed
npm run dev # Dev server
npm run typecheck
npm run lint
npx vitest run # Unit tests
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)

Configuration

Most deployments are configured through the setup wizard on first launch, then the admin dashboard; those values live in the admin config directory rather than .env.local. Environment variables still work, and they suit read-only or immutable infrastructure better. An environment variable always wins over the admin-managed value, so setting JMAP_SERVER_URL hides that field from the wizard and locks it in the admin UI.

Nearly all variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. The exceptions are the NEXT_PUBLIC_* ones noted below, which Next.js bakes in at build time. Edit .env.local:

# Optional – overrides whatever the wizard writesJMAP_SERVER_URL=https://mail.example.comAPP_NAME=My Webmail
Server listen address
HOSTNAME=0.0.0.0# Default; use "::" for IPv6PORT=3000
OAuth2 / OIDC
OAUTH_ENABLED=trueOAUTH_ONLY=true# hide the username/password form entirelyOAUTH_CLIENT_ID=webmailOAUTH_CLIENT_SECRET=# optional, for confidential clientsOAUTH_CLIENT_SECRET_FILE=# path to a file containing the secretOAUTH_ISSUER_URL=# optional, for external IdPsOAUTH_AUTHORIZE_URL=# override only the user-facing authorize endpointOAUTH_ALLOW_PRIVATE_ENDPOINTS=# allow discovery to resolve to RFC-1918 addressesOAUTH_SCOPES=# replace the requested scopes (space-separated)OAUTH_EXTRA_SCOPES=# append to the defaults instead of replacing themAUTO_SSO_ENABLED=true# skip the login form, go straight to the IdP

Endpoints are auto-discovered via .well-known/oauth-authorization-server or .well-known/openid-configuration. OAUTH_ALLOW_PRIVATE_ENDPOINTS is off by default as an SSRF guard. Enable it only for split-DNS deployments where the issuer's public hostname resolves to an internal IP.

Anonymous telemetry
BULWARK_TELEMETRY=on# opt-in; off by defaultTELEMETRY_DATA_DIR=./data/telemetry# instance id and consent; mount a volume

Off unless you turn it on, in the admin UI, the installer, or here. Heartbeats carry version, platform, bucketed account counts, and feature toggles. No email addresses, hostnames, or IPs. Setting the variable (to either value) locks the choice and disables the admin toggle.

Session & settings sync
SESSION_SECRET=# openssl rand -base64 32SESSION_SECRET_FILE=/session-secret# path to a file containing the secretSETTINGS_SYNC_ENABLED=trueSETTINGS_DATA_DIR=./data/settings# mount as a volume in Docker

Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires SESSION_SECRET.

Multiple JMAP servers & custom endpoints
ALLOW_CUSTOM_JMAP_ENDPOINT=trueJMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com"}]JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true

ALLOW_CUSTOM_JMAP_ENDPOINT shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.

JMAP_SERVERS offers a fixed list instead; each entry needs id, label, and url, and may carry domains and its own oauth block. With JMAP_SERVER_AUTO_PICK_BY_DOMAIN, the domain of the address the user types selects the server. The admin dashboard manages the same list — the env form is for stateless deployments.

Branding & PWA
APP_NAME=My WebmailAPP_SHORT_NAME=WebmailAPP_DESCRIPTION=Your personal mailFAVICON_URL=/branding/favicon.svgPWA_ICON_URL=/branding/icon.svg# falls back to FAVICON_URLPWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svgAPP_LOGO_DARK_URL=/branding/logo-dark.svgLOGIN_LOGO_LIGHT_URL=/branding/login-light.svgLOGIN_LOGO_DARK_URL=/branding/login-dark.svgLOGIN_COMPANY_NAME=My CompanyLOGIN_WEBSITE_URL=https://example.comLOGIN_IMPRINT_URL=https://example.com/imprintLOGIN_PRIVACY_POLICY_URL=https://example.com/privacy# Web push goes through a hosted relay, so no VAPID keys or Firebase project# of your own. Point this at your own relay to opt out. Build-time variable.NEXT_PUBLIC_PUSH_RELAY_URL=https://notifications.relay.bulwarkmail.org# Per-domain overrides (optional). When the webmail is served on multiple# hostnames, each host can override any subset of the branding fields above.# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to# match any subdomain. Unset fields fall back to the global values.DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
Extension directory
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.orgPLUGIN_DEV_DIR=../my-plugins# load plugins from disk instead of ZIPs

EXTENSION_DIRECTORY_URL enables the admin marketplace for browsing and installing plugins and themes. PLUGIN_DEV_DIR is for plugin authors: each immediate subfolder is one plugin with a manifest.json, and an entrypoint under src/ is bundled on demand with esbuild, so editing sources needs only a browser refresh.

Sandboxed plugins that integrate provider-side labels can use the native api.keywords facade exposed by @plugin-host:

constapi=require('@plugin-host');constknown=awaitapi.keywords.list();// settings:readconstscan=awaitapi.jmap.getKeywords();// email:readconstproviderLabel=scan.labels.find((label)=>label.id.startsWith('$label:'));if(providerLabel){awaitapi.keywords.add([{// settings:writeid: providerLabel.id.slice('$label:'.length),label: providerLabel.name,// color is optional; Bulwark picks a palette colour when omittedvisibility: 'show',}]);}constcurrent=awaitapi.keywords.list();// settings:readawaitapi.keywords.reorder(current.map(({ id })=>id),{// settings:writecaseSensitive: false,// default});constcounts=awaitapi.keywords.refreshCounts();// email:read// Complete replacement: keywords omitted here are removed from the message.awaitapi.jmap.setKeywords('email-id',{// email:write'$seen': true,'$label:provider-label-id': true,});awaitapi.jmap.setKeyword('email-id','$label:work');// email:writeawaitapi.jmap.removeKeyword('email-id','$label:work');// email:write

jmap.getKeywords() is a narrow read-only facade rather than an arbitrary JMAP request API. When the JMAP server advertises https://bulwarkmail.com/ns/jmap/keywords, it returns all cached keywords with exact total/unread counts and provider-label metadata, including empty provider labels. On servers without the capability it falls back to a bounded scan of message keywords. keywords.discover() retains its original message-scan response for compatibility.

jmap.setKeywords() replaces one message's complete keyword map via Email/set. Omitted keywords are removed, so extensions should use the existing jmap.setKeyword() and jmap.removeKeyword() methods for incremental edits. The existing email.setKeyword() and email.removeKeyword() names remain as compatibility aliases.

keywords.add() is append-only and case-insensitive by id: it returns added and skipped definitions without overwriting the user's existing label name, colour, visibility, or order. keywords.reorder() accepts a complete permutation of the existing label ids and changes only their order; missing, unknown, or duplicate ids are rejected without changing settings. Matching is case-insensitive by default; pass { caseSensitive: true } to require exact id casing. Keyword discovery reports whether its bounded scan was complete.

Stalwart integration & logging
STALWART_FEATURES=true# password change, Sieve filters, etc.LOG_FORMAT=text# "text" or "json"LOG_LEVEL=info# error | warn | info | debug
Admin data directories
ADMIN_CONFIG_DIR=./data/admin# operator-authored: config.json, policy.json, plugins/, themes/ADMIN_STATE_DIR=./data/admin-state# runtime: audit log, login timestamps, setup tokenADMIN_CONFIG_READONLY=true# enforce read-only mode at the app layer

The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through ADMIN_DATA_DIR.

Default UI locale

The UI language follows each visitor's Accept-Language header and their stored preference. NEXT_PUBLIC_DEFAULT_LOCALE sets the fallback used when neither matches a supported locale (default en):

NEXT_PUBLIC_DEFAULT_LOCALE=de

Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh. An unsupported value falls back to en.

Like NEXT_PUBLIC_BASE_PATH, this is read at build time. To use it with the published Docker image, build your own:

docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail .
Subpath / reverse proxy mount

To serve the webmail at a subpath (e.g. https://example.com/webmail):

NEXT_PUBLIC_BASE_PATH=/webmailNEXT_PUBLIC_LOCALE_PREFIX=always# avoids next-intl rewrite loops

Unlike most other variables, NEXT_PUBLIC_BASE_PATH is read at build time because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:

docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .

Then point your reverse proxy at the container without stripping the prefix. The app expects requests under /webmail/... and serves every route (/webmail/api/..., /webmail/_next/static/..., /webmail/sw.js, and so on) accordingly.

Keyboard shortcuts

KeyAction
j / kNavigate between emails
Enter / oOpen email
EscClose / deselect
xExpand / collapse thread
cCompose
r / RaReply / Reply all
fForward
sStar
eArchive
# / DelDelete
u / Shift+IMark unread / read
!Toggle spam
Ctrl+ASelect all
Shift+GRefresh
/Search
?Show all shortcuts

In the composer: Ctrl/Cmd+Enter sends, Ctrl/Cmd+Shift+Enter opens scheduled send, and t opens the template picker.

Tech stack

FrameworkNext.js 16 with App Router, React 19
LanguageTypeScript
StylingTailwind CSS v4
StateZustand
ProtocolCustom JMAP client (RFC 8620)
EditorTiptap
i18nnext-intl
IconsLucide React
TestingVitest + Playwright

Why Stalwart?

Stalwart is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.

Contributing

See CONTRIBUTING.md.

License

GNU AGPL v3. This repository preserves the original MIT attribution for the fork lineage in NOTICE.

Acknowledgments

Thanks to root-fr/jmap-webmail and @ma2t for the groundwork this project builds upon.

About

Used for testing

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages