Opening a message now fetches its conversation (Thread/get) and shows it in the reading pane, adds the address menus to each address, and shows a loading placeholder while a first message waits for its conversation. Shorter list rows put three more rows on screen, so searching and switching folders draw more rows, and the first-start notices sit above the list. Measured against a build of 5a020cf2f, the last green run, with component names kept. The open's extra pass that drew the mail view twice is fixed instead; the budget takes the rest. Render passes for opening go down from 8 to 6.
Where people may sign in to a server of their own (the custom endpoint
setting and the desktop app's standalone mode), the sign-in form asked
for a "JMAP Server" first, refused a host without https:// with the
browser's own bubble and talked about CORS. Now the email address comes
first and the server is looked up from its domain (JMAP well-known, SRV,
then the admin's default) through a new budgeted route. The "Mail
server" field opens only when nothing is found, takes a bare host, and
keeps the CORS hint behind "Having trouble?" (not in the app). Gmail,
Outlook.com and iCloud addresses get a plain note that these offer no
JMAP. The app's browser button stays the main one and asks for what it
needs under the field it needs.
Also on the page: the theme button is an icon named "Light or dark",
the heading is the app's name only when the admin gave one (else "Sign
in", without the subtitle repeating it), "I have a code from my
authenticator app", "Add account", and no 11 px text.
login-page-i18n.test.tsx now names the app for the subtitle test and
waits for the heading, since the default name no longer shows a subtitle,
and finds the theme button by its new name.
The hand-off parked a password before its question, for up to five
minutes, and on a shared computer another local user could ask for
someone's sign-in with a link of their own. Now nothing leaves the page
before the yes, and the instance has a switch for such machines.
- `/check` tests a password and keeps nothing, `/park` runs on the yes and
hands over tokens wherever the server allows, a password as a last
resort
- `/discard` and a timer take out what SSO has to park first, and a no
revokes the refresh token, and the shared store's TTL is the real expiry
- `DESKTOP_BROWSER_SIGN_IN`, default on, closes the routes, the page's
hand-off and the app's button; the question says which device asked
- tokens from a typed password stay a password account under the name
typed (#1432)
- redeem refuses a server of the user's own when the device's rule says
no
- the park budget is reserved before going upstream, and only failures
count
- "Not now" ends the flow in the app at once, and the question names the
account
`desktop-handoff-routes.test.ts` now tests the two-step flow against the
real budgets. `sso-start-route.test.ts`'s config mock answers the new
setting, and the SSO test's expected answer has the account in it.
The desktop login page gets "Sign in with your browser…" as its main
button. On an instance it opens the instance's real login page in the
system browser, with a PKCE challenge. The page checks the password or
runs SSO, parks the sign-in under a one-time code, asks once, and sends
the code to the app's loopback listener. This browser gets no session
out of it, and the app's own page redeems the code, so SameSite=Strict
is fine. Standalone runs the mail server's OAuth against the listener,
with a page from main for a server with no OAuth at all.
- `/api/auth/desktop-handoff/park` and `redeem`, and `sso/complete` parks
tokens the same way for a loopback target
- `auth.claim()`: the calls are taken once per document, in the first
render, in `WindowControlsHost`
- `BrowserSignIn` policy, default on, with its ADMX and samples
- strings in all 30 languages, and the old button's are gone
`runDesktopBrowserSignIn`'s tests changed because the code no longer goes
through the callback page's address: it's redeemed where it arrives.
Until now a lost laptop kept its offline mail for as long as anyone could
open it, and an account whose login the server had ended kept its copy too.
- `lib/devices`, `app/api/devices`: browsers with offline on and desktop
apps register, in a durable collection with no expiry. A check-in goes
by a per-device secret, so a revoked device still hears its wipe.
- `app/api/admin/devices` and the org devices page: wipe one device or all
of a user's, with audit entries for each request and each confirmation.
- `lib/offline/device-wipe.ts`: a start finishes any wipe cut short, then
waits up to a second for the instance before showing the local copy. A
wipe takes the store, keys, plugin databases and handoffs, signs out,
and reports back, again at the next contact if the report failed.
- `lib/jmap/client.ts`, `stores/auth-store.ts`: only `invalid_grant`, the
session limit, or a 401 lasting past a re-auth count as refused. A 500,
a proxy page or an outage never do.
- Settings: "Remove everything from this device", with a confirmation.
`lib/shared-store/__tests__/import-data.test.ts` changes its assertion
because it lists every collection the import copies, and `devices` is new.
An installed app had no title bar of its own, no way to save attachments
except a zip, and "pop out" stayed inside the page. A new window was a
second copy of the app that had to restore the session itself, and on
start-up that evicts any login made without "Remember me" from the list
every window shares.
- Title bar: `display_override` with `window-controls-overlay`, one
`--titlebar-inset` variable at the page roots and skeletons, and a drag
strip with the window title. 0 without an overlay, so nothing moves.
- Save all to a folder: `showDirectoryPicker`, asking the device policy
before the picker and before each file, with the zip as the fallback.
- Pop-out routes `/popout/message/<id>` and `/popout/compose`, on offer in
an installed app or when the desktop bridge has windows. Hidden in Lite.
- A pop-out borrows the main window's session and never evicts or starts a
token timer. Every renewal now takes a Web Lock, so two windows can't
send the same refresh token, which Stalwart 1.0 may treat as a replay.
- A draft moves to its window under the same id. A closing window hands
unsaved text to the main window to save, since its own request wouldn't
survive the close.
- Read state, drafts, theme and language follow across windows, and
`html[data-desktop]` gates the app-like behaviour in `app/desktop.css`.
Every image, the standalone tarballs and the Lite zips are built with
source maps (`BULWARK_SOURCE_MAPS=1`). Turbopack builds give each chunk
a debug ID themselves; the webpack build in the Docker image gets them
from `sentry-cli sourcemaps inject`. `scripts/sourcemaps.mjs collect`
then moves every map out of the build before anything is packed, and
`scripts/sourcemaps.mjs check` fails the build when a `.map` file, an
inline map or a `sourceMappingURL` to a missing file would ship.
- Docker images: a `sourcemaps` stage holds only the maps; the amd64
job exports it (a cache hit) and uploads it, since both platforms
build identical chunks
- standalone tarballs, the Lite zips and the Lite image upload their
own maps and check what they ship
- `scripts/upload-sourcemaps.sh` uploads to `bulwark-instances` and
`bulwark-telemetry` (Bugsink keeps maps per project) and skips with a
notice when the `BUGSINK_AUTH_TOKEN` secret is missing
- `/api/metrics` in Prometheus text format, off until `METRICS_TOKEN`
or `METRICS_TOKEN_FILE` is set (404 before, 401 without the bearer
token): requests per route group with status class and latency,
sign-in outcomes, calls to Stalwart, WOPI, translation, the plugin
proxy and OAuth, browser errors, errors today, process memory,
event-loop lag, uptime and build info. Labels come from fixed lists.
Each replica serves its own.
- `/api/health/live` answers while the process runs;
`/api/health/ready` checks the config, the shared store, writable
data dirs and Stalwart's session resource. `/api/health` and
`/api/ready` are unchanged.
- the Docker image gets a HEALTHCHECK on the live endpoint
- adds prom-client, and the OpenTelemetry packages the tracing step
loads on demand
A Playwright job on the perf guard's production build runs axe-core's
WCAG 2.2 A/AA rules on the mail list, reading pane, composer, calendar,
contacts and Settings > Accessibility, and fails when a screen has more
serious or critical violations than scripts/a11y-guard/budget.json
allows (all zero today). Two more checks cover what axe can't see: the
keyboard focus outline stays visible in Windows contrast themes, and
single-key shortcuts don't act on the mail behind an open dialog.
axe-core is vendored next to the spec (MPL-2.0, see NOTICE), so the
check adds no dependency. `A11Y_GUARD_UPDATE=1 npm run test:a11y-guard`
rewrites the budget.