A new feature gate, tagsAboveSharedEnabled (default off, so the sidebar
keeps its order), lists the Tags section above the shared mailboxes
instead of below them. With many shared mailboxes expanded, the tags
otherwise end up out of sight. In Mountain View the tags then follow the
Labels list directly, also when More is open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The old panel kept its active tab in a store, so every page sat at
/admin, the sidebar duplicated the webmail rail by hand, and each page
was a stack of boxed cards with their own save buttons. Plugin settings
were only reachable through a gear icon in a row of five.
- one catch-all route for the whole panel; pages switch through
history.pushState, so `/admin/plugins/<id>`, the back button, bookmarks
and connector links all work without a route transition
- sidebar laid out like the user settings: back to mail, search over
pages and their sections, two group headings, change password and
sign out at the bottom; the phone shows a list, then the page
- pages use SettingsSection rows instead of nested cards, with one page
header, one flash message and one save bar at the bottom (Discard,
Save, Ctrl+S); the unsaved-changes prompt still guards leaving
- plugins and themes are lists of rows with a toggle each; a row opens
the extension's own page with its settings, force-on, permissions,
kept versions and removal
- overview shows stat tiles, server facts and recent activity
- old `/admin?tab=x` links redirect; the tab store is gone and the
connector registry resolves admin links to the page URL
- `NEXT_TURBOPACK_ROOT` overrides the Turbopack root so `next dev` runs
from a worktree with a junctioned node_modules
The marketplace now talks to the extension directory's v2 API only. The
detail page lists every version, previews the one picked, installs it,
and refuses a version the publisher withdrew unless the admin confirms,
or one outside the Bulwark range it supports. The listing gains sort, an
Installed view and an updates strip fed by the directory's updates check,
with notices for advisories, deprecations and suspensions.
A self-hosted directory older than 2026-10-14 has no v2 API and no
longer works with the marketplace.
A new feature gate, archiveActionEnabled (default on), lets the admin turn the
Archive action off for installations that archive mail elsewhere, such as a
mail gateway. With it off, Bulwark offers no Archive anywhere: the viewer's
button and its overflow entry, the context menu for one or several messages,
Mountain View's selection bar, the hover and swipe actions, the E shortcut and
its help row, archiving after an export, and the archive mode setting (also in
the settings search). With it off nothing creates an Archive folder:
ensureArchiveMailbox returns null. Existing Archive folders stay ordinary
folders, moving mail into them by hand still works, and the plugin API is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part 2 of 3 of the account archive agreed in #1198. When the admin turns on
"Account List Backup: Passwords" (off by default), saving the list offers to
include the passwords of the accounts signed in here with a plain password.
They are encrypted in the browser together with the list. On restore, those
accounts are signed in to in the background, one by one, without leaving the
active account; a refused password leaves the account waiting for a sign-in by
hand, as before. OAuth and TOTP accounts are saved without a password.
Plugins that talk to Nextcloud, Paperless or an AI provider used to need
the password or key in their own settings, readable by the plugin and
stored in the browser. A plugin can now declare credentials in its
manifest and send requests "with credential X" through the server:
- `credentials` in the manifest: admin scope (secret and server from the
admin config) or user scope (the user connects an account; the server
address is fixed or chosen by the user)
- `/api/plugin-secrets`: the user's connected accounts, encrypted per
mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets
- `/api/plugin-proxy`: adds the secret and forwards only to the
credential's own origin, through the SSRF guard unless the admin set
the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no
redirects or WebDAV moves to other servers, streamed bodies capped at
512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once
per user, and one audit line per request without paths or bodies
- a capture mode that stores a secret from a sign-in response (Nextcloud
Login Flow v2) and removes it from what the plugin gets back
The admin page offered "User Plugin Uploads" and "Require Admin
Approval", but nothing in Settings used them: there was no upload, no
uninstall and no way to ask for approval, and the admin had no list of
requests. The approval code that existed pinned a hash of the code only
and showed the admin a manifest the browser sent, never the code.
Now:
- Settings > Plugins has an install button (when uploads are allowed),
a remove button for the user's own plugins, and lists in plain
language what each plugin may do, high-risk items first
- an install sends the ZIP to the server, which reads the manifest and
code itself and pins the request to a hash of both
- the admin Plugins tab lists requests with the code and approves or
denies them; a user may have 10 open requests
- a user plugin is checked again on every start, so a revoked approval
or turning user plugins off takes effect
- the consent dialog and the plugin settings are translated, use the
theme colours, and show the sites a plugin may send data to
The Settings upload, the admin upload, the marketplace install and the
marketplace preview each parsed the ZIP and manifest with their own copy
of the code, and the copies had drifted. A `permissions` string skipped
validation and was then matched by substring, a small ZIP could inflate
to hundreds of megabytes on the server, the Settings upload dropped
`locales` and took origin lists unchecked, and settings schemas were
never checked at all.
All of them now go through `lib/extension-package.ts`:
- every file is inflated through a byte cap, with a limit on entries
- one manifest check: types, permissions, entrypoint, version,
minAppVersion, settingsSchema, configSchema, locales and origin lists
- only checked fields are stored
Users with several accounts can save the list of accounts in their browser
(address, server, label, colour) on the webmail server and bring it back on
another device. The list is encrypted in the browser with a password the user
chooses (PBKDF2-SHA-256, 600,000 iterations, AES-256-GCM, owner bound as
additional data); the server stores only the envelope, one file per owner
under SETTINGS_DATA_DIR/account-archives. No mailbox passwords are included.
Off by default behind the admin feature gate accountArchiveEnabled, pinned
off in Lite. Every method of /api/account-archive needs a session cookie of
the owner, matched without regard to case, whose credential is checked
against the mail server on each call; listing included.
Restored accounts this browser lacks are listed as awaiting sign-in: they
survive a reload, and opening one leads to the sign-in form with the address
filled in. Accounts already present get their archived label and colour.
Opening a message puts its subject in the browser-tab title. The title does
not stay in the tab strip: browsers keep it in history and in the
session-restore list, and screen shares show it. For mailboxes that hold
other people's correspondence that is a leak nobody audits.
A new feature gate, tabTitleSubjectEnabled ("Subject in Tab Title" under
Admin -> Policy), is on by default, so nothing changes for existing
installs. With it off, the title names the folder instead, so the tab still
says where you are. It is an admin gate rather than a user setting: whose
mail the boxes hold is a deployment decision.
The gate counts as off until the policy has loaded, so a subject never
slips into history while the setting is still unknown.
Refs #1159
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server entry's OAuth client ID already has a meaning while OAuth is
off globally: it is the registered client that password and TOTP logins
use with Stalwart's require_client_registration. The PR made every such
entry offer OAuth on its own, so existing installs would suddenly run
discovery and show SSO buttons on the login page, and multi-IdP setups
would get one extra button per server.
A server now gets its own button only with both a client ID and a button
label, which no existing config has. The rule lives in `offersOwnOAuth`
with a test. Also:
- the OAuth-only login form uses the selected server's label
- the public config validator checks `buttonLabel` is a string
- the admin hint and `.env.example` say the label enables the button
A server entry that names its own OAuth client now offers the OAuth
button even while OAuth is off globally, so a Gmail bridge can sign in
with Google next to a server that uses passwords. The entry can also
name the button ("Sign in with Google"); the generic SSO label stays
the default.
Several endpoints answered anyone with what fingerprints a deployment:
`/api/health?detailed=true` (versions, memory), the update status
(exact version and the text of a pending security advisory, even with
`loginShowVersion` off), `/api/plugins` (installed plugins and the hosts
they may reach), the admin policy (push relays, sidebar app URLs,
plugin lists) and `/api/config` (every domain each server serves).
Visitors without a session now get:
- the plain liveness answer from `/api/health`;
- the update status only where the login page shows the version, and
never the advisory text;
- a 401 from `/api/plugins`, which the client only syncs after signing
in;
- the policy without plugin lists, push relays and sidebar apps, marked
`X-Bulwark-Policy-Scope: public`; the client fetches the full policy
once it signs in;
- server domain lists only when servers are picked by domain.
A mailbox session, a remembered session cookie or the admin session
counts as signed in. Deployments without a session secret cannot tell
sessions apart and keep the old answers.
The admin login limit keys on the client IP taken from
X-Forwarded-For. With no reverse proxy in front, that header is
whatever the client sends (Next only fills it from the socket when the
request carries none), so each forged address got a fresh bucket of
five guesses. Route handlers cannot see the socket address, so the
limiter now also stops at 50 attempts per 15 minutes across all
clients. `.env.example` says why a proxy that appends the header is
still needed for trustworthy per-IP limits and audit addresses.