Three things, all from running the real thing rather than trusting a status code.
1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
A locally-running `opencode serve` — the same runtime Paperclip drives as
an adapter. Its appeal over a BYOK profile is precisely what was broken
before: opencode owns provider auth itself, so there is NO api key for
this app to hold, and it reports a REAL model list (25 on this machine)
instead of asking the user to type an exact provider-specific model id
from memory. Typing "Sonnet 5" into a free-text box and getting a bare
"Provider returned 401" is the failure this removes.
IMPORTANT trap, documented in the module header and pinned by a test:
opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
both answer 200 — because a web-UI catch-all serves index.html for ANY
unknown path. I built the first version against that assumed compatibility
on the strength of two 200s and had to throw it away once I read a body.
Every probe now validates the parsed shape and content-type, never the
status alone. The real API is GET /api/model + POST /session +
POST /session/{id}/message, and the reply's `reasoning` parts are stripped
so a model's private chain of thought can never surface as the answer.
Proxied through our own backend (like the `server` class) because the
desktop renderer's origin is a random port that changes every launch;
same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
no keys, nothing leaves the device" is the whole point of this class.
2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
omitted `?slot=`, so search resolved to whichever account the multi-slot
resolver found first. Single-account installs never noticed; a real
multi-account/shared-mailbox setup reads an empty store every time. Both
call sites now pass the active slot.
3. "No local mail index available in this session" was shown even when the
index existed and simply matched nothing — actively misleading, and it
masked the missing-SESSION_SECRET bug for hours. AskResult now carries
retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
get different words: build the index, versus rephrase (with the honest
caveat that keyword search answers content questions better than recency
ones like "the last mail").
Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.
Bulwark Webmail
A self-hosted webmail client for Stalwart Mail Server, built with Next.js and the JMAP protocol.
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-lockedmarker 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
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 writes
JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail
Server listen address
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
PORT=3000
OAuth2 / OIDC
OAUTH_ENABLED=true
OAUTH_ONLY=true # hide the username/password form entirely
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs
OAUTH_AUTHORIZE_URL= # override only the user-facing authorize endpoint
OAUTH_ALLOW_PRIVATE_ENDPOINTS= # allow discovery to resolve to RFC-1918 addresses
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 default
TELEMETRY_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 32
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
SETTINGS_SYNC_ENABLED=true
SETTINGS_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.
Custom JMAP endpoint
ALLOW_CUSTOM_JMAP_ENDPOINT=true
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
Branding & PWA
APP_NAME=My Webmail
APP_SHORT_NAME=Webmail
APP_DESCRIPTION=Your personal mail
FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
LOGIN_COMPANY_NAME=My Company
LOGIN_WEBSITE_URL=https://example.com
LOGIN_IMPRINT_URL=https://example.com/imprint
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# 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.org
Enables the admin marketplace for browsing and installing plugins and themes.
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 token
ADMIN_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=/webmail
NEXT_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
| Key | Action |
|---|---|
j ↓ / k ↑ |
Navigate between emails |
Enter / o |
Open email |
Esc |
Close / deselect |
x |
Expand / collapse thread |
c |
Compose |
r / R a |
Reply / Reply all |
f |
Forward |
s |
Star |
e |
Archive |
# / Del |
Delete |
u / Shift+I |
Mark unread / read |
! |
Toggle spam |
Ctrl+A |
Select all |
Shift+G |
Refresh |
/ |
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
| Framework | Next.js 16 with App Router, React 19 |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| State | Zustand |
| Protocol | Custom JMAP client (RFC 8620) |
| Editor | Tiptap |
| i18n | next-intl |
| Icons | Lucide React |
| Testing | Vitest + 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.




