A review found that an open page kept sending to a DSN after consent was
taken back, that a state file or config file could name where telemetry
goes, and that `BULWARK_TELEMETRY=off` did nothing on the desktop build.
A due heartbeat now waits a random 30 s to 10 min, a refusal from the
collector isn't retried, crash files go at sign-out and account switch,
and server builds parse stacks as they did.
Existing assertions changed in the desktop telemetry tests: the catch-up
wait is now a range, the endpoint comes from the shell, and the platform
is `desktop` against the collector's patched branch.
Review of the local store export. A cancel, sign-out or wipe during the
final step still downloaded the file; now the sink takes the job's signal
and drops the Blob, and a job that outlasts the wait is forgotten when it
ends. Finished exports are in a list a later wipe or sign-out can empty,
also after the dialog closed.
- `localExportAllowed` uses `scopeDecided`, so a domain or group scope
can't be missed before the subject arrives. It's checked before the
save picker, in the same tick as the click.
- WebKit holds the final Blob in memory: exports stop at 1 GB, and a
larger choice or one over `storage.estimate()` is refused up front.
- An empty folder that refuses during a full listing counts as empty,
only when the store's own count agrees. Any other refusal stops the run.
- A picked file goes on an abort or an all-failed run; if it can't, the
dialog names it. Failure texts come from our strings, not the browser's.
- A selection keeps to the chosen folders on a retry.
The test that the organisation shows as the reason now flips the policy
through the policy store, because the old one replaced the guard and so
never reached the watch that a real change goes through. The spec moves
to an account of its own, dso-export: other specs running at the same
time work in ent-dave, and its folder went missing there mid-run.
The desktop app and an installed PWA had a row across the whole window
with the title and the window controls. That row is gone. The rail,
sidebar, list and reader run to the top of the window in their own
colours, and the controls sit in the top row of whatever is rightmost.
- `lib/window-controls-overlay.ts` finds and marks those rows before
paint, and they reserve the controls' width through
`--titlebar-right-inset`
- the rows and the rail are the drag area, everything clickable is
`no-drag`, and an invisible band lets a page with no row still move
- settings and the admin shell get a row only under the overlay, Pro's
tab strip and Mountain View's bar match the controls' height
- Alt floats the menu over the top left, in `window-menu-bar.tsx`
- skeletons carry the same marks, so nothing shifts when the app loads
The timers used to delete everything an account kept, unsent mail included.
They now follow the refused-login rule: sign out and hide, the next sign-in
brings it back, otherwise it goes after 30 days. A login without "Remember
me" that ends with the browser, an admin wipe and a wipe from settings don't
change.
- `wipeOnRefusedLogin` is now `wipeOnForcedSignOut`, since it also covers the
limits. The server's `SESSION_TOO_OLD` goes the same way, as `session`.
- A refused account beside one that stays signed in shows in the switcher as
needing sign-in, with the count and the date, and gets one toast.
Existing tests changed because the rule did: the session timer tests expected
a plain `logoutAll`, and the auth store tests expected no hook at the session
limit.
Firefox and Safari have no save picker, so an export wrote a plain file
into the origin private file system for as long as it ran, and a crash
left a whole mailbox readable there until the next start. The check that
nothing readable sits in OPFS would find it.
The file is now a spool: each piece is sealed with AES-GCM under a key
that exists only in the tab's memory. When the export is done it opens
into a Blob the browser holds, and the spool goes at once.
The test that the private file stays while its download reads it changes
to say the same of the Blob: the file no longer outlives the export, so
what a download depends on is what gets released after the grace period.
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 plugin's render can be a key operation, and mark-read replacing the
message object ran it twice: a second PIN dialog, and two cancels lock the
key. The effects now depend on what the hook sees and on the explicit
re-render nonce, not on the object. The list row and the full message still
differ, as a row has no headers for a plugin to recognise its mail by.
onRenderEmailBody can return `blockExternalContent: true` (flag
`api.host.features.blockExternalContent`) for a body it can't vouch for.
External content then stays off whatever the settings and trusted senders
allow, in the reader and in conversations, with no way to load it. The S/MIME
plugin uses it against EFAIL.
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.
The lock is off by default, and an OS policy can require it and set the
timeout. It locks after a time without use counted across all windows and
tabs, on OS screen lock in the desktop app, and from Lock now.
Locking saves open composers, stops the outbox at its next message, closes
the store and stops its worker with every key, then reloads the page, so
nothing of the old page stays in reach. The new page holds the app back at a
Suspense boundary until someone unlocks. Pop-outs get no session while
locked, push notifications name nobody, and subjects leave plain storage.
One random lock key wraps each account's key as a layer. The PIN wraps the
lock key with PBKDF2 at 600,000 iterations, with a wait after wrong tries
that a reload doesn't reset. A passkey with PRF and the desktop's OS storage
wrap it too. Details and limits are in the decisions file.
A restored composer opened whatever the id named. Sending keeps the id, so its autosave would have destroyed the Sent copy, Send mailed it twice and Discard deleted it. It now needs the login in its address and a real draft, keeps its From with several accounts, and stays on the list if the server doesn't answer. A message from a bulwark:// link no longer marks itself read or sends a receipt. Native menus speak all 30 languages. Spellcheck dictionaries come from Google only after a yes, never in no-third-party mode.