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.
The agenda sidecar queried only the session's primary calendar account,
so events in a shared or group account (a family or team calendar) never
reached the Calendar Agenda plugin, although the calendar app shows them.
Read every account the calendar app reads: the primary account plus
accounts that advertise the calendar capability or are not personal,
mirroring the client's getCalendarCapableAccountIds(). Event and
calendar ids repeat across accounts, so the DTO id is scoped as
<accountId>:<id> and carries accountId; calendar colours are looked up
per account. A shared account whose calendars cannot be read is skipped
instead of failing the whole agenda; a failure of the primary account is
still reported.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Any failed method in the preview request answered 502, so when the
server named the delivered message and it was read fine, a failing
Inbox query still turned the notification into a generic "New mail".
The named message is now a complete preview on its own.
A response without the optional `total` was also treated as a failure;
it now counts the returned ids.
A JMAP method error comes back with HTTP 200. The preview route read a
failed Mailbox/query or Email/query as an empty Inbox and answered
"nothing unread", so the service worker dropped the push in silence.
It now answers 502, and the worker shows its generic notification.
When the server names the message that arrived and it cannot be read -
deleted since, or filed where this login cannot see it - the route no
longer substitutes the newest unread message in the Inbox, which was
some other mail announced as if it had just arrived. It names no
message; the unread total still comes back for the generic toast.
Some servers build a message's preview from its HTML body without skipping
the <style> element, so a marketing mail's preview - in the list and in its
push notification - can read "@media screen and (min-width:600px){.hide{
display:none!important;…" and never reach the text.
Drop whole leading rules from the preview before showing it; a preview that
ends inside the style sheet was nothing but CSS and becomes empty, so the
usual "no preview" text shows instead. A rule only counts as CSS when its
block holds declarations or further rules, so prose with a brace in it
("the meeting is at {time} tomorrow") is left alone.
Three files carried a literal NUL byte inside a string: the admin
check's cache key in `app/api/admin/auth/route.ts`, the global search
hit key in `lib/global-search/types.ts` and two TNEF fixtures in
`lib/__tests__/tnef.test.ts`. Git treats any file containing a NUL as
binary, so it showed no diffs for them, never normalised their line
endings, and reported every pull request that touched the admin route
as a conflict even when a text merge was clean.
The escape produces the same string at runtime. The admin route was
stored with CRLF while git saw it as binary; it is now stored with LF
like the rest of the tree, so its diff covers the whole file (the only
content change is on line 70).
`/api/auth/sso/complete` only accepted cookie slots 0-4 and quietly
used slot 0 for anything higher. Since the account limit rose to
`MAX_ACCOUNT_SLOTS` (50) on HTTP/2, adding a sixth account through
server-side SSO wrote its refresh token over the first account's cookie,
while the new account was registered on slot 5 with no cookie behind it.
The route and the OAuth callback's pending-slot check now use the same
bound as every other auth route.
Retry interrupted or invalid runtime configuration requests with bounded backoff and per-attempt timeouts. Validate the complete response before caching it, and prevent stale HTTP-cached configuration.
Cover request and body timeouts, shared hook consumers, retry bounds, the server response contract, and both Lite configuration paths.
The app refuses a JMAP server over plain http (loopback aside), yet the
desktop issued a code, reported "Signed in on your phone" once it was
redeemed, and the phone failed. Linking now stops before the step-up
with an explanation.
The token proxy trusted the endpoint sealed into a phone's refresh token
for good: after a server was removed or its provider moved, it kept
posting there with the plain fetch and whichever secret the server id
now maps to. Trust is now re-derived on every renewal; an endpoint the
webmail is no longer configured for gets neither the secret nor the
unguarded fetch.
The step-up for SSO accounts asks the provider for a new login with
`prompt=login`, but a provider that ignores it answers from its existing
session, so anyone at an unlocked desktop could link a phone. When the
ID token's `auth_time` is older than the step-up, the re-auth is now
refused.
The step-up's failure count was keyed on the cookie slot, which the
client chooses, so a session could spread guesses over 50 slots; and
even one key allowed 480 failures a day, well above Stalwart's ban of
100 a day per address, which would ban the webmail's own IP for every
user. Failures now count per server and login name, 5 per 15 minutes
and 20 a day.
The mobile handoff returned the provider's token endpoint and the
webmail's client id as they were. With a confidential client the phone
could not renew (it has no secret), and a provider on another host was
refused by the app outright. The handoff now returns the same bundle a
paired phone gets: such grants renew through `/api/auth/pair/token` with
a sealed refresh token.
"Link Mobile App" only appeared for OAuth sessions, its step-up could
only go through the identity provider (which failed without an OAuth
client and bounced password+TOTP users to the provider's login page),
and it handed the phone the desktop's own refresh token. Revoking that
token on sign-out, or a rotating provider, signed the phone out too; a
confidential client stopped the phone at its first token expiry; and an
identity provider on another host was refused by the app.
The step-up now mints the phone's own grant:
- Password and password+TOTP accounts confirm the current password (and
code) in the dialog. The server uses it once to get an OAuth grant
from Stalwart's structured login for the app's own client, retries
with the webmail's client when the server insists on a registered one,
and falls back to an app password ("Bulwark mobile (paired <date>)")
or, on servers that offer neither, the verified password. Failed
attempts are limited per account so the webmail's address cannot be
banned by guessing through it.
- SSO accounts keep the provider round trip, now with `offline_access`
where offered; its fresh tokens become the phone's grant instead of
being thrown away, and must belong to the account being paired.
- The account and server always come from the slot's verified session
context, never from the request.
Grants live 5 minutes server-side, codes 2 minutes, both single use; the
store is pinned on `globalThis`. Redeem answers `invalid_code`,
`expired_code` or `used_code`, and the desktop polls a separate status
id to replace the QR with "Signed in on your phone". A phone whose
client is confidential, or whose provider is on another host, renews
through `/api/auth/pair/token` with a sealed refresh token, so current
app builds work unchanged. Signing out discards a pending grant but no
longer affects a paired phone.
The dialog shows for every account (not in Lite), with a copyable
sign-in link, a TOTP field when needed, and the app's own "Scan a
sign-in code" wording.
The step-up for linking a device sends the user to the identity
provider with `prompt=login`. The login route accepted that pending
state too, so a code from the re-auth could be spent there and sign this
browser in as whichever account answered the prompt. It now refuses a
re-auth state; only the pairing route completes one.
The callback page also handles its code once. React's development
double effect ran it twice: the second pass no longer saw the re-auth
flag and raced the pairing route for the single-use code.
The password+TOTP login drove Stalwart's structured login (`/api/auth`,
then `/auth/token` with PKCE) inline. Linking the mobile app needs the
same exchange to mint a phone's own grant, so it now lives in
`lib/auth/stalwart-password-login.ts` and the route only maps its
result to the responses it always gave.
The mock JMAP server behind `/api/dev-jmap` accepts any password and
shipped in every image, held back only by `DEV_MOCK_JMAP=true` at
runtime. It now also needs the flag at build time (inlined through
`next.config.ts`), so a build made without it cannot switch the mock on,
and the Docker image drops the route before building.
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.
A WOPI token carries the user's credentials to the editor server and
stayed valid for its whole six-hour lifetime, so an editor opened
before signing out could still read and save the document afterwards.
Each token now records the browser (a random id in an HttpOnly cookie,
hashed) and account slot that opened it. Signing out records a
revocation time for that browser and slot, or for every slot on a full
sign-out, and WOPI calls with a token minted before it are refused.
Signing out on another device leaves this browser's editors alone.
Revocations live in the state directory, so they survive a restart and
reach replicas sharing the state volume, and are dropped once the
tokens they cover have expired.
`/api/auth/verify` checked any username and password against the JMAP
server without a limit. Every probe reaches the server from the
webmail's own address, so the route was a password oracle and enough
wrong passwords could trip Stalwart's authentication ban against the
webmail itself, locking out every user.
Wrong passwords now use up a budget of five per client and thirty
across all clients per fifteen minutes; right ones are refunded. Over
budget the route answers `inconclusive` and the browser probes the JMAP
server itself, as it did before the pre-check existed.
Used impersonation token ids were only remembered in process memory, so
a captured handoff link could be redeemed a second time within its five
minute lifetime once the process restarted, or on another replica. Each
used id now also leaves a marker file in the state directory, created
exclusively, which every replica sharing the state volume sees; markers
are pruned once the token could no longer verify.
The absolute session limit comes from the eight-hour app password that
Stalwart itself expires.
`/api/favicon` is unauthenticated and answers on the webmail origin,
and it passed the upstream Content-Type through unchecked. Should the
favicon service ever return `image/svg+xml` or `text/html`, opening
that URL on its own would run script as the webmail. Only raster image
types are served now, with `nosniff` and a sandboxing CSP; anything
else falls back to the transparent placeholder.
Clicking a new-mail notification opened `/mail/message/<id>` in
whichever account was active. With several accounts signed in, that
looked the id up in the wrong account: nothing opened, or a different
message did and was marked read.
The preview API now returns the cookie slot of the login that owns the
notified account, the service worker adds it to the permalink (or links
that account's Inbox for a group notification), and the app switches to
the login on that slot before opening the message.
The code exchange, token refresh and revocation POSTs carry the
authorization code, refresh token and client secret, and used a plain
`fetch` that follows redirects. The endpoints come from a validated
discovery document, but a redirect from them (or from an endpoint that
changed after discovery) re-sent the body to a host nobody validated.
These requests now fail on any redirect. A failed refresh still keeps
the refresh token, as for any other outage.
A failed TOTP login returned the first 500 bytes of the upstream error
body. With custom JMAP endpoints allowed and no server configured, the
upstream is any address the caller names, so the route read other
servers' responses on the caller's behalf. The detail is now only
passed on from an admin-configured server.
A Bearer login may claim a bare local part, which is accepted when the
server's `Session.username` has the same local part (TOTP users who
type `john` log in this way). Settings were keyed on that claim, so on a
multi-domain server `john@b.example` could claim `john` and read and
overwrite the synced settings of the `john@a.example` who signs in as
`john` with a password.
`resolveJmapIdentity` now returns the canonical account name for a bare
Bearer claim, the context cookie carries it as `accountName`, and the
settings route keys on it. Basic logins and fully-qualified claims keep
their existing key.
The theme sanitizer was a short denylist. It missed protocol-relative
`url(//host)`, schemes and keywords spelled with CSS escapes
(`url(\68ttps:...)`, `@\69mport`), `image-set("https://...")` and
`@font-face` sources, so a theme could load remote resources from the
app document. `validateThemeSelectors` was never called, so theme CSS
could also target `input[type=password]` or restyle real UI. The admin
routes and the zip validator only sanitized when the safety check
failed, and CSS returned by the `onThemeBeforeApply` plugin hook was
injected as-is.
- every `url()` except a `#fragment` and every `image-set()` is removed
with its arguments; a banned construct spelled with escapes drops the
whole stylesheet
- theme CSS keeps only `:root` and `.dark` rules (no nested rules),
plus `@font-face`, `@keyframes` and `@media`/`@supports` wrappers
- sanitizing is unconditional everywhere, including plugin-transformed
CSS; compiled token values may not contain escapes or `url()`
The Stalwart admin check only bailed while `allowCustomJmapEndpoint` was
on. A context cookie minted while it was on keeps naming the
user-chosen server, and the envelope has no expiry, so after the admin
turned the switch off that server could still answer the admin probe
and mint a Bulwark admin session. The check now also requires the
cookie's server to be an admin-configured one.
The Stalwart admin check probed `x:Account/query`. Stalwart's default
tenant-admin role carries `sysAccount*`, so an administrator of a single
tenant passed and, in the default `auto` mode, was signed into the
Bulwark dashboard, which configures the whole instance for every
tenant. The `auto|password|off` switch added for #870 left the default
at `auto`, so the advisory never held on a default install.
The probe is now `x:Tenant/query`, which needs `sysTenantQuery`: by
default only the server-wide superuser holds it.
GetFile answered on the webmail origin with the Content-Type stored in
the WOPI token, which for a mail attachment comes straight from the
launch request and for a Files node is a user-settable property. No
CSP, nosniff or disposition was added, since `/api/*` skips the proxy.
A user could mint a token for their own blob typed `text/html` and have
a victim open the GetFile URL, running script in the webmail origin.
GetFile now always sends `application/octet-stream` with
`Content-Disposition: attachment`, `nosniff` and a sandboxing CSP, and
refuses browser loads (any `Sec-Fetch-Dest` other than `empty`). The
editor fetches it server-to-server, so it is unaffected.
The origin gate in the proxy only covered `/api/auth/*`. The JMAP
passthrough, `/api/settings`, `/api/wopi/launch`,
`/api/plugin-approval-status`, `/api/fetch-ical`, `/api/translate`,
`/api/caldav/discover`, `/api/calendar-agenda` and `/api/webdav` all act
with the session cookie and had no origin check. A page on a same-site
sibling origin receives SameSite=Lax cookies, so it could run arbitrary
JMAP as the signed-in user, including sharing mailboxes to a third
account.
The proxy now gates every `/api/` route except the WOPI file endpoints
the office editor calls server-to-server, and each of these handlers
checks again. The proxy also decides on the decoded path, so an escaped
spelling such as `/api/%61uth/session` no longer skips the gate.
An impersonation handoff sealed `<target>%<master>` and the master
password into the session cookie, and `PUT /api/auth/session` hands that
cookie's contents to the browser on every restore. Anyone who received
a handoff could read a credential that signs in as every mailbox and
into Stalwart administration, with no expiry and no audit trail.
The impersonate route now uses the master credential server-side, once,
to create an app password on the target mailbox that expires after
8 hours. Only that app password reaches the cookies. Signing out
revokes it through a sealed grant cookie, and PUT refuses any session
that still holds the configured master password, so cookies minted
before this change are dropped instead of returned.
Fixes from the review of the Files work (1cf9944b..a9a25b94).
- A file shared on its own, or one below a folder its owner took back,
could not be reached. Once Stalwart 1.0's placeholder folders were
left out it became a root of "Shared with me", which listed folders
only, and search opened it on the drive root. 0.16 lists such files
as roots too. Each owner's files shared on their own are now listed
on one page under "Shared with me" ("Files from <name>"), where they
open, preview and download like any other file and nothing can be
added; two of the same name get a number. Search opens a shared hit
where it is listed, and links into a shared drive name the node by id
(`/files?shared=<owner>:<id>`), so a shared folder or page also
survives a reload. A Files page that is already open takes the link
from search directly, since Next keeps it mounted when only the query
changes.
- A copy that fails part-way destroys only the nodes it made, the
deepest first and without `onDestroyRemoveChildren`, one call per
level because 0.16 refuses a folder destroyed in the same call as
its children. It used to destroy the new folder with everything
below it, so whatever someone put there during a long copy was
deleted for good. A folder holding such nodes now stays, and the
error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
without Modify, so every save of such a document failed with a 500.
1.0, told apart by its `maxFileNodeDepth`, still asks for
`mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
the 1.0 pre-releases a failed update also drops the cached copy, and
the next restart or reload of the Applications downloads it again,
not only a later `UpdateApps`. The static README says a restart
applies the CORS setting on the current 1.0 pre-releases, where
`ReloadSettings` fails with the stock spam-filter rules.
Four smaller calendar fixes found in review of the Stalwart 1.0 work.
- A range load that failed left `dateRange` on the range loaded before,
so a push or a pull-to-refresh reloaded that one instead of the range
on screen, and any action that reset `error` hid the "may be out of
date" notice. The store now takes the range asked for and keeps the
failure in `eventsLoadError`, which only a successful range load
clears.
- The agenda plugin's expanded query had no sort, so its limit could
keep later occurrences instead of the next ones. It now sorts by
start, looks less far ahead when the server refuses the range as too
long (`expandDurationTooLarge`, an administrator's shorter
`maxExpandedQueryDuration`) instead of answering 502, and sends its
gets several per request.
- With a limit (search, contact activity) the chunks of a long range
went out one request each. Up to four now share a request, and what
the later ones return past the limit is dropped. Global search passes
its AbortSignal down, so a replaced search stops between requests.
- A search of several words sends its date bounds as conditions of a
flat AND. The client did not see them as a range: it neither expanded
recurrences nor turned the UTC bounds into the request's wall clock,
so such a search found series instead of occurrences, over a range
shifted by the zone's offset. The bounds are now lifted out of the
AND, converted and chunked, and each chunk keeps one flat AND of
single conditions - the shape 1.0 expands; it refuses an AND holding
a condition with both bounds.
Copying across accounts went through Blob/copy, which failed with
`unknownMethod` on Stalwart 0.16.10+ and 1.0 because
`urn:ietf:params:jmap:blob` was not in `using`. Even with it, the copy
would have been reserved to the target account, which only its members
may use, so copying into another user's shared drive stayed refused.
Copies now point at the source's blobId: Stalwart resolves a FileNode
blob from another account for anyone who may read that node, in both
directions and on every supported version.
A folder copy lays out the whole subtree before the first create. If a
create fails part-way, the half-made copy is destroyed with its
children and the server's error is reported, instead of leaving part
of the tree behind. Files created over WebDAV inside a copied folder
are no longer copied under their percent-encoded names (#869).
New files are created without `size`: every server takes it from the
blob, and 1.0 refuses a size that differs from the blob's.
Stalwart 1.0 treats a FileNode's `modified` as the client's: a content
update leaves it alone unless the patch asks for it. Saves from the
document editor therefore kept the old date in the file list, and the
editor's conflict check (409 with COOLStatusCode 1010) never noticed a
change made by another JMAP client. PutFile now sends `modified: null`,
which has the server stamp the save. 1.0 echoes the new time in
`updated`; 0.16.6+ answer `null` as before and the batched FileNode/get
reads it.
The editor also could not open a file in someone else's shared drive,
on any version: the Files app sent the listing's "<owner>:<id>" id for
the user's own account, and the launch failed with a 404. It now names
the owner's account and the node's own id, so owner and sharee join
the same editor session. A save uploads into the user's own account
and then points the owner's node at that blob, because Stalwart only
lets members of an account use a blob uploaded into it. The access
token carries that upload account; tokens minted before it upload into
the document's account as before.
The editor now also uses the cookie slot of the login the Pro shell is
browsing, not always the active one.
Stalwart 1.0 reads a text filter value that starts and ends with a
quote as one exact phrase, and a value ending in `*` as one prefix over
the whole string, not split into words and capped at 16 bytes. Every
search path sent the typed input as one `text` value, so on 1.0
`"annual report" 2024 "draft"` became a single four-word phrase and
`project budg*` a prefix containing a space: both found nothing.
`"Q3 planning" budget` lost its phrase on every version.
`splitSearchText` (`lib/jmap/search-utils.ts`) now sends each quoted
phrase and each `word*` as a condition of its own, ANDed with the rest.
The remaining words stay one condition, so a query without quotes or
stars goes out as before. Outer single quotes are stripped from that
part, since 1.0 reads them as a phrase too. The split applies to:
- the search bar, tag views, cross-account and all-folders search;
- advanced search, including the from/to/subject/body fields;
- contact search;
- global search for mail, contacts and calendar.
`inMailbox` and the calendar bounds sit next to the text conditions in
one AND, with one key per condition: Stalwart rejects an operator that
also carries property keys, and 1.0 allows only one AND group when it
expands recurrences.
SearchSnippet/get switches the whole request to exact matching once a
term is quoted, which marks nothing for a phrase ANDed with other
words, so the snippet filter unquotes phrases.
On 1.0 and on external search backends each phrase is now a real
phrase. The 0.16 built-in index reads it as an AND of its words,
without stemming. The dev mock and the demo client now evaluate AND
filters and quoted phrases, so they keep working with the new shapes.