A refused login used to delete the account's data, and with it everyone's
S/MIME and OpenPGP keys, also with offline off. The usual cause is a password
changed on another device, which Stalwart 0.16 and 1.0 both answer with
`invalid_grant`, so that lost unsent mail for little protection.
- A refused login signs out at once and hides the data. A new password brings
it back, unsent mail included. Otherwise it goes after 30 days. The sign-in
page gives a count and a date. The 24-hour timer is gone.
- `organisation.devices.wipeOnRefusedLogin`, off by default, asks for deletion
at once, of that account's own data only.
- The session limit no longer counts as a refusal on any route. It hides the
data and says nothing.
- A wipe runs under a Web Lock, tells other windows when it starts and ends,
and queues its report before deleting anything. It reports only once the
deletion worked and no plugin database is left.
- More goes with a wipe: Pro tabs, search history, Files and WebDAV state,
templates, pop-out drafts, shown notifications, push state, and Web Push
with the last account.
- The unsent count is per account, and unknown rather than 0 when the store
can't say. A new row is checked against the account that asked, and a
sign-out drops its row.
Existing tests changed because the rule did: `auth-store-keep-local.test.ts`
expected a wipe on `invalid_grant` and at the session limit, and
`device-wipe.test.ts` expected shared keys to go on a refused login.
A persisted account id with no subject yet read as decided: Everyone's
noOffline wiped mail a group scope allowed, and a wider scope went out at
every reload. A public, partial policy counted as the organisation's word,
and turned on the desktop default in a forbidding organisation.
- an organisation's cap on one file now binds a pin
- Clear and the off switch read what would be lost when asked, messages,
changes and drafts, and Clear waits for a connection
- turning one account off survives the last one going off, and off then on
no longer warns of an eviction
- a tab that takes over the store sends no scope until it has heard the policy
- a dropped account is judged by what applies to anyone
- a choice reaches the store once it settles; progress announces phases only
- restored Pro tabs ask for their titles
Eviction now goes by the base's signal: `storeLost` and the `store-reset`
notice, with `bulwark-store-opened` as the marker and `prune-files` for files
with no key. The storage page and the lost count hang off that. What a user
picks is stored under `bulwark-offline-scope`, the key `offlineScopeFor`
reads, and `email-snapshot` follows the base's rule. `OfflineEnabled` is read
from the desktop bridge when it's there.
The offline section of Settings > Backup & reset now shows, per account, the
space each part takes, the last sync, a first sync's progress with the time
left, the window and attachment choices, and clearing the account. All
conflicts, calendar and contacts too, are listed there and counted in the
banner.
- the organisation's caps beat the user's choice and the desktop default, and
every path that takes a scope goes through `effectiveScope`
- the desktop app keeps mail without asking, says so once, and stays off for
an account where the user turned it off
- `navigator.storage.persist()` is asked on turning it on, and a refusal
warns with a way to ask again; Safari gets the 7-day hint when not installed
- a store the browser deleted is noticed, says what was lost and syncs again
- a full disk says so, with what to do
- those who had offline mode keep it, with the 30 days they had
- Keep offline in the Files app's menu
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`.
A security and an accessibility review of the proofreading hook found
gaps, fixed here.
What leaves the browser:
- A checker gets a name for each composer of its own, not the composer's
id, so it can't reach the message through the other api.composer calls
after proofreading stops.
- Checking pauses while a plugin that can sign or encrypt is turned on but
still starting, and while the policy couldn't be fetched (the built-in
defaults could undo an admin's lock).
- No plugin can hold up turning proofreading off (onBeforeSettingChange).
- Suggestions can't carry zero-width, bidi or line-separator characters;
messages lose bidi overrides; a flood of annotations is cut off early.
- The notices name the plugin as installed, not a label it chose, and say
that text written before signing or encryption was turned on may
already have been checked.
- A checker registered during start-up no longer fails its first check.
Keyboard and screen readers:
- Escape in the issue panel closes the panel, not the message.
- Underlines carry aria-invalid, as the browser's own markers do.
- The focus goes somewhere sensible when a button or an issue goes away,
Tab past the popup's edges goes back to the text, and the shortcuts work
in the popup and the panel.
- Ctrl+' and Ctrl+; follow the character typed, so other layouts keep
their own keys (Ctrl+S on Dvorak), with Ä and Ö on German keyboards.
- The popup is described by its message, sits inside a modal composer,
fits narrow screens, and the panel fits a phone.
- The banner judged its start and end by the time it was mounted, so an
announcement fetched later than its start never showed in that tab.
- The Pro shell shows it once above its panes; email and folder tabs had
none and split panes showed it twice.
- Screen readers get it from a live region that fills a moment after it
is in the page; text present when a live region appears isn't read out
reliably. The tests that checked the strip's role now check that region.
- Moving the start or end no longer shows a closed announcement again;
the lib test that expected a new end time to reset it now expects the
opposite.
Organisation > Devices now sets policy.json `organisation.devices`, each
switch off by default and scoped per domain or group:
- No downloads: attachments and files only preview (reader, conversation
view, Pro tab, list chips, .eml previews, the Files app). Download,
Download all, Open in new tab, Save to Files, drag-out, the browser's
PDF viewer (pdf.js instead) and media download controls go away.
- No export: no .eml export, View source, message drag-out, or folder and
account export; a running export stops and its file is deleted when
the switch turns on. Import stays.
- No printing: no Print buttons in mail, calendar and contacts; Ctrl+P
prints a note; the reader frame prints blank.
- No install prompt: no card, and the browser's own banner is held back.
- Watermark: the user's address and the date over the reader,
previews and every printout (mail, calendar, contacts, plugins).
A message follows its own account's policy; what isn't about one message
follows the strictest signed-in account. Every user path hides its
button, and the anchor and new-tab helpers refuse as a backstop
(lib/download-guard.ts), never the blob readers. The plugin APIs
`ui.downloadFile` and `ui.print` refuse too, and the office editor frame
loses `allow-downloads`.
Two assertions in components/mail/__tests__/blob-routing.test.tsx now
expect `{ kind: 'export' }` as the fifth argument of downloadBlob for an
.eml export: the export says what it is, so the backstop checks "No
export" rather than "No downloads" for it.
Admins had logs and nothing else: client errors only reached the
browser console. An admin can now set a Sentry-protocol address (DSN)
for Bugsink, GlitchTip or Sentry under System > Monitoring, or with
`ERROR_REPORTING_DSN`, `ERROR_REPORTING_DSN_FILE`,
`ERROR_REPORTING_SAMPLE_RATE` and `ERROR_REPORTING_ENVIRONMENT`.
Bulwark Lite reads the same from `errorReporting` in policy.json.
Everything stays off while no address is set.
- browsers report errors from the error boundaries, window.onerror,
unhandled rejections and failed JMAP requests (method and error type
only), with route and JMAP method breadcrumbs and the chunks' debug IDs
- the server reports what Next hands to onRequestError and what routes
log with logger.error
- every report is scrubbed first: quoted text, addresses, URLs, file
names, tokens and the rest after the first line are removed, and the
server scrubs what browsers post to /api/errors again
- reports are deduplicated and rate-limited per session and process
- errors are counted per day for "errors today" on the admin page
- startup messages and audit entries go through the logger, so
`LOG_FORMAT=json` covers them
Fixes from the review of the admin part:
- GET /api/admin/policy reads the policy and its version together; an
identity check waiting on the store let another replica's change pair
the old policy with the new ETag, and a save with it then won.
- A policy save answers with the stored policy and its ETag, and the
policy, organisation, plugins and themes pages continue from that
instead of reading it back from an instance that may not have it yet.
- A stored policy that doesn't parse keeps its version, so a save can
replace it instead of answering 409 forever.
- A reload of another replica's change throws when this replica's writes
kept landing while it read, so the change poller tries again; a write
whose reload raced it reads the document again instead of applying its
older result.
- Dropping a deleted plugin from the policy rereads policy.json first in
local mode, as every other write does.
- New updateAdminConfig(key, change) builds a value on the stored one.
The policy route's test mocks gain getPolicyWithVersion.
The admin pages save the whole policy from the draft they loaded. A
save from a page opened before another tab's or another admin's save,
or before a deleted plugin was dropped from the policy, used to put the
older policy back. GET /api/admin/policy now sends the policy version as
an ETag; the policy, organisation, plugins and themes pages send it back
as If-Match, and a save based on an older version answers 409 and keeps
the draft on the page. A PUT without If-Match replaces the policy as
before. Deleting a plugin or theme changes only its id in the stored
policy, and a policy import compares against what it replaces.
Test mocks of the config manager gain getPolicyVersion and updatePolicy,
which the route and the extension cleanup now call.