The desktop app and an installed PWA had a row across the whole window
with the title and the window controls. That row is gone. The rail,
sidebar, list and reader run to the top of the window in their own
colours, and the controls sit in the top row of whatever is rightmost.
- `lib/window-controls-overlay.ts` finds and marks those rows before
paint, and they reserve the controls' width through
`--titlebar-right-inset`
- the rows and the rail are the drag area, everything clickable is
`no-drag`, and an invisible band lets a page with no row still move
- settings and the admin shell get a row only under the overlay, Pro's
tab strip and Mountain View's bar match the controls' height
- Alt floats the menu over the top left, in `window-menu-bar.tsx`
- skeletons carry the same marks, so nothing shifts when the app loads
A plugin's render can be a key operation, and mark-read replacing the
message object ran it twice: a second PIN dialog, and two cancels lock the
key. The effects now depend on what the hook sees and on the explicit
re-render nonce, not on the object. The list row and the full message still
differ, as a row has no headers for a plugin to recognise its mail by.
onRenderEmailBody can return `blockExternalContent: true` (flag
`api.host.features.blockExternalContent`) for a body it can't vouch for.
External content then stays off whatever the settings and trusted senders
allow, in the reader and in conversations, with no way to load it. The S/MIME
plugin uses it against EFAIL.
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 restored composer opened whatever the id named. Sending keeps the id, so its autosave would have destroyed the Sent copy, Send mailed it twice and Discard deleted it. It now needs the login in its address and a real draft, keeps its From with several accounts, and stays on the list if the server doesn't answer. A message from a bulwark:// link no longer marks itself read or sends a receipt. Native menus speak all 30 languages. Spellcheck dictionaries come from Google only after a yes, never in no-third-party mode.
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.
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.
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.
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.
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`.
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.