The settings pass hid each calendar's CalDAV address behind "Use in
other apps"; pim-26 lists the address once per account. The list now
waits behind one "Use in other apps" button, and the wording test
clicks the new Edit… button instead of the old pencil.
With two accounts signed in, Calendar showed only the active one's
calendars. Now, as in the Pro interface, the sidebar lists each
account's calendars under its name, each with its own checkbox, ticked
by default, and the grid shows the ticked ones together. Creating,
editing, moving, deleting and answering an event of the other account
go through that account; tasks of all accounts are listed and change in
their own account.
- a calendar one account sees as shared and the other owns shows once
- a load or push that names one account keeps the merged list
- an event made in the other account's calendar no longer takes the id
of the active account's event with the same raw id
- a change to an event the store no longer holds still goes to the
account its id names
The import dialog in the desktop app can now find Thunderbird profiles and
Outlook PST files, show what it found and import what the user confirms.
Browsers behave as before.
- The bridge's `localImport` gives opaque handles and range URLs. The shell
asks natively before it looks, before it reads and before it goes on after
a restart.
- `lib/mail-import` gets a Maildir source and a per-file `folder`, so
Thunderbird's `.sbd` tree maps onto the account's folders. The PST worker
reads through a synchronous range XHR when there is no Blob.
- What was imported is remembered by Message-ID and size per folder, because
compacting a folder changes its checkpoint key and Email/import doesn't
dedupe. It runs from the dialog and on a resume.
- The job's record lives in IndexedDB, since Chromium writes localStorage too
late to survive a kill. Windows wait for the job's lock, so one takes over
when another goes.
- The desktop dialog says how long an import takes at the upload limit, warns
when Thunderbird is running, and turns down .ost files with the reason.
The engine used to be able to lose an import while offline: uploadBlob kept
the file on this device under a stand-in id and reported success. It now
passes `noHold`, Email/import is network only, an offline spell pauses without
using up a message's tries, and a device-only client stops at once. The same
fix applies to imports in the browser.
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.
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.
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.
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`.
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.
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.
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.
api.email.getThread fetches only the newest 50 messages of a thread
(Thread/get, then Email/get for those ids) instead of all of them with
their bodies. api.email.getThreadCount answers the number of messages
from Thread/get, with its own rate limit.
plugin-host-thread.test.ts now expects getThreadEmails to be called with
{ newest: 50 }, the new option that limits what is fetched.
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