Commit Graph
2164 Commits
Author SHA1 Message Date
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 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 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 dccf4e8b9f chore: merge feat/desktop-offline into the windows branch 2026-11-03 07:31:32 +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 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 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
Linus Rath e65f7fe31f feat: let the desktop app reach LAN and plain http mail servers the user approved 2026-11-03 06:32:28 +01:00
Linus Rath a30f989c57 feat: add a desktop build of the webmail that no server build can switch on 2026-11-03 06:32:27 +01:00
Linus Rath 9e89fb9038 feat: merge notification actions and background sync 2026-11-03 06:28:25 +01:00
Linus Rath 51c09a1a69 fix: never let a notification's buttons go round plugins, session limits or Trash
Review of the notification actions. With a plugin that may stop a move
or read change, or a silent tab holding the login, the message now
opens instead of the route acting. The route answers 401 under a
session limit, and neither path touches a message since moved to Trash
or Junk. A tab that thawed declines an old click, Archive follows the
gate of the notification's login and files into a shared-out Archive
as the app's button does, Reply opens a composer in the Pro shell, the
route retries once when another client made the folder first and
leaves no empty folder behind, and logins match however the server
address is written.

Two assertions of this feature's own tests change: the route answers
401, not 403, when a login's scopes can't be worked out, since that now
also covers session limits; a tab's message to the page now carries
`at`.

The Pro shell's mail surface now reads a parked link's query from the
handoff: the address bar may have none by its first render, which lost
`?slot=` and `?owner=` there too.
2026-11-03 06:28:04 +01:00
Linus Rath 2481f060b7 feat: merge offline search 2026-11-03 06:17:13 +01:00
Linus Rath d44738af87 fix: keep deleted mail out of merged search results and keep paging right after a refresh
The providers test now expects the palette option, because the palette keeps its paging apart from the mail list's. The search-merge test for a position with no cursor changed too: it used to expect that position sent to the server, which skips rows after a merge.
2026-11-03 06:16:45 +01:00
Linus Rath 41987cb5cf fix: stop hostile HTML stalling mail sync and index each message only once 2026-11-03 06:16:44 +01:00
Linus Rath 4ee918a98f fix: list a large folder off its index and sync message bodies in bigger batches 2026-11-03 05:46:55 +01:00
Linus Rath 877a7a93b2 feat: keep calendars on the device and queue calendar changes while offline
Adds a `calendar` area to the local store. It syncs calendars, participant
identities, events and tasks as masters, through CalendarEvent/changes,
and answers Calendar/get, CalendarEvent/query, /get and /set from SQLite
when the server can't be reached.

- A range query gets series in both client modes, with their overrides,
  and the calendar store expands them as it does for an older server.
- The synthetic-id probe gets the verdict from the last sync, so a client
  started offline behaves the same once the server is back.
- Create, edit, delete and RSVP queue in `calendar_pending`, one merged
  row per event, and go out through `calendar.flush` or the next sync.
  A create finds itself by uid after a lost response.
- A write the server refuses, or one on a value the server changed, goes
  to `calendar.conflicts`.
- `lib/store/calendar/reminders.ts` lists upcoming alerts with keys that
  don't depend on synthetic ids.
2026-11-03 05:42:01 +01:00
Linus Rath fdeb7963ba refactor: read event durations without loading the calendar UI 2026-11-03 05:41:35 +01:00
Linus Rath a4451dc900 feat: add results from the local store to online searches 2026-11-03 05:31:35 +01:00
Linus Rath fe5b5d5746 feat: search mail while offline, with compound words, prefixes and highlights 2026-11-03 05:31:34 +01:00
Linus Rath 2bb0797996 feat: archive, mark read or reply right from a new mail notification
A notification for one message gets buttons where the browser has room
for them, and a click on its body works as before. The service worker
has no login, so an open tab takes the action first, through the store
actions. With no tab open a new same-origin route, /api/push/action,
does Mark read and Archive with the context cookie. When the cookie is
gone, the token has expired or the login can't be told apart, the
message opens in the app instead. Reply opens the composer through a
`?reply=1` link, with any text typed on the notification.

- `public/sw.js`: buttons, asking open tabs, the route, `sync` handling
- `app/api/push/action`: same-origin, checks the login and the account,
  honours the admin's Archive gate, files by the mirrored archive mode
- `lib/pwa-notify`: the page's side, the mirror for the worker, and
  `requestOutboxSync`, which the offline queue calls
- Background Sync, thin version: a `sync` event tells open tabs to flush

Two push-preview-route assertions change: they match the whole answer
for a named message, which now also carries `login`, what the action
route checks so a stale notification can't act on whoever holds the
cookie slot now.
2026-11-03 05:26:43 +01:00
Linus Rath c65d18eaae feat: open mail, calendar and contact files from the OS and share into a new draft
The installed app only handled mailto: and webcal: launches. The launch
consumer dropped files, and nothing could hand a File to the composer or
the import dialogs, because the pending mailto is JSON in sessionStorage.

- manifest: `shortcuts` named in the browser's language, `file_handlers`
  for .eml, .ics and .vcf, and `share_target`, all with the base path and
  per-domain branding. Lite's manifest is unchanged.
- `lib/launch-handoff.ts`: the one place files and links wait, in memory
  and in the Cache API, so they survive a sign-in that leaves the page.
  The desktop bridge can call the same functions, since launchQueue never
  fires in Electron. Sign-out clears it.
- launch consumer: routes files by type, and takes a shortcut to an open
  window, which focus-existing would otherwise only focus.
- .eml opens in a new host for the preview, also before sign-in, with the
  strictest device policy deciding about saving. .ics and .vcf open their
  import dialogs through a new `initialFile` prop. A .ics needs the server,
  so offline the dialog says so and carries on once back online.
- composer: `initialFiles` goes through `addFiles`, so plugin vetoes and
  size limits still apply, also from a Pro compose tab.
- `sw.js` takes the share POST before the offline check and keeps the files
  for the page. Without a worker, `/protocol/share` passes text and links
  on and the page says the files are lost. `protocol` joins the app
  sections and the handler pages join the shell, so a mailto: launch
  offline no longer gets a network error.

Browsers without launchQueue or a service worker behave as before.
2026-11-03 05:26:01 +01:00
Linus Rath bad156eaed refactor: let each part of the local store expose its own calls to the page 2026-11-03 04:59:39 +01:00
Linus Rath 6da9a6a6f5 feat: add an encrypted local mail store that syncs in a worker 2026-11-03 04:57:20 +01:00
Linus Rath 1dfe09d321 fix: keep private files and secrets out of standalone builds 2026-11-03 04:14:47 +01:00
Linus Rath af32c846a2 fix: let plugins read the signature auto-insert toggles they may change 2026-11-03 02:46:51 +01:00
Linus Rath 34e016f1c0 test: stop the server-state guard counting empty as-const tuples as state 2026-11-03 02:35:42 +01:00
Linus Rath 5c0ffd7aff fix: let plugins switch the signature auto-insert toggles and refuse the proofreading settings 2026-11-03 02:35:01 +01:00
Linus Rath e2bc222f49 fix: load the assistant's calendar and settings calls on first use so plugin views don't load the calendar store 2026-11-03 01:54:32 +01:00
Linus Rath ddbff7aeb7 Merge the assistant's Files (package D)
# Conflicts:
#	components/email/__tests__/composer-plugin-edit.test.tsx
#	lib/__tests__/plugin-projection.test.ts
2026-11-03 01:48:30 +01:00
Linus Rath 553f7ee883 fix: start a plugin's api calls at once again, not a tick later 2026-11-03 01:38:42 +01:00
Linus Rath e9e07ddb64 Merge main into feat/assistant-everywhere 2026-11-03 01:35:57 +01:00
Linus Rath cf512f0a4e fix: check attachments, shared folders and limits in the assistant's Files calls
Fixes from the review of api.files:
- `files.attach` to a new draft runs `onBeforeAttachmentUpload` for each
  file and the server's total size limit before `compose.open`, which
  runs neither; refused files come back in `refused`
- a write is refused when a folder above the target is missing from the
  listing, instead of counting it as not shared
- `onFileMove` reports the item as it was, from where it was, like the
  Files app's own move
- writes are counted against the 20-a-minute limit before the drive is
  listed, so refused writes count and cost no listing
- reads stop the download past 20 MB (`fetchBlobArrayBuffer` with a
  limit) and answer `too_big` when the listed size was wrong
- when the server refuses the new content of a replaced file, the old
  version's copy is taken out of the trash again

The two getText assertions on `fetchBlobArrayBuffer` now expect the
account (undefined for the own drive) and the byte limit, since the
call passes both.
2026-11-03 01:31:05 +01:00
Linus Rath d7cb895ebf Merge branch 'feat/assistant-everywhere' into feat/assistant-d
# Conflicts:
#	lib/__tests__/plugin-permission-coverage.test.ts
2026-11-03 01:19:42 +01:00
Linus Rath 55d9c81210 Merge more actions for the assistant (package C) 2026-11-03 01:13:47 +01:00
Linus Rath 6c3e68ce4e Merge branch 'feat/assistant-everywhere' into feat/assistant-d 2026-11-03 01:01:29 +01:00
Linus Rath 989cfc64a4 fix: offer a plugin the invitation and settings calls only once it was granted their permission 2026-11-03 00:55:50 +01:00
Linus Rath 37aaf05d6d fix: spend a plugin's undo token only once its undo went through, so a failed undo can be tried again 2026-11-03 00:51:30 +01:00
Linus Rath 169100eb12 fix: take the setting names app.getSettings gives, and check hidden, gated and restricted settings on undo too 2026-11-03 00:47:35 +01:00
Linus Rath 8add856aac fix: let plugins only switch off sender favicons and avatars in Junk
Both make the server fetch the sender's domain. The test that every
default value is allowed now also names senderFavicons (on by default,
which a plugin may no longer set); the rest of it is as it was.
2026-11-03 00:46:08 +01:00
Linus Rath d973e9062a fix: give a photo's date only from when it was taken, not when the file changed
exif.test.ts: the case "falls back to the file date" now expects no
date, since IFD0's DateTime is the file's change date, not the date
the photo was taken.
2026-11-03 00:45:56 +01:00
Linus Rath 22a783efe0 fix: check an invitation's sender also when it claims one of the user's own addresses 2026-11-03 00:43:16 +01:00
Linus Rath f7a7828bfa fix: check an image attachment's size before decoding it, and decode on older browsers
The host reads the size a PNG, GIF, JPEG, WebP or BMP declares in its
header first: over 50 megapixels it answers too_big, trackers and tiny
images are never decoded, a format whose header it can't read is
unsupported, and large images are decoded at the target width. A browser
that throws a TypeError on createImageBitmap's options decodes without
them.

The tests' stand-in pictures are now PNG headers instead of JSON (the
code reads the header), so image-shrink.test.ts and
plugin-host-attachment-image.test.ts build them that way, and the first
shrink test expects the resize options on createImageBitmap.
2026-11-03 00:42:53 +01:00
Linus Rath 6ef1397a53 fix: refuse to switch the out-of-office on for a plugin while it forwards mail or that can't be read 2026-11-03 00:42:30 +01:00