Commit Graph
996 Commits
Author SHA1 Message Date
Linus Rath be8aba2ae7 Merge remote-tracking branch 'origin/main' into feat/desktop-offline
# Conflicts:
#	stores/auth-store.ts
2026-11-04 03:42:18 +01:00
Linus Rath 484ce34f5d Merge branch 'feat/do-browser-signin' into feat/desktop-offline 2026-11-04 03:19:32 +01:00
Linus Rath 8cb218ce3c fix: apply email notifications off and do not disturb per login, not to every login's mail 2026-11-04 03:13:59 +01:00
Linus Rath f0517ecd08 test: type the sign-out push test's fake client 2026-11-04 03:13:59 +01:00
Linus Rath 248f9c261b fix: take down push for logins with no connection when signing out of everything 2026-11-04 03:13:59 +01:00
Linus Rath 80084457be fix: end a removed login's push before its client disconnects 2026-11-04 03:13:58 +01:00
Linus Rath a6ef818149 fix: end a login's push registration when it signs out, is removed or wiped 2026-11-04 03:13:57 +01:00
Linus Rath fc6a209727 fix: keep push notifications off screen with email notifications off or do not disturb on 2026-11-04 03:13:56 +01:00
Linus Rath 97c0bd633a Merge branch 'feat/desktop-offline' into feat/do-edit-attachments
# Conflicts:
#	lib/desktop/languages.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
2026-11-04 02:40:47 +01:00
Linus Rath ab21bf2df3 fix: keep a desktop sign-in in the browser's page until the user says yes
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.
2026-11-04 02:40:37 +01:00
Linus Rath d31d0af560 fix: say what an open program still holds, keep forwarded parts under their rules, and let main decide what a sent draft leaves 2026-11-04 02:36:48 +01:00
Linus Rath 1c98e74149 Merge branch 'feat/desktop-offline' into feat/do-browser-signin
# Conflicts:
#	lib/desktop/languages.ts
2026-11-04 01:40:31 +01:00
Linus Rath c8c98e79d4 feat: sign in to the desktop app in your browser, with your password manager
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.
2026-11-04 01:09:12 +01:00
Linus Rath 889f6ffbd1 fix: fail requests under way when the desktop app closes its connections, and forget client certificates per account 2026-11-04 00:56:09 +01:00
Linus Rath 8549e3c4c6 feat: translate the desktop app's client certificate picker and forget chosen certificates at sign-out and account switch 2026-11-03 23:21:56 +01:00
Linus Rath fafdb0cdc0 fix: stop every search re-rendering the mail list when nothing is ticked 2026-11-03 21:06:48 +01:00
Linus Rath 529d334496 Merge branch 'fix/ui-bug-hunt' 2026-11-03 19:44:46 +01:00
Linus Rath 335e1edc10 Merge branch 'feat/desktop-offline' into feat/do-app-lock
# Conflicts:
#	components/settings/general-settings.tsx
#	hooks/use-calendar-alerts.ts
#	lib/desktop/app-lock-state.ts
#	lib/desktop/policy-schema.ts
#	lib/desktop/types.ts
2026-11-03 17:52:30 +01:00
Linus Rath 99bc974f8b Merge branch 'feat/do-telemetry' into feat/desktop-offline
# Conflicts:
#	lib/desktop/types.ts
2026-11-03 17:42:32 +01:00
Linus Rath dcfe25c754 Merge branch 'feat/do-store-export' into feat/desktop-offline 2026-11-03 17:39:54 +01:00
Linus Rath 3d5b02b75f fix: stop a page reporting once consent goes, and let nothing on disk aim the desktop app's telemetry
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.
2026-11-03 17:38:04 +01:00
Linus Rath 3f264367af fix: harden the app lock: forget in an order that can't leave a sign-in without a PIN, hear wipes and clear subjects while locked, and refuse obvious PINs 2026-11-03 17:08:02 +01:00
Linus Rath 289cce9bd5 Merge branch 'feat/do-reminders' into feat/desktop-offline
# Conflicts:
#	lib/offline/offline-mode.ts
2026-11-03 16:58:11 +01:00
Linus Rath af6cdfb997 fix: keep unsent mail through the idle and session limits, and mark a signed-out account in the switcher
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.
2026-11-03 16:36:13 +01:00
Linus Rath f21198b804 Merge branch 'feat/desktop-offline' into feat/do-app-lock 2026-11-03 16:24:34 +01:00
Linus Rath 71bd7af93d Merge branch 'feat/do-remote-wipe' into feat/desktop-offline 2026-11-03 16:20:09 +01:00
Linus Rath 3c3f6016b9 test: catch a wipe heard twice, a row put back and a mark left by a recovered refusal 2026-11-03 16:04:14 +01:00
Linus Rath c76189a174 fix: drop ticked search results when the folder searched in changes 2026-11-03 15:44:20 +01:00
Linus Rath 3cda05a1fa feat: export mail kept on this device to mbox or EML zip, also offline 2026-11-03 15:40:57 +01:00
Linus Rath 940a572844 feat: hide an account's mail when its login is refused, until the new password or 30 days
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.
2026-11-03 15:11:47 +01:00
Linus Rath cc9aa64eb7 fix: flag the mail a reply answers when it crosses between a Pro tab and the standard composer 2026-11-03 14:57:35 +01:00
Linus Rath 2836cb6466 Merge branch 'feat/desktop-offline' into feat/do-reminders
# Conflicts:
#	lib/desktop/types.ts
#	lib/offline/offline-mode.ts
#	lib/store/areas.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
2026-11-03 14:46:00 +01:00
Linus Rath 306b249c07 chore: merge the offline and desktop base into the app lock branch 2026-11-03 14:40:25 +01:00
Linus Rath 7aac946a9d feat: merge managed deployment policies and enforcement
# Conflicts:
#	lib/desktop/types.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
2026-11-03 14:08:20 +01:00
Linus Rath bd7d12f801 Merge branch 'feat/desktop-offline' into feat/do-remote-wipe
# Conflicts:
#	components/offline/offline-settings.tsx
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
2026-11-03 13:58:22 +01:00
Linus Rath 52a2b458a0 feat: merge decrypting once per message and keeping external content off unverified mail 2026-11-03 13:38:40 +01:00
Linus Rath a74543c137 feat: take back certificate grants at sign-out and account switch 2026-11-03 13:24:11 +01:00
Linus Rath 8523289a4b feat: let an admin wipe a lost device, and clear an account the server refuses for good
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.
2026-11-03 13:19:44 +01:00
Linus Rath fb3c82eed5 feat: lock the app with a PIN, a passkey or Windows Hello, with mail out of memory and the local store closed
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.
2026-11-03 13:08:37 +01:00
Linus Rath cd3bd33307 fix: bring an account back when its server is listed again, whichever way the page got to the login 2026-11-03 13:07:59 +01:00
Linus Rath 8d802e4601 fix: decide on offline policy only with the whole policy and the user's groups, and ask before losing edits
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
2026-11-03 12:33:53 +01:00
Linus Rath a802411cdd feat: fire calendar reminders from the local store, with native snooze and join buttons and one summary of what was missed 2026-11-03 12:31:58 +01:00
Linus Rath 2edd6b5ea7 fix: hold forced policies against spoofed plugins, recommended values and stopped servers
A forced value could be got around from inside the app. Only a forced
value may now decide what's allowed: the keys that do are forced-only,
so a recommended one no longer acts as forced. With a plugin list a
package can name any id, so uploads are refused and only managed
plugins can match. A server taken off AllowedJmapServers stops its
accounts without signing out: client, push and sync stop, the switcher
drops them, restore and refresh answer 403 with the cookie kept, and
listing it again resumes with no sign-in. OAuth and SSO starts check
the list too.

- `policy.onWipeOfflineData` is gone from the bridge: main runs the wipe
- `KeystoreAllowedOrigins` added, and `POLICY_CONSUMERS` names who acts
  on each key or who still owes it
- a Bulwark server's name must be plain after parsing, a wildcard in the
  auth lists needs a domain of two labels
2026-11-03 12:06:21 +01:00
Linus Rath efa6fa4ae4 Merge branch 'feat/do-store-settings' into feat/do-policies 2026-11-03 11:44:34 +01:00
Linus Rath fb6ff162ff Merge branch 'feat/desktop-offline' into feat/do-policies 2026-11-03 11:40:53 +01:00
Linus Rath e0abc7fa18 chore: merge the reviewed store, pop-outs and desktop core into the settings branch
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.
2026-11-03 10:40:47 +01:00
Linus Rath eb365938a2 test: line the merged branches' tests up with the store's wipe rules
The token route now answers 503 when the provider can't be reached, in every build, and a refused renewal for a background account now removes it as evicted, which keeps its data unless the server said invalid_grant. Two assertions from the desktop and pop-out branches change for that. Also drops a duplicate import from the same merge.
2026-11-03 10:26:00 +01:00
Linus Rath 6e262bf1c5 feat: move offline mode onto the encrypted store, with a merged outbox and conflicts
# Conflicts:
#	app/api/auth/token/route.ts
#	lib/offline/offline-mode.ts
#	stores/account-store.ts
#	stores/auth-store.ts
2026-11-03 10:21:34 +01:00
Linus Rath a9b6a05841 feat: enforce the desktop app's OS policies for plugins, mail servers and offline mail 2026-11-03 10:15:15 +01:00
Linus Rath 1dde703993 fix: list files again when the real client takes over from the local copy 2026-11-03 09:53:04 +01:00