-
# Contributing to Bulwark Webmail
-We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native client with an interface built this decade. It's AGPL and self-hosted, run by the people who use it rather than sold to them.
+Thanks for helping build the webmail we all wished existed. Bug reports, feature requests, translations and patches are all welcome.
-If that sounds like your kind of project, we'd love the help.
+The full guide lives in the documentation: **[Contributing](https://bulwarkmail.org/docs/development/contributing)**. It covers the development setup, the test suites, translations and right-to-left layouts, code style, and the pull request process.
-## Join the community
-
-You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this.
-
-- **Get support** - real-time help with development hurdles
-- **Share ideas** - feature suggestions, design feedback, doc improvements
-- **Collaborate** - meet the team and other contributors
-
-[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
-
----
-
-## Getting started
-
-### Development setup
-
-1. **Fork and clone** the repository:
-
- ```bash
- git clone https://github.com/bulwarkmail/webmail.git
- cd webmail
- ```
-
-2. **Install dependencies**:
-
- ```bash
- npm install
- ```
-
-3. **Set up environment**:
-
- ```bash
- cp .env.dev.example .env.local
- ```
-
- This enables the built-in mock JMAP server (`DEV_MOCK_JMAP=true`), so you can
- develop without a mail server. Log in with any username and password. To work
- against a real server instead, copy `.env.example` and set `JMAP_SERVER_URL`.
-
-4. **Start development server**:
-
- ```bash
- npm run dev
- ```
-
- Then open http://localhost:3000.
-
-### Code quality
-
-Before submitting a pull request, ensure your code passes all checks:
+The short version:
```bash
-# Type checking
-npm run typecheck
-
-# Linting
-npm run lint
-
-# Fix lint issues automatically
-npm run lint:fix
+git clone https://github.com/bulwarkmail/webmail.git
+cd webmail
+npm install
+cp .env.dev.example .env.local # built-in mock JMAP server, no mail server needed
+npm run dev
```
-These checks run automatically on commit via Husky pre-commit hooks.
+Before opening a pull request, run `npm run typecheck && npm run lint && npx vitest run`, and use [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, …).
-### Testing
-
-| Suite | Command | What it covers |
-| ---------------- | -------------------------- | ------------------------------------------------------------------ |
-| **Unit** | `npx vitest run` | Vitest + jsdom. Tests live in `__tests__/` folders next to the code |
-| **Translations** | `npm run test:translations` | Locale files checked for structural drift against English |
-| **Integration** | `npm run test:integration` | Playwright against a real Stalwart server in Docker |
-
-Run a single unit test file with `npx vitest run lib/__tests__/.test.ts`, or `npx vitest` to watch.
-
-The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there.
-
-## Code style guidelines
-
-### TypeScript
-
-- Use TypeScript for all new code
-- Define proper types and interfaces
-- Avoid `any` types when possible
-- Use meaningful variable and function names
-
-### React components
-
-- Use functional components with hooks
-- Keep components focused and single-purpose
-- Extract reusable logic into custom hooks
-- Place components in appropriate directories under `/components`
-
-### Styling
-
-- Use Tailwind CSS utility classes
-- Follow the existing design system
-- Support both dark and light themes
-- Use CSS variables for theme colors
-
-## Internationalization (i18n)
-
-This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 23 additional locales (ar, ca, cs, da, de, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh).
-
-Arabic, Hebrew, and Persian render right-to-left (see `i18n/direction.ts`). Use Tailwind's **logical** utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`) rather than physical ones (`ml-*`, `pl-*`, `left-*`) so layouts flip correctly. For popovers positioned in JS via `getBoundingClientRect()`, check `isDocumentRTL()`: inline `position: fixed` styles don't pick up logical utilities.
-
-### Rules
-
-1. **Never hardcode user-facing text** - always use translations:
-
- ```tsx
- const t = useTranslations("namespace");
- return
{t("key")}
;
- ```
-
-2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English.
-
-3. **Namespace organization**:
- - `login.*` - login page
- - `sidebar.*` - sidebar navigation
- - `email_list.*` - email list
- - `email_viewer.*` - email viewer
- - `email_composer.*` - composer
- - `settings.*` - settings page
- - `notifications.*` - toasts and alerts
- - `common.*` - shared strings
-
-4. **Locale-aware navigation**:
-
- ```tsx
- router.push(`/${params.locale}/settings`);
- ```
-
-### Adding a new locale
-
-Registering a new locale takes edits in four places:
-
-1. `locales//common.json` - copy `locales/en/common.json` and translate
-2. `i18n/routing.ts` - add the code to `SUPPORTED_LOCALES`
-3. `i18n/request.ts` - add a `case` to the static-import switch
-4. `components/ui/language-switcher.tsx` - add `{ value, label }` with the **native** language name, plus a flag in `components/ui/flag-icons.tsx`
-
-For a right-to-left language, also add the code to `rtlLocales` in `i18n/direction.ts`.
-
-Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English.
-
-## Pull request process
-
-### Before submitting
-
-1. **Create a feature branch**:
-
- ```bash
- git checkout -b feature/your-feature-name
- ```
-
-2. **Make your changes** following the code style guidelines
-
-3. **Test your changes** thoroughly, and add unit tests for new logic
-
-4. **Update translations** if you added user-facing text
-
-5. **Run all checks**:
- ```bash
- npm run typecheck && npm run lint && npx vitest run
- ```
-
-### Submitting
-
-1. **Push your branch** to your fork
-
-2. **Open a Pull Request** with:
- - Clear title describing the change
- - Description of what was changed and why
- - Screenshots for UI changes
- - Reference to any related issues
-
-### Commit message convention
-
-Follow the conventional commits format:
-
-- `feat:` - New features
-- `fix:` - Bug fixes
-- `docs:` - Documentation changes
-- `style:` - Code style changes (formatting, etc.)
-- `refactor:` - Code refactoring
-- `test:` - Adding or updating tests
-- `chore:` - Maintenance tasks
-
-Examples:
-
-```
-feat: add email threading support
-fix: resolve attachment download issue
-docs: update README with keyboard shortcuts
-```
-
-## Project structure
-
-```
-webmail/
-├── app/ # Next.js App Router
-│ ├── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings)
-│ ├── (main)/admin/ # Admin dashboard
-│ ├── (main)/setup/ # First-launch setup wizard
-│ ├── (sandbox)/ # Isolated plugin sandbox routes
-│ └── api/ # Route handlers (auth, admin, jmap, caldav, …)
-├── components/ # React components
-│ ├── email/ # Email list, viewer, composer
-│ ├── calendar/ contacts/ files/ filters/ templates/
-│ ├── layout/ # Sidebar, shell, navigation
-│ ├── settings/ # Settings panels
-│ ├── plugins/ # Plugin host UI
-│ └── ui/ # Reusable primitives
-├── contexts/ # React contexts
-├── hooks/ # Custom React hooks
-├── i18n/ # next-intl routing, locale detection, RTL direction
-├── lib/ # Utilities and libraries
-│ ├── jmap/ # JMAP client implementation
-│ ├── stalwart/ # Stalwart-specific admin/API helpers
-│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows
-│ ├── plugin-sandbox/ # Plugin sandbox bridge and hardening
-│ └── __tests__/ # Vitest unit tests
-├── locales/ # Translation files, one directory per locale
-├── stores/ # Zustand state stores
-├── public/ # Static assets and branding
-└── integration/ # Dockerized Stalwart + Playwright suite
-```
-
-## Security
-
-- **Never commit secrets** - API keys, passwords, tokens, `.env*` files
-- **Sanitize user input** and email content
-- **Block external content** by default - privacy is the point
-- **Report vulnerabilities privately** to dev@bulwarkmail.org, not via public issues
-
-## Questions?
-
-Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
+- **Questions**: ask on [Discord](https://discord.gg/tYCujymGrT).
+- **Documentation fixes**: the docs are Markdown in the [website repository](https://github.com/bulwarkmail/website/tree/main/docs).
+- **Security vulnerabilities**: report them privately to [dev@bulwarkmail.org](mailto:dev@bulwarkmail.org) or through a [security advisory](https://github.com/bulwarkmail/webmail/security/advisories/new), never in a public issue.
diff --git a/FEATURES.md b/FEATURES.md
deleted file mode 100644
index fb4461820..000000000
--- a/FEATURES.md
+++ /dev/null
@@ -1,163 +0,0 @@
-# Features
-
-## Mail
-
-- Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables
-- Gmail-style threading, expanded inline, with a conversation toggle you can switch off
-- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account. The combined folder rows only appear once 2+ accounts are connected (or "Include group inboxes" is on and a group inbox exists) — with just one account and no group inbox, turning the setting on adds the section header but nothing under it.
-- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from. Each of these three is a separate admin policy gate (see [Admin & extensibility](#admin--extensibility)) — if none are enabled for your instance, the toggles for them do not even appear in Settings → Appearance, and the Unified Mailbox section can look empty even with the feature turned on and multiple accounts connected. Ask your admin to enable the ones you need.
-- Search runs across all unified views; the per-role mailboxes add the full filter panel on top
-- Three mail layouts: split three-pane, focused list, or reading pane at the bottom
-- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies
-- Attachments upload, download, drag out to the file system, and preview inline. Images and PDFs render on desktop and mobile, composer attachments open on click, and `.eml` (`message/rfc822`) parts display as a nested email. There are list thumbnails, and a warning when you mention an attachment and forget it.
-- Scheduled send, plus a configurable delay before anything leaves the outbox
-- Sending without a subject asks for confirmation instead of refusing, and the prompt can be switched off
-- Read receipts (MDN, RFC 8098)
-- Quoted text lands in an editable island that keeps the original layout
-- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
-- Multi-select for batch archive, delete, move, and tag
-- Archive directly, by year, or by month
-- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
-- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
-- Each tag can be configured to show always, only when there are unread mails or always be hidden
-- Tag names and colors live in the browser, while the tags themselves ride on your messages as JMAP keywords. A scan finds keywords no local tag explains and adds them back.
-- Star or unstar, with a configurable mark-as-read delay
-- Configurable message-list order: presets (unread first, starred first, tagged first) or up to three custom sort levels, applied server-side through the JMAP sort so the whole folder is ordered, for the Inbox only or every folder
-- Large mailboxes scroll virtually, and the first page of mail prefetches at login
-- A refresh button in the list toolbar that spins while the fetch is in flight, alongside the F5 / Ctrl+R / pull-to-refresh gestures it shares a code path with
-- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
-- Plain-text composer mode and Reply-To
-- The signature sits above or below the quoted text, per identity
-- Override the From header in the composer. Reply to an alias on a domain you own and it auto-fills as the sender, even when no identity exists for it.
-- Import `.eml` files from the folder right-click menu
-- Forward a message as an `.eml` attachment, from the viewer or the list right-click menu, named by your filename template
-- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
-- Folders take an icon, nest, and show counts in the sidebar
-- Print from the viewer
-- Browser back and forward move through mail history
-
-## Calendar
-
-- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
-- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
-- Recurring events edit and delete by scope: this occurrence, this and following, or all
-- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
-- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
-- iCalendar import previews first, then bulk-creates, deduplicating on UID
-- iCal / webcal subscriptions, editable, with batch import
-- A birthday calendar generated from your contacts
-- Virtual locations (video-conference URLs) are first-class event fields
-- Tasks with due dates, priority, and completion status
-- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
-- Week numbers, hover preview, notifications with a sound picker
-- JMAP push keeps everything in sync
-
-## Contacts
-
-- JMAP sync (RFC 9553 / 9610), falling back to local storage
-- Several address books, with drag-and-drop between them
-- Groups with member management
-- A card can be a person or an organization. Organization cards need no personal name; the org name carries the card and fills vCard's mandatory `FN`.
-- vCard import/export (RFC 6350) that flags duplicates
-- Trusted senders live in their own JMAP address book
-- Autocomplete on To, Cc, and Bcc
-
-## Filters & templates
-
-- Server-side filters as JMAP Sieve Scripts (RFC 9661)
-- A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard
-- Rules written in other clients survive the round-trip
-- Raw Sieve editor with syntax validation
-- A vacation responder you can schedule to a date range
-- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
-
-## Files
-
-- Browse Stalwart's native JMAP FileNode storage as a real folder tree. Legacy flat-named files migrate into nested `FileNode` folders on first load.
-- Streamed WebDAV PUT upload, whole folders included, with progress
-- Upload limits follow the server's own configuration
-- Grid or list, sorted by name, size, or date
-- Preview images, text, audio, and video
-- Cut, copy, paste, duplicate; favorites; recent files
-- JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me".
-
-## Security & privacy
-
-- External content stays blocked until you say otherwise, and trusted senders are remembered
-- HTML sanitized through DOMPurify
-- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
-- Add S/MIME or PGP public keys per account, then turn on Stalwart's encryption at rest against one of them. The algorithm, whether new mail is encrypted on upload, and whether spam training runs before encryption are all configurable.
-- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail
-- OAuth2 / OIDC with PKCE against Keycloak, Authentik, or the built-in provider, plus OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
-- TOTP two-factor authentication
-- Password and 2FA management through the Stalwart admin API
-- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
-- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
-- Plugins are scanned for dangerous patterns and need admin approval
-- Newsletter unsubscribe (RFC 2369)
-
-## Interface
-
-- Split three-pane, focused list, or bottom reading pane, columns resizable
-- Dark and light themes. Email colors are remapped by luminance, so a mail hard-coded to dark-on-white stays readable on a dark background.
-- Bundled themes such as Aurora Glass and Elastic. Each theme card renders as a miniature mailbox built from that theme's own colors, with chips for the light and dark variants.
-- Layouts for desktop, tablet, and mobile
-- Full keyboard navigation
-- Drag and drop to organize mail and assign tags
-- A guided tour for first-time users
-- Right-click menus, and toasts that offer an undo
-- Every screen has a permalink. `/mail/message/`, `/mail/thread/`, `/mail/folder/inbox`, `/calendar/day/2026-08-06`, `/calendar/event/`, `/contacts/`, `/files/`, and `/settings/` all open directly, the address bar follows what you are looking at, and "Copy link" is in the message, conversation, and event menus. Dashboards and launchers can link straight to a message; browser back and forward walk the mail UI.
-- Toolbar position, favicon, and login branding are configurable
-- Sidebar apps pin and reorder by drag
-- Settings sync between devices, encrypted
-- Storage quota display
-- WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions
-
-## Internationalization
-
-27 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Norsk bokmål · Polski · Português · Română · Slovenčina · Türkçe · Монгол · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文 · 繁體中文
-
-- Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically
-- The browser's `Accept-Language` picks the first language, and the choice persists per user
-- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix
-
-## Identity & multi-account
-
-- Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit.
-- An account switcher showing connection status, and a default account
-- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
-- Signature above or below the quoted text
-- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
-- Shared folders across accounts
-- Shared and group (delegated) accounts put their folders next to your own, and "Include group inboxes" merges them into the Unified Mailbox. You can open, mark read, flag as spam or not-spam, move, delete, and archive their messages from there, and folder unread counts stay in step.
-- Several JMAP servers per deployment, optionally auto-picked by email domain
-- Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it
-
-## Admin & extensibility
-
-- A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched.
-- The Stalwart admin dashboard, its policy sections collapsed into one tabbed page
-- Admin policy gates for the Unified Mailbox: turn All mail / Unread / Starred on or off org-wide, and gate cross-account capability separately (off by default, auto-enabled on upgrade for instances already using it). A gated view still respects the user's own toggle.
-- Admin storage splits in two. `ADMIN_CONFIG_DIR` is operator-authored and can be mounted read-only once setup finishes; `ADMIN_STATE_DIR` holds the runtime audit log and login timestamps.
-- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
-- An admin toggle controls search-engine indexing (`robots.txt` / `noindex`)
-- Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar, email-banner, calendar-event, and contact crypto-key slots, i18n APIs (sandboxed plugins localize through manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
-- Plugin APIs reach contacts (`contact.get` / `create` / `update` / `search`), the signed-in user (`user.getAccounts`, `user.getIdentities`, `user.logout` and a logout hook), and — on the privileged tier — public keys and encryption at rest. Each sits behind its own consent permission.
-- `onBeforeBlobUpload` can divert an attachment to external storage before it reaches the server, and `api.http.post` takes binary `Blob` / `File` bodies to get it there
-- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
-- Themes upload as ZIP bundles, and admins can enforce one
-- An extension marketplace browses and installs plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`). Installing and uninstalling stay in the admin dashboard.
-- Bundled plugins, including Jitsi Meet for the calendar
-
-## Operations
-
-- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
-- The install prompt and the background-notification prompt are sequenced, so the two never compete for the same moment. Neither appears on login, setup, or settings screens.
-- Update checks run on their own, log new releases server-side, and raise a notice that can't be dismissed
-- Structured logging (`text` or `json`) with per-category levels
-- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles.
-- Docker images on GHCR, for release (`main`) and development (`dev`)
-- Bulwark Lite: a static export of the client (`npm run build:lite`, `bulwark-lite-.zip` on every release) for plain static hosts; runtime `config.json`, cookie-free token login against Stalwart, no admin/plugin/sync features
-- Bulwark Lite for Stalwart: `bulwark-lite-stalwart.zip` installs as a Stalwart `Application` (served by Stalwart itself under any prefix, same origin, zero configuration)
-- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
-- Demo mode runs on fixture data, no mail server required
diff --git a/README.md b/README.md
index 0e5890741..8e8ac3bfc 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,27 @@
-Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
+
+
+Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js on the JMAP protocol. Mail, calendar, contacts and files share one login, one settings store and one admin dashboard.
+
+## Contents
+
+- [Screenshots](#screenshots)
+- [Features](#features)
+- [Quick start](#quick-start)
+- [Other ways to install](#other-ways-to-install)
+- [Configuration](#configuration)
+- [Documentation](#documentation)
+- [Development](#development)
+- [Community and support](#community-and-support)
+- [License](#license)
## Screenshots
@@ -45,452 +65,98 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https
-## What Bulwark includes
+## Features
-Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting:
+- **[Mail](https://bulwarkmail.org/docs/features/email)** – threading, unified inbox, cross-account views, [full-text search](https://bulwarkmail.org/docs/features/email/search), Sieve filters, [S/MIME](https://bulwarkmail.org/docs/guides/smime), templates, scheduled send
+- **[Calendar](https://bulwarkmail.org/docs/features/calendar)** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
+- **[Contacts](https://bulwarkmail.org/docs/features/contacts)** – multiple address books, groups, vCard import/export
+- **[Files](https://bulwarkmail.org/docs/features/files)** – Stalwart's JMAP FileNode storage with previews, sharing and folder upload
-- **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
+Across all four: [single sign-on](https://bulwarkmail.org/docs/getting-started/configuration/authentication) and [2FA](https://bulwarkmail.org/docs/guides/account-security), [multiple accounts](https://bulwarkmail.org/docs/guides/multi-account), 27 languages, [PWA install and web push](https://bulwarkmail.org/docs/features/pwa), [themes](https://bulwarkmail.org/docs/guides/customization), [plugins](https://bulwarkmail.org/docs/guides/plugins) and [keyboard shortcuts](https://bulwarkmail.org/docs/guides/keyboard-shortcuts).
-They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 27 languages, PWA install, themes, and plugins apply across all four.
+The complete list is on **[All features](https://bulwarkmail.org/docs/features/overview)**.
-Full feature list: **[FEATURES.md](FEATURES.md)**.
-
----
+Bulwark comes in two [editions](https://bulwarkmail.org/docs/getting-started/editions): the full edition, a Node.js server with the admin dashboard, OAuth, plugins and settings sync; and **[Bulwark Lite](https://bulwarkmail.org/docs/getting-started/lite)**, the same client as static files that any web host, or Stalwart itself, can serve.
## Quick start
-### Docker
-
```bash
docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
```
-Or with Docker Compose:
+Open `http://localhost:3000` and the setup wizard walks you through connecting your Stalwart server. The [installation guide](https://bulwarkmail.org/docs/getting-started/installation) covers the details, and [Stalwart setup](https://bulwarkmail.org/docs/getting-started/configuration/stalwart-setup) covers the mail server side.
-```bash
-docker compose up -d
+## Other ways to install
+
+| Method | Guide |
+| --- | --- |
+| Docker Compose | [Compose](https://bulwarkmail.org/docs/deployment/docker/compose) |
+| Behind a reverse proxy or on a sub-path | [Reverse proxy](https://bulwarkmail.org/docs/deployment/docker/reverse-proxy) |
+| From source, without Docker | [Manual install](https://bulwarkmail.org/docs/deployment/manual) |
+| Lite on any static host | [Static hosting](https://bulwarkmail.org/docs/deployment/static) |
+| Lite as a container | [Container image](https://bulwarkmail.org/docs/deployment/static#container-image) |
+| Lite served by Stalwart | [Install on Stalwart](https://bulwarkmail.org/docs/deployment/stalwart-app) |
+
+To move to a new version, see [Updating](https://bulwarkmail.org/docs/deployment/updating).
+
+## Configuration
+
+Most installs are configured in the setup wizard on first launch and then in the [admin dashboard](https://bulwarkmail.org/docs/guides/admin). Environment variables work too and suit immutable infrastructure better; a variable always wins over the admin-managed value.
+
+```env
+JMAP_SERVER_URL=https://mail.example.com
+APP_NAME=My Webmail
```
-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](#configuration).
+| Topic | Guide |
+| --- | --- |
+| Overview, config files and precedence | [Configuration](https://bulwarkmail.org/docs/getting-started/configuration) |
+| Every variable | [Environment reference](https://bulwarkmail.org/docs/getting-started/configuration/environment-reference) |
+| OAuth2 / OIDC and single sign-on | [Authentication](https://bulwarkmail.org/docs/getting-started/configuration/authentication), [Embedded SSO](https://bulwarkmail.org/docs/guides/embedded-sso) |
+| Several JMAP servers, custom endpoints | [Multi-server deployments](https://bulwarkmail.org/docs/getting-started/configuration/stalwart-setup#multi-server-deployments), [Custom endpoints](https://bulwarkmail.org/docs/getting-started/configuration#custom-jmap-server-endpoints) |
+| Branding, logos, per-domain branding | [Customization](https://bulwarkmail.org/docs/guides/customization) |
+| Anonymous telemetry (off by default) | [Anonymous usage stats](https://bulwarkmail.org/docs/features/telemetry) |
-### From source
+## Documentation
+
+Everything lives at **[bulwarkmail.org/docs](https://bulwarkmail.org/docs)**:
+
+- **Getting started** – [Introduction](https://bulwarkmail.org/docs/getting-started/introduction), [Installation](https://bulwarkmail.org/docs/getting-started/installation), [Editions](https://bulwarkmail.org/docs/getting-started/editions), [Bulwark Lite](https://bulwarkmail.org/docs/getting-started/lite), [Demo mode](https://bulwarkmail.org/docs/getting-started/demo-mode)
+- **Deployment** – [Docker](https://bulwarkmail.org/docs/deployment/docker), [Manual install](https://bulwarkmail.org/docs/deployment/manual), [Static hosting](https://bulwarkmail.org/docs/deployment/static), [Install on Stalwart](https://bulwarkmail.org/docs/deployment/stalwart-app), [Updating](https://bulwarkmail.org/docs/deployment/updating)
+- **Guides** – [Admin dashboard](https://bulwarkmail.org/docs/guides/admin), [Account security](https://bulwarkmail.org/docs/guides/account-security), [Impersonation](https://bulwarkmail.org/docs/guides/impersonation), [Plugins](https://bulwarkmail.org/docs/guides/plugins), [Marketplace](https://bulwarkmail.org/docs/guides/marketplace), [Troubleshooting](https://bulwarkmail.org/docs/guides/troubleshooting)
+- **Extensions** – [Introduction](https://bulwarkmail.org/docs/extensions/introduction), [manifest.json](https://bulwarkmail.org/docs/extensions/manifest), [Publishing](https://bulwarkmail.org/docs/extensions/publishing)
+- **Development** – [Architecture](https://bulwarkmail.org/docs/development/architecture), [Contributing](https://bulwarkmail.org/docs/development/contributing)
+- **Legal** – [Privacy](https://bulwarkmail.org/docs/legal/privacy)
+
+The pages are Markdown in the [website repository](https://github.com/bulwarkmail/website/tree/main/docs); corrections are welcome there.
+
+## Development
```bash
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
+cp .env.dev.example .env.local # built-in mock JMAP server, no mail server needed
+npm run dev
```
-### Static Lite build
-
-Bulwark Lite is the same client exported as static files: no Node.js process, upload the folder to any static host (nginx, Caddy, Netlify, Cloudflare Pages, GitHub Pages, an S3 bucket). Mail, calendar, contacts and files talk to the JMAP server straight from the browser, so the only server-side requirement is CORS on the mail server (`http.permissive-cors = true` in Stalwart).
-
-Every release ships `bulwark-lite-.zip` (and `bulwark-lite-stalwart.zip`, see [Install on Stalwart](#install-on-stalwart)), and the **Build Static Lite** workflow can be dispatched with a fixed server URL, app name, sub-path mount, locale subset or the demo flag. To build locally:
-
```bash
-git worktree add ../bulwark-lite HEAD # disposable checkout: the build deletes server-only trees
-cd ../bulwark-lite && npm ci
-LITE_JMAP_SERVER_URL=https://mail.example.com npm run build:lite -- --in-place
-npm run lite:serve # http://localhost:4173/
-```
-
-`out/` then holds one shell per locale and surface plus the deployer files: `config.json` (server URL, app name, login branding; editable without a rebuild), `policy.json`, `manifest.webmanifest`, `_redirects` and `_headers` (Netlify/Cloudflare), `nginx.conf.example`, `Caddyfile.example`, a `404.html` that replays deep links on hosts without rewrite rules, and `LITE-README.md` with the three-step setup. Build inputs: `LITE_JMAP_SERVER_URL`, `LITE_APP_NAME`, `LITE_ALLOW_CUSTOM_ENDPOINT`, `LITE_REMEMBER_ME`, `LITE_DEMO_MODE`, `LITE_LOCALES` (e.g. `en,de`), `NEXT_PUBLIC_BASE_PATH`.
-
-Lite keeps everything that runs in the browser (multi-account, password and TOTP login, "remember me" via Stalwart refresh tokens, themes, deep links, demo mode). Server-backed features are off: the admin console and setup wizard, plugins and sidebar apps, settings sync, OAuth/SSO, the account security tab, ICS URL subscriptions and CalDAV discovery, sender favicons, office editing, web push, the update banner, the "Default apps" protocol-handler registration. Password logins go through Stalwart's token endpoints in the browser (`/api/auth` + `/auth/token`, so those need CORS as well); without "remember me" the refresh token lives in sessionStorage and the session ends with the tab. `scripts/lite/verify.mjs` fails the build if a client chunk references a server endpoint that is not documented in `scripts/lite/lib.mjs`, and `npm run test:lite-smoke` drives a demo export with Playwright.
-
-#### Lite as a container
-
-`ghcr.io/bulwarkmail/webmail-lite` is the same export behind an unprivileged nginx (no Node.js at runtime, runs as uid 101, listens on 8080, IPv4 and IPv6). Releases are tagged like the main image (`latest`, `1.11.0`, `1.11`, `1`); `ghcr.io/bulwarkmail/webmail-lite-beta:latest` follows `main`. Configure it by mounting your own `config.json` (and, if you need it, `policy.json`) over the baked one:
-
-```yaml
-services:
- webmail-lite:
- image: ghcr.io/bulwarkmail/webmail-lite:latest
- ports:
- - "8080:8080"
- environment:
- # connect-src of the Content-Security-Policy. Defaults to "*" because the
- # image cannot know your mail server; pin it once config.json does.
- - LITE_CSP_CONNECT_SRC=https://mail.example.com
- volumes:
- - ./config.json:/usr/share/nginx/html/config.json:ro
- # Optional hardening: nginx only writes to these two.
- read_only: true
- tmpfs:
- - /tmp
- - /etc/nginx/conf.d:uid=101,gid=101
- restart: unless-stopped
-```
-
-```json
-{
- "appName": "Example Mail",
- "jmapServerUrl": "https://mail.example.com",
- "allowCustomJmapEndpoint": false,
- "rememberMeEnabled": true
-}
-```
-
-The nginx config is generated from the same routing rules as `nginx.conf.example` (deep links below a surface fall back to that surface's shell, everything else is a real 404) and adds what a static host's `_headers` would: the Content-Security-Policy and the other security headers, immutable caching for `/_next/static/`, revalidation for the shells and `config.json`, gzip. Terminate TLS in the reverse proxy in front of it. The image serves from `/`, so give it a host of its own rather than a sub-path, and remember that the browser talks to the mail server directly: CORS as described above. To bake different defaults or a locale subset, build it yourself - the build args are the `LITE_*` inputs above:
-
-```bash
-docker build -f Dockerfile.lite --build-arg LITE_LOCALES=en,de -t bulwark-lite .
-```
-
-#### Install on Stalwart
-
-Stalwart 0.16 can host the webmail itself as an `Application`: it downloads a zip, serves it under a path of its own HTTP listener, and the webmail talks to the same origin's JMAP, `/api/auth` and `/auth/token`, so there is no server URL to enter and no CORS to set up. Every release ships `bulwark-lite-stalwart.zip` for this (built with `npm run build:lite -- --target=stalwart`). An administrator creates the Application and then asks Stalwart to fetch it. Creating it alone mounts nothing:
-
-```bash
-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
-```
-
-The same in the web admin: **Settings > Web Applications**, create the entry, then run the "update applications" action (or restart Stalwart). Over JMAP, as an administrator:
-
-```json
-{
- "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:///webmail/`. Notes:
-
-- **Permissions**: `sysApplicationCreate` (plus `sysApplicationGet`/`Query`/`Update`/`Destroy` to manage it) and `actionUpdateApps`.
-- **Prefix**: `urlPrefix` matches the first path segment only, and one bundle may be mounted under several prefixes; it reads its prefix at runtime. Stalwart routes these segments itself, so they cannot host an Application: `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`.
-- **Updates**: Stalwart caches the zip for `autoUpdateFrequency` (default 90 days) and downloads `resourceUrl` again at the next restart or `UpdateApps` after that. `UpdateApps` always re-downloads, so it is also how to update right away. Open tabs switch to the new build on their next navigation (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.
-- **Pinning**: use `https://github.com/bulwarkmail/webmail/releases/download//bulwark-lite-stalwart.zip` as `resourceUrl`.
-- **OAuth client**: login uses the Application's `oauthClientId`, or `bulwark-webmail` when it is empty. If client registration is required, register that id with the redirect URI `https:////` (one per prefix).
-- **Single sign-on**: accounts whose directory is an external OpenID provider cannot use the password form (Stalwart has no password to check). The app asks Stalwart who signs an address in and shows **Sign in with SSO** for them instead: authorization code with PKCE, redeemed in the browser. At the provider, register a public client (the `oauthClientId`, or `bulwark-webmail`) with the redirect URI `https:////oauth/callback` (no locale in it, so one per prefix) and allow CORS from the Stalwart host on its token endpoint, as Stalwart's own web admin requires. Setting `oauthClientId` also offers SSO next to the password form.
-- **Minimum version**: Stalwart 0.16.0 (0.16.19 for `oauthClientId`).
-- **Differences from the static-host zip**: one entry document boots every route (Stalwart has no rewrite rules), no `_redirects`/`_headers`/nginx/Caddy files and no `404.html`, `config.json` defaults to the serving Stalwart and hides the server field, and `manifest.json` replaces `manifest.webmanifest`. The bundle is read-only; to rename or rebrand it, run the **Build Static Lite** workflow (or the build above with `LITE_APP_NAME` etc.), host the resulting `bulwark-lite-stalwart.zip` yourself and point `resourceUrl` at it.
-
-### Development
-
-```bash
-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)
+npx vitest run # unit tests
+npm run test:integration # Stalwart in Docker + Playwright
```
-## Configuration
+The [contributing guide](https://bulwarkmail.org/docs/development/contributing) covers tests, translations, code style and pull requests; [Architecture](https://bulwarkmail.org/docs/development/architecture) explains how the code fits together.
-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.
+Built with [Next.js 16](https://nextjs.org/) and React 19, TypeScript, [Tailwind CSS v4](https://tailwindcss.com/), [Zustand](https://zustand-demo.pmnd.rs/), [Tiptap](https://tiptap.dev/), [next-intl](https://next-intl-docs.vercel.app/), [Tabler Icons](https://tabler.io/icons), and a custom JMAP client (RFC 8620). Tests run on [Vitest](https://vitest.dev/) and [Playwright](https://playwright.dev/).
-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`:
+## Community and support
-```env
-# Optional – overrides whatever the wizard writes
-JMAP_SERVER_URL=https://mail.example.com
-APP_NAME=My Webmail
-```
-
-
-Server listen address
-
-```env
-HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
-PORT=3000
-```
-
-
-
-
-OAuth2 / OIDC
-
-```env
-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
-
-OAUTH_SCOPES= # replace the requested scopes (space-separated)
-OAUTH_EXTRA_SCOPES= # append to the defaults instead of replacing them
-AUTO_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
-
-```env
-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
-
-```env
-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`.
-
-
-
-
-Multiple JMAP servers & custom endpoints
-
-```env
-ALLOW_CUSTOM_JMAP_ENDPOINT=true
-
-JMAP_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
-
-```env
-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
-
-# 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
-
-```env
-EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
-PLUGIN_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`:
-
-```js
-const api = require('@plugin-host');
-
-const known = await api.keywords.list(); // settings:read
-const scan = await api.jmap.getKeywords(); // email:read
-const providerLabel = scan.labels
- .find((label) => label.id.startsWith('$label:'));
-if (providerLabel) {
- await api.keywords.add([{ // settings:write
- id: providerLabel.id.slice('$label:'.length),
- label: providerLabel.name,
- // color is optional; Bulwark picks a palette colour when omitted
- visibility: 'show',
- }]);
-}
-const current = await api.keywords.list(); // settings:read
-await api.keywords.reorder(current.map(({ id }) => id), { // settings:write
- caseSensitive: false, // default
-});
-const counts = await api.keywords.refreshCounts(); // email:read
-
-// Complete replacement: keywords omitted here are removed from the message.
-await api.jmap.setKeywords('email-id', { // email:write
- '$seen': true,
- '$label:provider-label-id': true,
-});
-await api.jmap.setKeyword('email-id', '$label:work'); // email:write
-await api.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
-
-```env
-STALWART_FEATURES=true # password change, Sieve filters, etc.
-
-LOG_FORMAT=text # "text" or "json"
-LOG_LEVEL=info # error | warn | info | debug
-```
-
-
-
-
-Admin data directories
-
-```env
-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`):
-
-```env
-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:
-
-```bash
-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`):
-
-```env
-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:
-
-```bash
-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](https://nextjs.org/) with App Router, React 19 |
-| **Language** | TypeScript |
-| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
-| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
-| **Protocol** | Custom JMAP client (RFC 8620) |
-| **Editor** | [Tiptap](https://tiptap.dev/) |
-| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
-| **Icons** | [Tabler Icons](https://tabler.io/icons) |
-| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
-
-## Why Stalwart?
-
-[Stalwart](https://github.com/stalwartlabs/mail-server) 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](CONTRIBUTING.md).
+- **Questions and help** – [Discord](https://discord.gg/tYCujymGrT), or [Troubleshooting](https://bulwarkmail.org/docs/guides/troubleshooting) first
+- **Bugs and feature requests** – [GitHub issues](https://github.com/bulwarkmail/webmail/issues)
+- **Security vulnerabilities** – privately to [dev@bulwarkmail.org](mailto:dev@bulwarkmail.org) or through a [security advisory](https://github.com/bulwarkmail/webmail/security/advisories/new), never in a public issue
+- **Release notes** – [CHANGELOG.md](CHANGELOG.md) and [GitHub releases](https://github.com/bulwarkmail/webmail/releases)
## License