docs: fix facts and rewrite tone

This commit is contained in:
Linus Rath
2026-07-25 17:38:55 +02:00
parent 9b69d89dfd
commit 755201c92a
5 changed files with 251 additions and 160 deletions
+3 -1
View File
@@ -274,7 +274,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# #
# Fallback UI locale used when the visitor's Accept-Language header does not # Fallback UI locale used when the visitor's Accept-Language header does not
# match any supported locale. Defaults to "en". # match any supported locale. Defaults to "en".
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh # 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".
# NEXT_PUBLIC_DEFAULT_LOCALE=tr # NEXT_PUBLIC_DEFAULT_LOCALE=tr
# Locale prefix mode for URLs. Recommended "always" when proxying under a # Locale prefix mode for URLs. Recommended "always" when proxying under a
+69 -23
View File
@@ -10,13 +10,13 @@
# Contributing to Bulwark Webmail # Contributing to Bulwark Webmail
We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale. 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.
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change. If that sounds like your kind of project, we'd love the help.
## Join the Community ## Join the Community
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this. 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 - **Get support** - real-time help with development hurdles
- **Share ideas** - feature suggestions, design feedback, doc improvements - **Share ideas** - feature suggestions, design feedback, doc improvements
@@ -46,15 +46,21 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
3. **Set up environment**: 3. **Set up environment**:
```bash ```bash
cp .env.example .env.local cp .env.dev.example .env.local
# Edit .env.local with your JMAP server URL
``` ```
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**: 4. **Start development server**:
```bash ```bash
npm run dev npm run dev
``` ```
Then open http://localhost:3000.
### Code Quality ### Code Quality
Before submitting a pull request, ensure your code passes all checks: Before submitting a pull request, ensure your code passes all checks:
@@ -72,6 +78,19 @@ npm run lint:fix
These checks run automatically on commit via Husky pre-commit hooks. These checks run automatically on commit via Husky pre-commit hooks.
### 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 |
| **E2E smoke** | `npx playwright test` | UI smoke tests against `npm run dev` |
Run a single unit test file with `npx vitest run lib/__tests__/<name>.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 ## Code Style Guidelines
### TypeScript ### TypeScript
@@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks.
## Internationalization (i18n) ## Internationalization (i18n)
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh). 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 ### Rules
@@ -126,6 +147,19 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
router.push(`/${params.locale}/settings`); router.push(`/${params.locale}/settings`);
``` ```
### Adding a new locale
Registering a new locale takes edits in four places:
1. `locales/<code>/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 ## Pull Request Process
### Before Submitting ### Before Submitting
@@ -138,13 +172,13 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
2. **Make your changes** following the code style guidelines 2. **Make your changes** following the code style guidelines
3. **Test your changes** thoroughly 3. **Test your changes** thoroughly, and add unit tests for new logic
4. **Update translations** if you added user-facing text 4. **Update translations** if you added user-facing text
5. **Run all checks**: 5. **Run all checks**:
```bash ```bash
npm run typecheck && npm run lint npm run typecheck && npm run lint && npx vitest run
``` ```
### Submitting ### Submitting
@@ -181,21 +215,33 @@ docs: update README with keyboard shortcuts
``` ```
webmail/ webmail/
├── app/ # Next.js App Router pages ├── app/ # Next.js App Router
── [locale]/ # Locale-aware routing ── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings)
├── components/ # React components │ ├── (main)/admin/ # Admin dashboard
│ ├── email/ # Email-related components │ ├── (main)/setup/ # First-launch setup wizard
│ ├── layout/ # Layout components │ ├── (sandbox)/ # Isolated plugin sandbox routes
── settings/ # Settings components ── api/ # Route handlers (auth, admin, jmap, caldav, …)
│ └── ui/ # Reusable UI components ├── components/ # React components
├── contexts/ # React contexts │ ├── email/ # Email list, viewer, composer
├── hooks/ # Custom React hooks │ ├── calendar/ contacts/ files/ filters/ templates/
├── lib/ # Utilities and libraries ├── layout/ # Sidebar, shell, navigation
── jmap/ # JMAP client implementation ── settings/ # Settings panels
├── locales/ # Translation files │ ├── plugins/ # Plugin host UI
── en/ # English translations ── ui/ # Reusable primitives
│ └── fr/ # French translations ├── contexts/ # React contexts
── stores/ # Zustand state stores ── 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
├── e2e/ # Playwright smoke tests (against `npm run dev`)
└── integration/ # Dockerized Stalwart + Playwright suite
``` ```
## Security ## Security
+105 -103
View File
@@ -2,145 +2,147 @@
## Mail ## Mail
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables) - 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 with inline expansion and an optional conversation toggle - Gmail-style threading, expanded inline, with a conversation toggle you can switch off
- Unified Mailbox combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account - 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.
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message - 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.
- Search inside the Unified Mailbox text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes - Search runs across all unified views; the per-role mailboxes add the full filter panel on top
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom - Three mail layouts: split three-pane, focused list, or reading pane at the bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies - Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning - 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 and configurable send delay - Scheduled send, plus a configurable delay before anything leaves the outbox
- Read receipts (MDN, RFC 8098) - Read receipts (MDN, RFC 8098)
- Editable, layout-preserving quote island when replying - Quoted text lands in an editable island that keeps the original layout
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries - Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag - Multi-select for batch archive, delete, move, and tag
- Archive modes direct, by year, or by month - Archive directly, by year, or by month
- Multi-tag support with color labels, reordering, and drag-and-drop assignment - Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
- Star/unstar with configurable mark-as-read delay - Star or unstar, with a configurable mark-as-read delay
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login - Large mailboxes scroll virtually, and the first page of mail prefetches at login
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers - Quick reply, hover actions, favicon-based sender avatars, recipient popovers
- Plain-text composer mode and Reply-To support - Plain-text composer mode and Reply-To
- Configurable signature position (above or below quoted text) per identity - The signature sits above or below the quoted text, per identity
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured 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.
- `.eml` file import via folder right-click menu - Import `.eml` files from the folder right-click menu
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping - TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
- Folder management with icon picker, subfolders, and sidebar counts - Folders take an icon, nest, and show counts in the sidebar
- Print directly from the viewer - Print from the viewer
- Browser history sync for back/forward navigation - Browser back and forward move through mail history
## Calendar ## Calendar
- Month, week, day, and agenda views with a mini-calendar sidebar and task list - Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap - Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
- Recurring events with scoped edit/delete (this / this and following / all) - Recurring events edit and delete by scope: this occurrence, this and following, or all
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment - iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
- Inline calendar invitations in the email viewer auto-detect `.ics`, RSVP, import - `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
- iCalendar import with preview, bulk create, and UID deduplication - iCalendar import previews first, then bulk-creates, deduplicating on UID
- iCal / webcal subscriptions with editing and batch import - iCal / webcal subscriptions, editable, with batch import
- Auto-generated birthday calendar from contacts - A birthday calendar generated from your contacts
- Virtual locations (video conference URLs) as first-class event fields - Virtual locations (video-conference URLs) are first-class event fields
- Task management with due dates, priority, and completion status - Tasks with due dates, priority, and completion status
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors - Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
- Week numbers, event hover preview, notifications with sound picker - Week numbers, hover preview, notifications with a sound picker
- Real-time sync via JMAP push - JMAP push keeps everything in sync
## Contacts ## Contacts
- JMAP sync (RFC 9553 / 9610) with local fallback - JMAP sync (RFC 9553 / 9610), falling back to local storage
- Multiple address books with drag-and-drop between books - Several address books, with drag-and-drop between them
- Contact groups with member management - Groups with member management
- vCard import/export (RFC 6350) with duplicate detection - vCard import/export (RFC 6350) that flags duplicates
- Trusted senders stored in a dedicated JMAP address book - Trusted senders live in their own JMAP address book
- Autocomplete in the composer (To / Cc / Bcc) - Autocomplete on To, Cc, and Bcc
## Filters & Templates ## Filters & Templates
- Server-side filters via JMAP Sieve Scripts (RFC 9661) - Server-side filters as JMAP Sieve Scripts (RFC 9661)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…) - 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
- Preserves rules authored in other clients - Rules written in other clients survive the round-trip
- Raw Sieve editor with syntax validation - Raw Sieve editor with syntax validation
- Vacation responder with date range scheduling - A vacation responder you can schedule to a date range
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …) - Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
## Files ## Files
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load - 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 and folder upload with progress tracking - Streamed WebDAV PUT upload, whole folders included, with progress
- Dynamic upload limits based on server configuration - Upload limits follow the server's own configuration
- Grid and list views with sorting by name, size, or date - Grid or list, sorted by name, size, or date
- Previews for images, text, audio, and video - Preview images, text, audio, and video
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files - Cut, copy, paste, duplicate; favorites; recent files
- JMAP sharing (RFC 9670) for files and folders share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you - 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 ## Security & Privacy
- External content blocked by default, with a trusted senders list - External content stays blocked until you say otherwise, and trusted senders are remembered
- HTML sanitization via DOMPurify - HTML sanitized through DOMPurify
- S/MIME manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation - S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
- SPF / DKIM / DMARC status indicators surfaces the most severe SPF result and hides the "via" badge on spoofed mail - SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments - 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 - TOTP two-factor authentication
- Account security panel for password and 2FA management via the Stalwart admin API - Password and 2FA management through the Stalwart admin API
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie - "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention - CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
- Plugin hardening with dangerous-pattern detection and admin approval - Plugins are scanned for dangerous patterns and need admin approval
- Newsletter unsubscribe (RFC 2369) - Newsletter unsubscribe (RFC 2369)
## Interface ## Interface
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns - Split three-pane, focused list, or bottom reading pane, columns resizable
- Dark and light themes with intelligent email color transformation - 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 color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips - 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.
- Responsive desktop, tablet, and mobile layouts - Layouts for desktop, tablet, and mobile
- Full keyboard navigation - Full keyboard navigation
- Drag-and-drop email organization and tag assignment - Drag and drop to organize mail and assign tags
- Interactive guided tour for new users - A guided tour for first-time users
- Right-click context menus, toast notifications with undo - Right-click menus, and toasts that offer an undo
- Customizable toolbar position, favicon, and login branding - Toolbar position, favicon, and login branding are configurable
- Pinnable sidebar apps with drag-and-drop reordering - Sidebar apps pin and reorder by drag
- Encrypted settings sync across devices - Settings sync between devices, encrypted
- Storage quota display - Storage quota display
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions - WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions
## Internationalization ## Internationalization
19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文 24 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Slovenčina · Türkçe · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. - 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 ## Identity & Multi-Account
- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1) - 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.
- Account switcher with connection status and default account selection - An account switcher showing connection status, and a default account
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list - Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
- Configurable signature position (above or below quoted text) - Signature above or below the quoted text
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions - Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
- Shared folders across accounts - Shared folders across accounts
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("Include group inboxes"); their messages are fully actionable there open, mark read, spam / not-spam, move, delete, and archive with folder unread counts kept in sync - 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.
- Multiple JMAP servers per deployment with optional auto-pick by email domain - Several JMAP servers per deployment, optionally auto-picked by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) - Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it
## Admin & Extensibility ## Admin & Extensibility
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required - 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.
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page - The Stalwart admin dashboard, its policy sections collapsed into one tabbed page
- Admin policy gates for the Unified Mailbox enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); each gated view still respects the user's own toggle - 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.
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps) - 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.
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts - JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`) - An admin toggle controls search-engine indexing (`robots.txt` / `noindex`)
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement - Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event 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 hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins` - Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles - Themes upload as ZIP bundles, and admins can enforce one
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard - 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 calendar integration - Bundled plugins, including Jitsi Meet for the calendar
## Operations ## Operations
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots - Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
- Automatic update check with server-side logging of new releases and a non-dismissible update notice - 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 category-based levels - Structured logging (`text` or `json`) with per-category levels
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) version, platform, bucketed account counts, feature toggles only - 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.
- Release (`main`) and development (`dev`) Docker images on GHCR - Docker images on GHCR, for release (`main`) and development (`dev`)
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy - `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
- Demo mode with fixture data no mail server required - Demo mode runs on fixture data, no mail server required
+72 -30
View File
@@ -8,7 +8,7 @@
# Bulwark Webmail # Bulwark Webmail
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol. A self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
@@ -20,12 +20,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
## Installer ## Installer
New in **1.6.4**: a web-based setup wizard runs on first launch no `.env.local` editing, no shelling into the container. Since **1.6.4**, a web-based setup wizard runs on first launch no `.env.local` editing, no shelling into the container.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
</picture>
Point a browser at the running container and the wizard guides you through: Point a browser at the running container and the wizard guides you through:
@@ -70,21 +65,21 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
<td><img src="screenshots/settings.png" alt="Settings" /></td> <td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr> </tr>
<tr> <tr>
<td><sub><b>Light mode</b> full theme support with intelligent color transformation for HTML emails.</sub></td> <td><sub><b>Light mode</b> full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable.</sub></td>
<td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td> <td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td>
</tr> </tr>
</table> </table>
## Overview ## Overview
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login: 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 - **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 - **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export - **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload - **Files** Stalwart's JMAP FileNode storage with previews and folder upload
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard. 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](FEATURES.md)**. Full feature list: **[FEATURES.md](FEATURES.md)**.
@@ -104,7 +99,7 @@ Or with Docker Compose:
docker compose up -d docker compose up -d
``` ```
On first launch, open `http://localhost:3000` the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration). 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).
### From Source ### From Source
@@ -119,16 +114,20 @@ npm run build && npm start
### Development ### Development
```bash ```bash
npm run dev # Dev server with a mock JMAP server 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 typecheck
npm run lint npm run lint
npx vitest run # Unit tests
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)
``` ```
## Configuration ## Configuration
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI. 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.
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`: 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`:
```env ```env
# Optional overrides whatever the wizard writes # Optional overrides whatever the wizard writes
@@ -151,13 +150,28 @@ PORT=3000
```env ```env
OAUTH_ENABLED=true OAUTH_ENABLED=true
OAUTH_ONLY=true # hide the username/password form entirely
OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs 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`. 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.
</details>
<details>
<summary>Anonymous telemetry</summary>
```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.
</details> </details>
@@ -255,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp
</details> </details>
<details>
<summary>Default UI locale</summary>
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 .
```
</details>
<details> <details>
<summary>Subpath / reverse proxy mount</summary> <summary>Subpath / reverse proxy mount</summary>
@@ -271,37 +304,46 @@ Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** b
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail . 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 to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly. 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.
</details> </details>
## Keyboard Shortcuts ## Keyboard Shortcuts
| Key | Action | | Key | Action |
| ------------- | ----------------------- | | -------------------- | ----------------------- |
| `j` / `k` | Navigate between emails | | `j` `↓` / `k` `↑` | Navigate between emails |
| `Enter` / `o` | Open email | | `Enter` / `o` | Open email |
| `Esc` | Close / deselect | | `Esc` | Close / deselect |
| `c` | Compose | | `x` | Expand / collapse thread |
| `r` / `R` | Reply / Reply all | | `c` | Compose |
| `f` | Forward | | `r` / `R` `a` | Reply / Reply all |
| `s` | Star | | `f` | Forward |
| `e` | Archive | | `s` | Star |
| `#` | Delete | | `e` | Archive |
| `/` | Search | | `#` / `Del` | Delete |
| `?` | Show all shortcuts | | `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 ## Tech Stack
| | | | | |
| ------------- | ------------------------------------------------- | | ------------- | ------------------------------------------------- |
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router | | **Framework** | [Next.js 16](https://nextjs.org/) with App Router, React 19 |
| **Language** | TypeScript | | **Language** | TypeScript |
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) | | **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) | | **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
| **Protocol** | Custom JMAP client (RFC 8620) | | **Protocol** | Custom JMAP client (RFC 8620) |
| **Editor** | [Tiptap](https://tiptap.dev/) |
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) | | **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
| **Icons** | [Lucide React](https://lucide.dev/) | | **Icons** | [Lucide React](https://lucide.dev/) |
| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
## Why Stalwart? ## Why Stalwart?
+2 -3
View File
@@ -303,9 +303,8 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "x", description: "shortcuts.threads.expand_collapse" }, { key: "x", description: "shortcuts.threads.expand_collapse" },
], ],
composer: [ composer: [
{ key: "Ctrl + Enter", description: "shortcuts.composer.send" },
{ key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" }, { key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
{ key: "Ctrl/Cmd + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
], ],
} as const; } as const;