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.
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 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
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.
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.
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.
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.
The store took a scope only when it opened and acted on it only at a full
sync, so a lower cap left old mail on the device until something else
forced a resync. Now the engine applies a changed scope first, without the
network, and once a day as the window moves on.
Also tell the page whether an account's file was there when it opened, so
it can notice a store the browser deleted.
- `calendar-sync.test.ts` now syncs with `days: null`: its old events test
checks the calendar's own two years, and the calendar follows the scope now
- `page-side.test.ts`: the fake worker answers an open with what it found,
as the real one does
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.
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.
An edit that removed an entry was dropped at flush, because a server
value with more keys counted as already applied. Other fixes in the same
path:
- the local copy after a flush is the server's read-back, never what was
sent, since Stalwart 1.0 stores a data: URI as a blob
- a delete or a newer edit after a lost response no longer reads as a
change on the server, and an edit made during a refused send stays
- a create only becomes a card with its uid if that card holds what was
sent, otherwise it is a refused conflict
- a refused or failed flush no longer stops contacts syncing
- request limits come from the session
- answers wait for the first full pass
- paging goes by anchor, so a deleted card doesn't make the next one
get skipped and swept
contacts.test.ts: "continues a stopped full sync" now checks the anchor
instead of position 2000, because the cursor no longer pages by position.