Contributing
Bulwark is AGPL-licensed and developed in public on GitHub. Bug reports, feature requests, translations and patches are all welcome, and you don't need to be an expert to send one.
If your dev environment won't start, a bug is hard to pin down or a translation has you stuck, ask on the Discord server. That is also where the maintainers and other contributors talk.
What fits in Bulwark
Bulwark is a mail, calendar, contacts and files client for Stalwart. Read this section before you start on a feature. A pull request that doesn't fit is closed however good the code is, and that costs you more time than it costs us.
Stalwart first, standard JMAP welcome
Bulwark is built and tested against Stalwart. Changes that make Bulwark work with other standards-compliant JMAP servers are welcome when they cost Stalwart users nothing: no slower paths, no lost features, and no branches that only exist for one provider. Test against Stalwart as well, since that is what the maintainers run.
Core or plugin
Core is for what most people expect from a mail, calendar, contacts and files client. Everything narrower belongs in a plugin or a theme. That covers niche workflows, integrations with other services, and features that only a few deployments want. If a plugin can't do what you need because an API, slot or hook is missing, a pull request that adds that extension point is welcome.
These don't go in core:
- Other protocols. IMAP, POP3 and other non-JMAP backends. Bridges such as the legacy proxy are separate projects.
- Integrations with other services. CRMs, chat, cloud storage, AI assistants and the like. Write them as plugins.
- Stalwart server administration. Domains, queues, DNS and server settings stay in Stalwart's own admin interface. Bulwark covers what users manage for their own account and what the webmail's admin dashboard needs.
- Personalising the app's look beyond themes. Per-user fonts, colour pickers and alternative layouts. Changes to the look go through themes.
Settings
A new behaviour should have one good default. When deployments really do need different behaviour, make it an admin setting in the admin dashboard or the admin policy, not a per-user toggle. A pull request that adds a user setting so that its behaviour can be switched off will usually be asked to choose a default instead.
Both editions, every screen size
- Lite. Features that run in the browser have to work in Lite. A feature that needs the Node server has to be hidden in Lite, not left broken.
- Mobile. UI changes have to work at phone width and with touch, as well as in the light and dark theme.
The mobile app
The same scope applies to the mobile app. A feature that is out of scope for the webmail is out of scope for the app as well.
Before you write code
Bug fixes and translations can go straight to a pull request. Open an issue first, and wait until a maintainer agrees to the approach, when a change:
- is larger than about 500 lines, not counting locale files and tests,
- adds a runtime dependency to
package.json, or - touches security-sensitive code, whatever its size: sign-in, OAuth and sessions, the email HTML sanitizer, the plugin sandbox, outgoing-request guards (
url-guard), and the admin routes.
Agreement first saves you from building something we can't merge. Large features are then delivered as a series of pull requests, each of which works and can be reviewed on its own. Don't send one large pull request, and don't send a stack where every pull request depends on the next.
Development setup
git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
cp .env.dev.example .env.local
npm run dev
Then open http://localhost:3000. The .env.dev.example file turns on the built-in mock JMAP server (DEV_MOCK_JMAP=true), so you can develop without a mail server and sign in with any username and password. To work against a real server instead, copy .env.example and set JMAP_SERVER_URL. The environment reference lists every other variable.
For a tour of the code before you change it, read Architecture.
Checks and tests
npm run typecheck # TypeScript
npm run lint # ESLint
npm run lint:fix # ESLint, fixing what it can
Husky runs these on every commit, and CI runs the unit tests on every push and pull request.
| Suite | Command | What it covers |
|---|---|---|
| Unit | npx vitest run | Vitest with 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 |
| Lite smoke | npm run test:lite-smoke | Playwright against a demo Lite export |
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. New behaviour that touches mail or folder synchronization, or multi-account handling, belongs there.
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, by feature
- 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
- locales/Translation files, one directory per locale
- stores/Zustand state stores
- public/Static assets and branding
- integration/Dockerized Stalwart + Playwright suite
Code style
- TypeScript for all new code, with proper types and interfaces. Avoid
any. - Functional components with hooks. Keep components focused, and pull reusable logic into a custom hook under
hooks/. - Put
"use client"only where it is needed. - Reusable UI primitives go in
components/ui/; state lives in Zustand stores understores/. - Style with Tailwind utility classes and the existing theme CSS variables, and check both the light and dark theme. Reach for custom CSS only when Tailwind can't express it.
Translations
Translation runs through next-intl, currently across 27 languages: Arabic, Catalan, Czech, Danish, Dutch, English, Farsi, French, German, Hebrew, Hungarian, Italian, Japanese, Korean, Latvian, Mongolian, Norwegian Bokmål, Polish, Portuguese, Romanian, Russian, Simplified Chinese, Slovak, Spanish, Traditional Chinese, Turkish and Ukrainian. English (locales/en/common.json) is the source of truth.
-
Never hardcode user-facing text. Always go through a translation:
const t = useTranslations("namespace"); return <div>{t("key")}</div>; -
Add new keys to
en/common.jsonfirst. Other locales can follow in the same pull request or a later one; missing keys fall back to English. -
Use the existing namespaces:
login.*,sidebar.*,email_list.*,email_viewer.*,email_composer.*,settings.*,calendar.*,contacts.*,notifications.*,common.*. -
Keep navigation locale-aware:
router.push(`/${params.locale}/settings`).
Translation pull requests don't need an issue first. Keep each one to a single locale.
Right-to-left layouts
Arabic, Farsi and Hebrew 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. Popovers positioned in JavaScript with getBoundingClientRect() don't pick up logical utilities, so check isDocumentRTL() there.
Adding a locale
A new locale takes edits in four places:
locales/<code>/common.json: copylocales/en/common.jsonand translate it.i18n/routing.ts: add the code toSUPPORTED_LOCALES.i18n/request.ts: add acaseto the static-import switch.components/ui/language-switcher.tsx: add{ value, label }with the language's native name, plus a flag incomponents/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.
Pull requests
- Fork the repository and create a branch:
git checkout -b feature/my-feature. - Make the change, with unit tests for new logic and translations for new text.
- Run everything:
npm run typecheck && npm run lint && npx vitest run, andnpm run buildfor changes that touch the build. - Push to your fork and open a pull request.
In the pull request:
- Keep it to one change.
- Say what changed and why.
- Add screenshots for UI changes, at desktop and phone width.
- Reference the issues it relates to, including the one where the approach was agreed if it needed one.
If review feedback goes unanswered for 30 days, the pull request is closed. Reopen it when you pick the work up again.
Commit messages
Use Conventional Commits:
| Type | For |
|---|---|
feat: | New features |
fix: | Bug fixes |
docs: | Documentation |
style: | Formatting and other changes with no effect on behaviour |
refactor: | Restructuring without changing behaviour |
test: | Adding or updating tests |
chore: | Maintenance |
feat: add email threading support
fix: resolve attachment download issue
AI-assisted contributions
AI tools are part of how Bulwark is developed, and you're welcome to use them for code, tests, translations and documentation. You don't need to say that you did. You are responsible for what you submit, the same as for anything you wrote by hand: understand it, run it, and be ready to explain it in review.
Use a capable model
Use a current, capable model, the strongest one your tool offers. Don't use small, heavily quantized or older-generation models. Bulwark is a large codebase built on a protocol that most models know little about, and weaker models fill the gaps with guesses that look right:
- JMAP methods, properties and capabilities that don't exist, or that Stalwart doesn't support.
- Code that works with one account and breaks with several. Email, mailbox and account ids repeat across accounts and servers, so every write has to name the account it belongs to.
- Hardcoded English text instead of translations, and physical CSS utilities (
ml-*,left-*) that break right-to-left layouts. - Email HTML that skips the sanitizer, or remote content loaded when it should stay blocked.
Reviewing a change like that takes longer than writing it from scratch, so pull requests that show these patterns may be closed without a line-by-line review.
Before you open the pull request
- Read every line of the diff and remove anything you can't explain.
- Keep it to the change you meant to make. Drop reformatting, renames and new abstractions the tool added along the way.
- Run the checks and tests above, then try the change in a browser. If it touches mail sync or several accounts, try it against a real Stalwart server as well as the mock.
- Check that new tests fail without your change. A test that only records what the code does today doesn't catch anything.
- In translations, keep placeholders such as
{count}and plural rules intact, and match the tone of the existing strings in that language. - Write the pull request description yourself, or cut it down to what changed and why. Long generated summaries slow the review down.
- Answer review questions in your own words.
Issues and security reports
The same applies to issues and vulnerability reports. Reproduce the problem on a running instance before you send it, and include the steps. Reports that describe code or behaviour that doesn't exist in Bulwark are closed.
Documentation
The documentation lives in the website repository under docs/, one Markdown file per page. If your change alters behaviour a page describes, update that page too, or mention it in your pull request.
Reporting issues
Open an issue on GitHub and include:
- Browser and version
- Stalwart version
- Steps to reproduce
- Expected and actual behaviour
- Screenshots, if they help
Security
- Never commit secrets: API keys, passwords, tokens or
.env*files. - Sanitize user input and email content.
- Keep remote content in emails blocked by default.
- Report vulnerabilities privately to dev@bulwarkmail.org or through a GitHub security advisory, never in a public issue.
License
By contributing, you agree that your contributions are licensed under the GNU Affero General Public License v3 (AGPL-3.0-only), together with the additional permission for app store distribution in the webmail and mobile app LICENSE files.