Commit Graph
2211 Commits
Author SHA1 Message Date
Linus Rath 9c36801f5e fix: keep composers mounted while the app lock saves them, and tell the service worker nothing with the lock off 2026-11-03 13:37:15 +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 aebce87776 Merge branch 'feat/do-native-ui' into feat/do-app-lock 2026-11-03 12:01:57 +01:00
Linus Rath 8512e68d12 feat: merge offline drafts and kept attachments 2026-11-03 11:46:03 +01:00
Linus Rath 2c08b10fef feat: merge the plugin keystore capability 2026-11-03 11:41:30 +01:00
Linus Rath 1d4587f7ea fix: stop a stuck certificate call holding mail rendering, and let a server's certificate access be taken back 2026-11-03 11:31:14 +01:00
Linus Rath e98741d604 fix: say a store was reset also when a new owner took over the open, and slow raw downloads to one a second 2026-11-03 11:17:53 +01:00
Linus Rath fb0eeca9c5 fix: put the focus on the desktop menu strip once its names are on screen 2026-11-03 10:49:25 +01:00
Linus Rath d9c9fc03f7 feat: give the desktop app a native menu bar and native right-click menus built from the webmail's own 2026-11-03 10:44:03 +01:00
Linus Rath b2931ced3e feat: bring desktop windows back at the next start, open pop-outs from a warm spare and ask natively before discarding a draft 2026-11-03 10:44:02 +01:00
Linus Rath 60eef9c1ef chore: merge the reviewed mail branch, pop-outs and desktop core into the offline drafts branch
The flush keeps one `outbox.dropOrphans` caller, the mail branch's daily
one with its 7 days, and drafts' own query says what's needed, so
`addHeldFileSource` and its test are gone. `unsentCount` counts local
drafts, and a draft a waiting send names counts once. The scope check in
`recordBlob` and the permanent-status rule moved to
`lib/store/mail/attachments.ts`, with tests.
2026-11-03 10:40:52 +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 4fd82437ad fix: keep attachments alive through a flush and never destroy a copy that isn't ours
Review of the offline drafts found these.

- Every new message gets new part ids and old ones die with it, so an
  open composer failed with `blobNotFound` after a flush. The client and
  the flush re-read the draft and re-point parts once. The fake now
  gives new part ids, which is why `drafts.test.ts` and
  `drafts-client.test.ts` compare file contents instead of blob ids.
- A Message-ID alone could destroy another client's message. A copy now
  has to be solely in Drafts, with `$draft`, close to the attempt, with
  the same subject and size, and what the lookup saw is the destroy's
  base. Ghosts ride on the replacing row and only queue once it's up.
  `drafts.created` no longer queues a copy that arrived for a replaced
  row, so its test now expects the next flush to do it.
- Turning offline off asks about device-only drafts too.
- A kept file can't read back empty: chunks of a kept file are left
  alone, and `keptBlob` and `heldFile` refuse pieces that don't add up.
- A flush stopped by a passing failure tries again, the banner shows
  waiting drafts, and under the flush lock a `sending` row counts as
  left by a dead tab at once. A lookup refused for good no longer stops
  the outbox.
- `FetchTransport` streams downloads and reports a broken transfer as
  interrupted, so one bad file doesn't end the pass.
- Raw messages come after every file, only into free room, and a file
  needing room pushes them out first.
- An old version in another login stays until the new one is up. An
  online send of an unflushed stand-in still removes its submission.
2026-11-03 10:06:17 +01:00
Linus Rath 2db3c4b217 feat: let privileged plugins sign and decrypt with the OS certificate store in the desktop app 2026-11-03 09:58:48 +01:00
Linus Rath 7dc4c775cd fix: don't wipe a store just because this window can't open it, and don't send a stand-in when a file's upload is held again 2026-11-03 09:45:03 +01:00
Linus Rath ebec09212e fix: say plainly when a private window, old stranded mail or a silent server is the problem, and clean the domain of a legacy Message-ID 2026-11-03 09:25:07 +01:00
Linus Rath 024078c597 fix: clear files held for nothing a week after a flush, leaving what other parts of the store still hold 2026-11-03 09:25:06 +01:00
Linus Rath 3dc2fc2c3c fix: give up on a kept attachment only when the server says it won't come 2026-11-03 09:25:05 +01:00
Linus Rath 340ad27e32 fix: send waiting mail when a client connects after the first flush, and come back to stale sends and servers that were down 2026-11-03 09:18:42 +01:00
Linus Rath 9b3bbc6070 fix: delete a store file whose key is gone, say that it happened, and stop waiting on an owner tab that doesn't answer 2026-11-03 09:18:41 +01:00
Linus Rath 5dc0f05a70 fix: count a draft with a waiting send once when signing out 2026-11-03 09:16:32 +01:00
Linus Rath 18efb9f4c3 fix: wait 30 seconds before looking up a send, bound the waits at a start, and keep opened files only as the chosen scope allows 2026-11-03 09:00:52 +01:00
Linus Rath 62a47434e3 fix: ask before a sign-out about everything unsent, including changes, and say when the local store won't open 2026-11-03 08:56:30 +01:00
Linus Rath 3b1fa089aa test: give the stand-in desktop bridge its version and platform 2026-11-03 08:54:05 +01:00
Linus Rath cdda75d13b fix: keep queued changes and mail through rate limits, logins that need renewing and proxy errors, and show the server's copy after a real refusal 2026-11-03 08:49:53 +01:00
Linus Rath 5c5d982f9d feat: save drafts and keep attachments while offline
A draft saved offline now stays on the device under a stand-in id and
goes to the server by itself when it's back. Until now every save was
refused and the composer showed "not saved".

- `lib/store/drafts`: one pending create per draft, however often it's
  saved. A draft from the server edited offline is replaced by create,
  then destroy, so a failure leaves a copy. Each create has a fixed
  Message-ID, looked up in Drafts after a lost response. A refused
  draft stays on the device with its error and can be opened. Changed
  on another device: both are kept and the user is told.
- `JMAPClient`: stand-in ids in Email/get and Email/set resolve like
  calendar's, also when answering from the device. A file attached
  offline stays readable, and is uploaded before a save that names it.
- Held files and kept mail files are stored in 1 MB chunks. Orphans are
  dropped at the end of a flush, counting what drafts need.
- `lib/store/mail/attachments.ts`: attachments, inline images and raw
  messages per scope, newest first, after everything else in a pass,
  with eviction that spares pinned mail and never sees held files.
- `mail.rawBlob` and `mail.rawStatus` for an offline export.

Changed test assertions: `outbox.heldFile` and `mail.getBlob` return
`blob` instead of `data`, so a file isn't held whole in memory. The
attachment tests ignore raw downloads, and mail-sync runs with
attachments off, because raw messages add a commit per message.
2026-11-03 08:49:19 +01:00
Linus Rath 2b48047bcc feat: merge the webmail side of the desktop app
# 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
#	stores/account-store.ts
2026-11-03 08:48:10 +01:00
Linus Rath 55ca12adbd fix: say when a start stays on the local copy, queue its sends, paint it only with folders kept and fill the From list after the swap 2026-11-03 08:43:35 +01:00
Linus Rath b79781cc94 fix: never lose a draft moving between windows and keep credentials off the window channel
Review of the pop-out work found ways to lose text, a wrong sign-out and
changes reaching ordinary browser tabs.

- Drafts: a closing window hands text over only to a main window holding
  its login, and asks instead when the last save failed, the browser is
  offline, a save is running or plugins could still have a say. A failed
  rescue goes to "Continue draft". The opening window lets go only once
  the composer is on screen, also for Edit draft in a message window.
  Send options travel with the draft. Closing during a send asks first.
- Sessions: answers are encrypted for the window that asked, and only a
  window holding the login answers. A renewal queued on the lock can be
  called off by a sign-out, signs nobody out once its account is gone, and
  takes the cached token after waiting (Rauthy `nbf`, #552).
- Ordinary tabs run none of the host. No lock is held for life.
- A message window hides after a refused session and waits for its
  folders; a composer stays on screen with its text. Another tab dropping a
  login it couldn't restore no longer closes pop-outs.
- Plain-text mail stays selectable in the desktop app. Folder save renames
  reserved and shell names and skips one refused name. All display-mode
  checks know the overlay. The attachment bar keeps to one row.

The earlier test that accepted text being gone after a failed rescue now
asserts that it reaches the "Continue draft" slot.
2026-11-03 08:37:10 +01:00
Linus Rath 706550daf4 fix: keep local mail and the outbox when an automatic sign-out wasn't a real refusal of the login, for 30 days at most 2026-11-03 08:33:03 +01:00
Linus Rath 10a2c99b83 fix: keep syncing when a message has two keywords differing only in case, and compare keywords the way the server does 2026-11-03 08:22:15 +01:00
Linus Rath 46766ac8aa fix: let a server build pass its guard, and let the desktop app save drafts and forget a server on sign-out 2026-11-03 07:57:35 +01:00
Linus Rath a365ba9041 Merge branch 'feat/desktop-offline' into feat/do-desktop-core 2026-11-03 07:34:32 +01:00
Linus Rath 2ebb5ea9cf test: run the handoff wipes on the local store 2026-11-03 07:31:50 +01:00
Linus Rath dccf4e8b9f chore: merge feat/desktop-offline into the windows branch 2026-11-03 07:31:32 +01:00
Linus Rath b1c4bb00d9 chore: merge opening files and sharing into the mail branch 2026-11-03 07:30:08 +01:00
Linus Rath fcbc6edb89 fix: stop losing the first local store call of a tab that just became the owner, which left it syncing for good 2026-11-03 07:17:23 +01:00
Linus Rath 0dcabc9a7c test: type the desktop flag test's environments 2026-11-03 07:13:18 +01:00
Linus Rath d6ae3d172b feat: merge opening files and sharing into the installed app
# Conflicts:
#	components/mail/mail-app.tsx
#	lib/__tests__/sw-harness.ts
2026-11-03 07:11:39 +01:00
Linus Rath f716ea66ee fix: ask before a share starts a message and save nothing until the first edit
Any site could POST a form to the share target on load: with no clicks
the composer opened with its subject and file, and autosave stored a
real draft on the server 2 seconds later. The worker can't see where a
POST came from, and Chromium's link capturing accepts form submissions,
so an installed-app window proves nothing either.

- every share landing now asks first, in every window and browser; a
  text landing address gets the same question. Nothing goes up before
  the answer.
- handed-in text and files are the composer's starting state: no
  autosave, page-close save or sign-out flush before the first real
  edit.
- the worker ignores frames, keeps at most 5 entries and 100 MB for 15
  minutes, sweeps on activate, on each share and from a page timer,
  and seals what it keeps with a key passed in the landing fragment.
  Offline wipes clear it too.
- the server fallback uses the fragment and caps by encoded length:
  3000 Japanese characters made a 27 KB `Location` and a 431.
- a second compose handoff waits until the composer closes, also one
  set aside by reading a message, where it used to replace the first.
- a launch leaving the mail page saves what was just typed first.
- an expired handoff or a missing stored file now says so; shares get
  the control-character stripping and limits of `parseMailto`.

Existing tests changed because the landing moved from the query to the
fragment, stored entries gained a sealed part, and a share now asks
first.
2026-11-03 07:08:38 +01:00
Linus Rath d3bd93b80d feat: send calendar edits with the outbox and use kept files offline 2026-11-03 07:02:40 +01:00
Linus Rath 20b2e7054e chore: merge search, notification actions and calendar into the mail branch 2026-11-03 07:02:39 +01:00
Linus Rath 7ac582f803 fix: compile the desktop trust path out of every other build, and fail a build that keeps it 2026-11-03 06:55:22 +01:00
Linus Rath bb2393cf04 feat: open messages and drafts in their own windows and save all attachments to a folder
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`.
2026-11-03 06:50:21 +01:00
Linus Rath a6df6c4aac docs: name no address in the desktop http route's comment 2026-11-03 06:41:22 +01:00
Linus Rath f2356cdfc7 fix: look a send up soon after its response got lost, also with the server still there 2026-11-03 06:40:45 +01:00
Linus Rath 2eb835b0b6 feat: merge offline calendar
# Conflicts:
#	lib/jmap/offline-hooks.ts
2026-11-03 06:39:38 +01:00
Linus Rath a3819f6a1c fix: keep offline calendar writes in order, and stop them getting lost or doubled
Review of the calendar store found writes that went missing, went out
wrong, or appeared twice.

- Writes with different scheduling flags stay separate and go out in
  order. Merging them collapsed the two steps of an occurrence change, so
  attendees who had answered got no mail, and "don't notify" went out to
  everyone with a later RSVP.
- An edit made after a create's lost response is sent as an update. An
  import of a uid the server has updates what differs.
- `createCalendarEvent`, `batchCreateCalendarEvents` and
  `createCalendarTask` choose a uid before the first send, so a lost
  answer online doesn't duplicate.
- The client resolves stand-in ids before the network, which covers an
  open dialog once the server is back.
- A stale whole `recurrenceOverrides` map no longer erases overrides.
- A 4xx refuses that write and the queue goes on. `serverFail`,
  `rateLimit` or an answer with no verdict retry instead of deleting.
- "This and all following" is refused offline with a clear message.
- A full list only drops what the server says it doesn't have. Sync adopts
  a stand-in by uid. Reminder keys survive a stand-in becoming real.
  Accounts the session no longer lists are forgotten.

Changed tests: the one asserting a sticky scheduling flag is gone, it
locked in the bug. Reminder keys now use the account and uid. Two flush
assertions drop `sent: 1`, because sync reads a lost write back first. The
two-account id test uses a listed account, as unlisted ones are removed.
2026-11-03 06:32:53 +01:00
Linus Rath aa511e5d38 feat: sign in through the system browser in the desktop app, and refresh at the account's own server 2026-11-03 06:32:43 +01:00