Commit Graph
15 Commits
Author SHA1 Message Date
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 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 b635986506 chore: merge drafts and kept attachments into the settings branch
One attachment scope: the kept-attachments code, which takes chunks with a
file and spares pinned mail. A draft only on this device stays through a
tightened window. Mail's budget still gives way to what Files keeps.
2026-11-03 12:04:47 +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 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 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 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 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 b001cbf109 feat: show what each account keeps on the device and let it be changed and cleared
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
2026-11-03 08:57:41 +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 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 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 687e7cc012 feat: move offline mode onto the encrypted local store
The IndexedDB cache, its crypto, the responder and the page-side sync
go. lib/offline keeps its structure, policy, wipes, sign-out gates and
notices; answers, the outbox and sync now live in lib/store, in a worker
behind lib/store/client/api.ts.

What users get besides that:
- every folder is kept, not just the Inbox and opened ones
- delete for good and empty trash work offline
- a change the server refuses, or that meets a message changed on
  another device, is shown instead of dropped
- a send whose response got lost is looked up by a Message-ID fixed when
  it was queued, then finished, submitted or sent again
- with the store on, a start paints from the local copy, then connects

An account the app drops by itself no longer wipes its data: only a
sign-out or an explicit removal does. The outbox of the old database
moves over once, in lib/offline/migrate-legacy.ts, and the old database
goes only after the store committed. `isNetworkFailure` now counts
timeouts and proxy 5xx. Sends and outbox requests are never answered
from the device.

Tests moved with the code: cache.test.ts and responder.test.ts are
replaced by lib/store/__tests__/, and review-fixes.test.ts, outbox.test.ts
and client-fallback.test.ts now run the real engine on node:sqlite.
Changed assertions: an uncertain send is looked up rather than left alone
forever, destroy and empty trash are queued rather than refused, and #9
(opened folders) became "a folder the server deleted goes", since all
folders are kept.
2026-11-03 05:54:39 +01:00
Linus Rath 4b60fcc1e6 fix: make the offline outbox never send twice, never lose mail silently and never skip a plugin #523
The review of offline mode found ways to send a message twice, to lose
unsent mail without a word, and to send mail without its signing,
encryption or check plugin. This fixes them and the smaller findings:

- each outbox message is taken for sending in one database transaction
  under a Web Lock per account; only a failure before the submission
  request (SendNotStartedError from the client) puts it back, any other
  one marks it "may have gone out"; read-backs after the submission no
  longer fail a send; a send of a closed tab turns uncertain after a minute
- every sign-out the user didn't confirm names the deleted messages on
  the sign-in page, or in a toast when another account stays signed in
- nothing is queued while an enabled plugin isn't running (after a start
  without the network none can); the composer says why
- the outbox waits for a fresh policy and holds messages the rules have
  since made stricter (a required label, a required send check)
- another tab switching offline mode off sticks: only switching it on
  may create the database
- only attachments of kept messages are kept, filed with their message,
  deleted with it and under the cap between syncs; file names and types
  are sealed; folders that went away leave the scope, and a folder that
  couldn't be asked keeps its mail without holding the others back
- a group scope that forbids offline mode waits until the account's
  groups are known; one server down no longer holds another's mail
- an offline start only when the server is really unreachable, and the
  real sign-in follows by itself once it answers
- the service worker files pages and chunks under the build that served
  them, and the shell list of another build isn't kept under this one
- a raw send counts as the composer's only from the plugin running its
  takeover, for that identity
- errors and refusals are in all languages; the banner tells "offline"
  from "server can't be reached"; outbox buttons name their message;
  changes and messages go in the order they were made
2026-11-01 14:19:54 +01:00
Linus Rath db5ac82f53 feat: keep recent mail on this device and send mail written offline #523
Settings > Backup & reset gets "Keep mail on this device", off until the
user turns it on in this browser. While it is on:

- the JMAP client hands the server's answers to an encrypted cache in
  IndexedDB (AES-GCM, non-extractable key) and answers from it when the
  network is gone: the folder list, the last 30 days of the Inbox and of
  the folders the user opened, bodies and attachments, at most 250 MB,
  oldest out first; a background sync keeps it current
- read, flag, tags and moves made offline are queued and sent in order
  when the server answers again; deleting for good and emptying a folder
  are refused offline
- the composer, quick reply and the S/MIME and PGP plugins' sends that
  complete the composer's go to an outbox once every step up to the
  transport ran (checks, label, signature, plugin send checks); only the
  finished send waits, with files attached offline
- the service worker keeps this build's app shell, so the app also
  starts offline from the kept session and the last policy
- a full sign-out, the idle sign-out and signing one account out delete
  it, a sign-out the user starts asks first when mail is unsent, and the
  login page names what an automatic sign-out deleted; a policy fetch
  that forbids it (devices.noOffline) wipes that account

Not in Lite.
2026-11-01 11:46:11 +01:00