Architecture
Where the work happens, and why it was split up this way.
The shape of it
┌──────────────┐ JMAP/HTTP ┌──────────────┐
│ Browser │ ─────────────────► │ Stalwart │
│ React + Zus. │ │ Mail Server │
└──────────────┘ └──────────────┘
▲ ▲
│ │
│ Bootstraps auth, serves config │ JMAP `x:` (admin)
▼ │
┌──────────────┐ │
│ Bulwark │ ───────────────────────────┘
│ (Next.js) │
└──────────────┘
After authentication bootstrap, the browser talks JMAP directly to Stalwart. Bulwark's Next.js server is responsible for credential encryption, OAuth PKCE flows, runtime config, settings sync persistence, the first-launch setup wizard, and the admin dashboard - never as a proxy for normal mail traffic.
Project structure
webmail/
├── app/ # Next.js App Router
│ ├── api/ # API routes (auth, config, health, sso, settings, admin, setup, push, etc.)
│ └── [locale]/ # Locale-aware routing
│ ├── login/ # Login page
│ ├── auth/ # OAuth callback
│ ├── setup/ # First-launch setup wizard
│ ├── calendar/ # Calendar page
│ ├── contacts/ # Contacts page
│ ├── files/ # JMAP FileNode browser
│ ├── admin/ # Admin dashboard (single tabbed page)
│ └── settings/ # Settings page
├── components/ # React components, organized by feature
│ ├── email/ # Email list, viewer, composer
│ ├── calendar/ # Calendar views, event modals, task views
│ ├── contacts/ # Contact list, detail, groups, address books
│ ├── files/ # File browser, previews, upload
│ ├── layout/ # Sidebar, navigation rail, header
│ ├── search/ # Search panel, chips
│ ├── settings/ # Settings tabs, identities, S/MIME, PWA
│ ├── filters/ # Sieve filter builder
│ ├── identity/ # Identity management
│ ├── templates/ # Email template manager
│ ├── plugins/ # Plugin host, slots, harness
│ ├── admin/ # Admin dashboard panels
│ └── ui/ # Reusable UI primitives
├── contexts/ # React contexts (drag-and-drop, etc.)
├── hooks/ # Custom React hooks
├── lib/ # Utilities and libraries
│ ├── jmap/ # Custom JMAP client (RFC 8620)
│ ├── auth/ # Session cookies, AES-256-GCM crypto
│ ├── oauth/ # OAuth discovery, PKCE, token exchange
│ ├── sieve/ # Sieve script generator/parser
│ ├── smime/ # S/MIME sign/encrypt/decrypt/verify
│ ├── tnef/ # winmail.dat extractor
│ ├── ical/ # iCal/iMIP encoder and decoder
│ ├── plugins/ # Plugin loader, sandbox, permissions, proxy, esbuild on-demand
│ ├── admin/ # Admin session, config manager (split config/state), audit log
│ ├── setup/ # First-launch web setup wizard
│ ├── telemetry/ # Anonymous heartbeat (opt-out)
│ └── stalwart/ # Stalwart `x:` JMAP method bindings
├── stores/ # Zustand state stores
│ ├── auth-store.ts
│ ├── email-store.ts
│ ├── calendar-store.ts
│ ├── contact-store.ts
│ ├── file-store.ts
│ ├── settings-store.ts
│ ├── theme-store.ts
│ ├── filter-store.ts
│ ├── template-store.ts
│ ├── identity-store.ts
│ ├── plugin-store.ts
│ └── ...
├── locales/ # One directory per language, 24 of them:
│ # ar ca cs da de en es fa fr he hu it
│ # ja ko lv nl pl pt ro ru sk tr uk zh
├── i18n/ # next-intl configuration
├── public/ # Static assets, branding, PWA icons, service worker
└── e2e/ # Playwright end-to-end tests
JMAP integration
Everything Bulwark says to Stalwart goes over JMAP, through a client of its own. That client sits behind an interface, so a demo backend with fixture data can take its place; npm run dev with .env.dev.example does exactly that, and so do demo deployments. With ALLOW_CUSTOM_JMAP_ENDPOINT, the server URL can also come from the login form or from settings.
Request and response pattern
All JMAP operations use a single HTTP endpoint. Requests are batched method calls:
{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Email/query", { "filter": { "inMailbox": "inbox-id" } }, "call-0"],
[
"Email/get",
{ "#ids": { "resultOf": "call-0", "path": "/ids" } },
"call-1"
]
]
}
Capability detection
At session creation Bulwark reads the advertised capabilities and turns features on accordingly:
urn:ietf:params:jmap:core- required, batched method calls and state tokensurn:ietf:params:jmap:mail- required, email and mailbox handlingurn:ietf:params:jmap:calendars- calendar events and tasksurn:ietf:params:jmap:contacts- JMAP ContactCard / AddressBookurn:ietf:params:jmap:vacationresponse- vacation auto-replyurn:ietf:params:jmap:sieve- server-side email filters- Stalwart's JMAP FileNode extension - cloud file storage
- Stalwart's
x:method namespace - admin, API keys, app passwords (Stalwart 0.16+)
Session URL rewriting
The URLs in the JMAP session resource are rewritten to the origin the client actually connected to. That is what lets a Stalwart advertising an internal hostname such as http://stalwart:8080 work behind a reverse proxy without the browser ever noticing.
Plugin system
The plugin system covers:
- Schema-driven configuration - plugins declare a config schema, the admin UI is generated from it inline
- Render hooks - plugins can render into named slots (calendar event actions, composer sidebar, email banner/footer, sidebar widget, settings sections, context menus, navigation rail, toolbars)
- Intercept hooks - plugins can intercept user actions like send, reply, archive
onBeforeEmailSendhook - hook into the outgoing send pipeline;OutgoingEmailexposesfromEmailso plugins can branch on identityonAvatarResolvehook - provide custom avatar resolution logic- Plugin i18n API - plugins ship their own translation bundles
- Sandboxed HTTP proxy - plugins talk to external services through a server-side proxy with origin validation, never exposing user credentials
http:fetch+httpOrigins- per-plugin allowlist of outbound origins enforced by the proxyframeOriginsmanifest field - plugins declare whichhttps://hostorigins they need to embed; the proxy reads the union of enabled plugin origins and merges into the host CSPframe-src- Hot-reload and dev folder -
PLUGIN_DEV_DIRloads plugins from a folder during development, esbuild bundlessrc/on demand - Install/uninstall restricted to admin dashboard - regular users cannot add or remove plugins
- Disabled by default + admin approval - plugins are inert until an admin enables them
- Dangerous-pattern detection - plugins with prohibited JS patterns are blocked at install time
- Theme bundles - themes are uploaded as ZIP packages and managed alongside plugins; theme API v2 includes a token compiler and skin slot
A bundled Jitsi Meet plugin demonstrates the calendar event slot.
Push notifications
Real-time updates ride JMAP push: new mail, calendar changes, and filter state all arrive because the server sends them, not because the client asked. On HTTP/2 the push streams multiplex over one connection, which is what lifted the old 5-account cap. That cap was never ours; it came from HTTP/1.1 connection pooling in the browser.
Web push notifications layer on top of this: when the user grants permission, the service worker subscribes to JMAP push for the inbox and surfaces new-mail notifications even when the tab is closed.
State management
- Server state - Managed via JMAP state tokens for efficient incremental sync
- UI state - Zustand stores with optional
persistmiddleware - Theme state - Zustand store persisted in
localStoragewith system preference detection - Settings state - Zustand store with optional encrypted server-side sync (
SETTINGS_SYNC_ENABLED) - Per-account state - Cached in memory while the account is active so account switching is instant; OAuth refresh tokens are stored encrypted in httpOnly cookies
Security
- All communication uses HTTPS in production
- Session-based auth with no plaintext password storage
- Optional "Remember me" with AES-256-GCM encrypted httpOnly cookies (30-day expiry)
- OAuth2/OIDC with PKCE; server-side PKCE state for embedded SSO survives top-level navigation
- Strict session secret length validation
- HTML sanitization with DOMPurify
- External content blocked by default with per-sender trust list
- Enforced CSP with per-request nonce
- Plugin
frameOriginsmerged into host CSPframe-src - X-Content-Type-Options, X-Frame-Options (when not embedding), Referrer-Policy, and Permissions-Policy headers
- SSRF redirect validation
- IP spoofing prevention (configurable trusted-proxy depth)
- PDF iframe sandbox for safe document preview
- Plugin sandbox with dangerous-pattern detection and admin approval requirement
- Encrypted settings sync (per-account AES-256-GCM)
- S/MIME with per-account key isolation