Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Resume Where You Left Off — Session Restore Plan

Context

The native clients double as feed readers since the RSS reader shipped (1.15.0, rss-implementation-plan.md), and the launch flow they inherited from the mail-only era no longer fits: every cold launch lands on INBOX and offers a “pick up where you left off” toast, whether the user was last reading mail on this device, reading mail on another device, or reading a feed item here. A user who is mid-article, switches apps, and comes back to a relaunched app is dropped into INBOX with a prompt about a mail folder.

This plan replaces that with two layers that already exist in embryo:

On top of both sits a per-item reading-position cache: leaving an item half-read and coming back to it later in the same session (or after a relaunch) reopens it at the same place, for mail and feed items alike.

Why not make the server cursor per-device

set_nav_state replaces a single nav_state attribute on the cabal-user-preferences row, keyed by user only (no range key), and get_nav_state returns that bare object as the response body. A per-device server cursor means a schema change, a read-modify-write race on a map attribute or a new range key, and a breaking read for the shipped Apple and Android builds that decode folder at the top level. The local layer sidesteps all of it, and the server stays RSS-unaware until the cross-device RSS toast (Phase C) needs it, where it lands as additive optional fields.

Decisions

Progress

Phase Work item Status
A Apple: session record, feed-aware launch, position cache Shipped 1.17.0 (2026-09-11); follow-ups (#1555, #1535, element anchors) 1.18.2; restored-reader spinner finally fixed by #1672
B Android: position cache + local session record Shipped 1.19.7 (2026-09-16), PR #1600; device-checked by the user 2026-09-28
C Cross-device RSS toast (server additive fields) In review (2026-09-28): feed cursors, dual-form anchor, probes moved to the signed-in root
D Element anchors on Android (CSP hash-source boundary) Not started; briefed for a separate session (2026-09-13); covers the mail reader too

The background-termination investigation that surfaced this work is tracked separately in #1537 and is independent of every phase here.

Phase A — Apple

Status: Shipped 1.17.0 via PR #1538 (2026-09-11); sticky per-feed reader defaults followed in PR #1542 (same release). Device UAT (2026-09-13) confirmed the launch restore, the sticky defaults, and scroll positions surviving a relaunch, and surfaced three defects, all fixed in the follow-up PR: a root view rebuilt by a compact/regular size-class flip re-landed on the launch-time snapshot instead of the live position (#1555; #1557 separately stopped iPhone rotation from causing the flip at all); a navigate request selected a Folder(path:) stand-in the sidebar highlight never matched (#1535); and the item pushed by the launch restore could sit on its spinner until reopened, because the reader built its model from .task, which an iPhone push cancels (the mail reader’s existing .onAppear workaround now applies). Scroll positions restored “after a lag”, which pointed at the scroll bridge not firing. A web-view test (ReaderScrollBridgeTests, a real WKWebView with page JavaScript disabled) showed the bridge fires within 0.2 s; the actual defect was the anchor probe: elementFromPoint(4, 4) lands in the reader stylesheet’s body padding and resolves to body, so every capture fell back to the f<fraction> form, and a fraction of scrollHeight shifts as images load below the fold. The probe now samples the horizontal centre and a few rows down, so captures name an element and restore to it regardless of reflow.

Kit (CabalmailKit)

App (Cabalmail)

Verification

Phase B — Android

Status: In review (2026-09-15). The Android feed reader shipped in 1.19.0 (RSS plan 6a–6d), and that work landed part of this phase ahead of it; the remainder below is implemented: ResumeSessionStore (a JSON file, launch snapshot frozen against the live record, identity-guarded clears), LaunchDestination (the pure launch ladder) and ForeignCursorPolicy (foreign + newer than the DataStore watermark + not the same place), ReadingPositions generalised from the feed-only cache to Apple’s mail: / feed: key scheme (adopting the old file once) and wired into the mail reader through HtmlBody’s existing hooks, sign-out clears. Not device-tested at the time of writing.

Already in place from the Android reader (RSS plan 6a–6d):

Left for Phase B:

Phase C — Cross-device RSS toast

Status: In review (2026-09-28). Sequenced after the Android feed reader (RSS plan Phase 6, shipped 1.19.0) and this plan’s Phase B, in that order: the toast’s whole value is the cross-platform hand-off, so built earlier it would serve only Mac-to-iPhone and be tested with one client. The RSS plan records the same ordering from its side; the two documents should keep agreeing. Built before Phase D, so an Apple-to-Android hand-off restores to the fraction, not the element; Phase D upgrades that without a contract change.

As built.

Design constraint found on re-read (2026-09-13): the anchor must be dual-form. Apple captures the element anchor (i<path>|<delta>) and since 1.18.2 rarely falls back to a fraction; Android can capture and apply only f<fraction> (JavaScript is off in its body WebView). So a position handed Android-to-Apple restores today (Apple applies the fraction form), but Apple-to-Android would carry an anchor Android cannot apply. Before or as part of Phase C, Apple’s ScrollCapture should carry the fraction alongside the element anchor — it already reads the scroll offset, and the scrollable height is one more property in the same script — and the cursor should carry both (msg_anchor plus a sibling msg_fraction, additive and optional, with the element anchor preferred by a reader that can apply it). The same dual capture belongs in Apple’s local position cache so its own fraction is available for the hand-off. The dual form ships regardless of Phase D below: it costs almost nothing and covers Android builds that predate it.

Phase D — Element anchors on Android (separate session)

Status: Not started (2026-09-13). Decided worth doing: element anchors are superior wherever a document reflows after load, and the cross-device hand-off is the case where the two clients most need to agree. Scoped for its own session, since it is an Android web-view posture change that also covers the mail reader (the feed reader shares HtmlBody), with its own CI gate and device pass.

The constraint: Android’s javaScriptEnabled is one switch for the whole web view. Apple’s allowsContentJavaScript = false disables the page’s scripts while app-injected scripts still run, enforced by WebKit; Android has no such split, so the app’s anchor script cannot run unless the page’s could too. The boundary therefore has to be built and proven by us. Subresource integrity is not the primitive: it verifies fetched scripts against a hash but gates nothing about inline scripts, event-handler attributes, or javascript: links, and require-sri-for is gone from browsers. The SRI-shaped primitive that does gate execution is a Content-Security-Policy hash-source.

Design to build and prove, in this order:

  1. Policy. javaScriptEnabled = true on the body view with a Content-Security-Policy meta first in the generated head: script-src 'sha256-<hash of the anchor script>', no 'unsafe-inline', so inline scripts, on* attributes, javascript: URLs, and every external script are refused; a sender’s own CSP meta can only intersect with ours. A hash rather than a nonce, since the script is static and nothing needs minting per document.
  2. Exemption for the app’s own calls, verified. Chromium’s evaluateJavascript and androidx’s addDocumentStartJavaScript are believed not to be subject to page CSP; a real-WebView test must show it (as ReaderScrollBridgeTests did for the Apple bridge). If they are subject to it, the anchor script goes in the head as the hashed inline script and reports through WebMessageListener, the Android analogue of the script message handler, origin-restricted.
  3. Belt and braces. Strip script elements and on* attributes at render time regardless, so the policy is the second wall. blockNetworkLoads stays as it is: with remote content off, a script that somehow ran has nowhere to send anything; the residual exposure is DOM tricks and CPU, and grows only when the user taps “load remote content”.
  4. The CI gate. A fixture with an inline script, an event-handler attribute, a javascript: link, and an external script; a test that loads it in the reader’s configuration and asserts none executed while the anchor script did and reported. This test is the boundary; without it the policy is a comment. It should run on the mail reader’s configuration too, since that is the larger surface.
  5. Anchor parity. Port the Apple anchor function (plain DOM JavaScript; probes the horizontal centre and a few rows down; emits i<path>|<delta> with f<fraction> as the fallback) and the settle-debounced scroll bridge. FeedReadingPositions then stores whichever form was captured, and restore applies i when present and f otherwise, matching Apple’s restoreScript.

Out of scope for that session: the session record and launch restore (Phase B) and the cursor fields (Phase C). It should land before Phase C so the hand-off is element-level in both directions; it does not block Phase B.

Let the server cursor carry a feed position so a device can offer “pick up this article on your Mac”. Additive, optional fields on set_nav_state (kind, rss_scope, rss_item), with folder required only when kind is absent or mail. Safe for shipped builds: the Apple decoder treats a folder-less cursor as “nothing to restore”, Android’s folder is already nullable. Per-item positions across devices are a separate question; if wanted, the per-item read state rows the feed reader already syncs are the natural home for an anchor.