Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Native Linux Client Plan

Contents

Status tags mirror the Status: line on each work item. An item with no tag has not been started; the Progress table is the phase-level summary.


Context

The React admin app (react/admin/) and the native Apple clients (apple/) currently serve as the Cabalmail clients. Version 1.1.0 adds two native clients alongside the existing pair: the Android client (see android-client-plan.md) and the Linux desktop client described here. The two are independent efforts that share this version slot, a set of guiding principles, and the Lambda API contract — nothing else.

The Linux client mirrors the user-facing portions of the Apple client: mail reading, composition and send, folder management, address creation / revocation / suspension, on-the-fly From addresses, cross-device drafts, search, and synced preferences. Administrative functionality (user management, DMARC reports, multi-user address assignment, domain access grants) is out of scope; admins continue to use the web app.

Scope of “Linux client” for 1.1.0:

Distribution through official distro repositories is explicitly not a 1.1.x goal. This phase produces a client that is continuously built, tested, and packaged in CI, installable from a CI artifact or the AUR.


Approach

Eight phases: workspace scaffolding; build pipeline, packaging, and test harness (early, so every subsequent phase ships through it); configuration / auth / API transport; mail reading; composition and on-the-fly From; address, folder, and settings management; desktop integration and polish; then Debian and RHEL packaging.

Guiding principles

Lessons carried forward

Apple / Android plan said What actually shipped, or what we now know Linux implication
Direct IMAP/SMTP as primary transport ApiBackedImapClient via the Lambda API API-backed from commit one; no IMAP spike
IDLE for foreground push Polling — no IDLE over the API Poll /folder_status; adaptive interval
APNs push with an on-device NSE APNs-only: push_register validates a 64-hex APNs device token, push_dispatch fans out to APNs Push is unavailable to Linux. Poll + desktop notifications; Web Push is a documented seam, not 1.1.0 scope
Local-only drafts Cross-device sync via /save_draft, Markdown-canonical bodies Same — Markdown-canonical compose, server draft sync
Native rich-text widget WKWebView contenteditable + marked/turndown from react/admin/node_modules Reuse the same editor.html + editor-bridge.js + marked/turndown inside WebKitGTK
Prefs stored locally Synced via /get_preferences / /set_preferences under an app map Same wire keys, same server-wins-on-login semantics
Search as an afterthought Cross-folder structured search (/search_envelopes) + filter sheet + All/Unread/Flagged pills First-class scope from Phase 4
No cross-device resume Nav-state cursor (/get_nav_state / /set_nav_state) with a restore prompt Same
Keychain for secrets Data-protection keychain Secret Service (oo7), with an explicit failure mode when no keyring daemon is running
App-layer UI logic tested ad hoc 24 test files in apple/CabalmailTests/ pinning pure policy types (CompactColumnPolicy, ReaderToolbarLayout, MessageRowIdentity, ComposeCancelPolicy, …) Treat that file list as the specification for which UI decisions to extract as pure functions in cabalmail-kit

Stack decisions

Choice Decision Rationale
Language Rust, toolchain pinned to 1.97.1, edition 2024, no MSRV promise One binary, no runtime to package, strong test story. See Toolchain below
UI toolkit GTK4 + libadwaita via gtk4-rs / libadwaita-rs The native Linux desktop default; adaptive layout primitives are the sidebarAdaptable analog
API floor GTK 4.14 / libadwaita 1.4 Ubuntu 24.04 LTS. Rules out AdwBottomSheet, AdwSpinner, and other 1.5+/4.16+ API
HTML rendering WebKitGTK 6.0 (webkit6 crate) Direct WKWebView analog; renders mail bodies and hosts the composer
HTTP reqwest (rustls, no OpenSSL link) + tokio rustls keeps the dependency surface identical across all three distros
JSON serde / serde_json Standard
Auth Hand-rolled Cognito USER_PASSWORD_AUTH Exactly what CognitoAuthService does — JSON POSTs to cognito-idp.<region>.amazonaws.com. No AWS SDK
Secret storage oo7 (pure-Rust Secret Service client) Keychain analog; async, GNOME-blessed, works with gnome-keyring and KWallet’s SS interface
User config Hand-editable TOML at $XDG_CONFIG_HOME/cabalmail/config.toml Linux users expect to edit a text file; see Configuration model below
Local state directories for XDG base dirs + JSON files Mirrors the Apple on-disk Codable mirrors; no database, no dconf
UI definition Blueprint (.blp) compiled to .ui Roughly a third the line count of equivalent XML, and far better diffs. Packaged in Arch, Debian, and Fedora, so the build dependency is available in every packaging container
Async bridging One tokio multi-thread runtime + async-channel into glib::spawn_future_local See below — this is the one genuinely hard structural problem
Testing cargo test + wiremock (HTTP contract) + xvfb for widget tests Kit tests need no display; app tests need a headless one
Linting rustfmt + clippy -D warnings, plus namcap / lintian / rpmlint on packages Mirrors swiftlint’s role in apple.yml
Task automation cargo xtask Gives AI-executed phases one deterministic entry point per operation

Toolchain: pinned 1.97.1, edition 2024, no MSRV promise

An earlier draft pinned MSRV to Debian stable’s rustc, on the theory that it kept distro-native packaging open. That reasoning does not survive contact with what the distros actually ship:

Target rustc Consequence
Arch 1.97.1 Never binding — always current
Debian 13 trixie 1.85.0 Exactly the edition-2024 threshold
Ubuntu 24.04 LTS 1.75 unversioned; rustc-1.76…1.80 also in archive, rotating Would be the binding floor, and it is not even a stable floor
Fedora / RHEL 10 current Not binding

Holding to 1.75 would cost the entire 1.76–1.97 language window — including let-chains, which config parsing and MIME walking both want — in exchange for a distro-native .deb. And a distro-native .deb is not actually a step toward the Debian archive: Debian’s Rust policy requires every dependency crate to be packaged separately as its own .deb, which dwarfs any MSRV consideration. Official-archive inclusion is out of scope for 1.1.0 and would be its own project regardless.

So: pin an exact toolchain, build the packages with it, ship the binary.

Contributors need rustup, not the distro rust package — the two conflict on Arch, and only rustup honours rust-toolchain.toml. linux/README.md says so explicitly.

Async bridging: the one structural risk

GTK owns the main thread and its own glib::MainContext event loop; reqwest wants a tokio reactor. Getting this wrong produces an app that deadlocks under load or blocks the UI on every request, and it is very hard to retrofit. The pattern is fixed in Phase 1 and every later phase follows it:

cabalmail-gtk gets a single spawn_to_ui! helper wrapping this dance, written and tested in Phase 1 before any feature code exists.

Configuration model

The Apple client has no user-editable configuration: settings are server-synced and everything else is UserDefaults. Porting that model directly would give Linux users nothing to edit, which is wrong for the platform — a hand-editable file under $XDG_CONFIG_HOME is a baseline expectation, and “I can grep and diff my config” is a real reason people choose a native client over the web app.

But a hand-edited file has to coexist with server-synced preferences. If a user sets dispose_action = "trash" in a file and the next login pulls "archive" from the server, their edit is silently reverted — worse than having no file at all. The resolution is explicit precedence plus treating the file as a peer editor that participates in sync, rather than as an override layer that suppresses it.

Precedence, highest wins

  1. Command-line flags
  2. Environment variables (CABALMAIL_*, e.g. CABALMAIL_LOG_LEVEL)
  3. $XDG_CONFIG_HOME/cabalmail/config.toml (default ~/.config/cabalmail/config.toml)
  4. Each entry of $XDG_CONFIG_DIRS in order (default /etc/xdg/cabalmail/config.toml) — gives distro and site administrators a defaults drop point
  5. Server-synced preferences (/get_preferences)
  6. Built-in defaults

Note the spec variables are XDG_CONFIG_HOME (a single directory) and XDG_CONFIG_DIRS (a colon-separated system list). Use the directories crate for the former and read the latter directly; do not hand-roll $HOME/.config.

Flags and environment variables are transient local overrides: they apply for the run, are never pushed to the server, and are never written into the file. Everything else participates in propagation, described next.

The file is a peer editor, not an override layer

Preference sync is preserved in full. The config file, the Settings UI, and the server are three writers to one logical value, and an edit through any of them propagates to the other two:

config.toml ──(inotify)──┐
                         ├──> local preference store ──(debounced push)──> server
Settings UI ─────────────┘             ▲                                     │
                                       └───(pull on login / focus)───────────┘
                                                    └──> rewrite config.toml

The file is not a pin and does not suppress sync. It is a second input device onto the same store the UI writes to, and a materialized view of that store’s synced section. Editing dispose_action in $EDITOR behaves exactly as if you had changed it in Settings: it lands in the store and pushes on the same debounce. A server pull that changes a value rewrites the file.

This is what “preserve sync across devices” requires, and it replaces the sticky-override model an earlier draft of this plan proposed. Two properties make it safe:

Three sync scopes

Sync scope is a property of each key, and there are three of them — not two. Collapsing platform-specific settings into “machine-local” would be wrong, because they should still follow the user between devices of the same platform: Siri behaviour belongs on both the iPhone and the iPad, and tray behaviour belongs on both of a user’s Linux machines.

Scope Syncs to Test for membership Examples
Universal Every device, every platform The concept exists everywhere and should hold one value dispose_action, signature, mark_as_read, theme, default_from_address
Platform-scoped Devices of the same platform only The concept is meaningless on other platforms Apple: Siri / App Intents, crash_reporting_enabled (MetricKit). Web: accent, density. Linux: start_minimized, notification_actions, close_to_tray (see caveat under Desktop compatibility)
Machine-local Nothing — stays on the device Tied to this box, not to the user control_domain, log_level, cache_max_mb, cache paths, proxy, keyring backend

The distinguishing question for scope two is “does this concept exist on the other platform?” — not “might the user want a different value there?” A key that exists everywhere but that someone would like to vary per machine is still universal; wanting per-machine variation is not the same as the setting being platform-specific, and conflating them is how a preference system ends up with an unsyncable everything.

Two of the three scopes already exist in the wild, unlabelled: crash_reporting_enabled is Apple-only and Linux ignores it, while accent and density are the React app’s. This plan names the pattern rather than inventing it.

The file makes all three visible:

# Synced across all your devices. Changing these here, in Settings,
# or on another device produces the same result.
[preferences]
dispose_action = "trash"
mark_as_read   = "on_open"
signature      = "-- \nsent from a machine I control"

# Synced to your other Linux machines only. Meaningless on iOS or the web.
[preferences.linux]
start_minimized      = false
notification_actions = ["open", "mark_read", "archive"]

# This machine only. Never leaves this device.
[local]
control_domain = "stage.cabalmail.example"
log_level      = "debug"
cache_max_mb   = 500

A key in the wrong section is a hard parse error naming the section it belongs in, not a silent no-op.

What the backend actually permits

lambda/api/set_preferences/function.py constrains this design in three ways that must be respected rather than discovered later:

  1. The synced key set is closed. APP_ALLOWED enumerates exactly mark_as_read, load_remote_content, dispose_action, theme, default_body_render_mode, folder_count_display, crash_reporting_enabled, plus default_from_address and signature, with name at the top level. Unknown keys are rejected with a 400 by design (“so a client typo surfaces as a 400”). The client validates the [preferences] section against this set locally and reports the offending key with its file position — never by forwarding a 400.
  2. Per-key merge already exists, at both levels (app.#k = :v). Concurrent multi-device edits are last-write-wins per key, and a client that omits a key cannot clobber it. This is why the Linux client simply never sends crash_reporting_enabled and the iOS setting survives untouched.
  3. Nested per-platform maps are not expressible. The obvious encoding for platform scope — app: {linux: {close_to_tray: "1"}} — cannot work: _validate_app tests val not in APP_ALLOWED[key] against a set, and a dict value raises TypeError: unhashable type. That escapes the handler’s except ValueError, so the request fails as an unhandled 502 rather than a clean 400. (Worth fixing independently of this plan — a client bug should not be able to 502 a Lambda. Small and unrelated to Linux; raise it separately.)

    Platform-scoped sync therefore needs one of:

    • Prefixed scalar keys in the existing map — linux_close_to_tray: {'0','1'} added to APP_ALLOWED. A two-line server change per key, no structural work, and per-key merge means other platforms never touch them. The shared map accumulates prefixed keys, which a naming convention keeps legible.
    • A sibling platform map with its own validator keyed by client platform. Cleaner separation, more server work, and it needs a platform discriminator the API does not currently carry.
    • Keep them machine-local for 1.1.0 — no server change, but a user’s second Linux machine does not inherit them.

    Decided: prefixed scalar keys — the smallest change consistent with “preserve sync wherever possible”. It remains the only part of this plan that touches shared server code, so the APP_ALLOWED additions ship as their own PR against lambda/api/, reviewed on its own merits, before the Phase 6 work that depends on them.

  4. There is no delete and no timestamp. The row carries no updated_at or version attribute, and the Lambda only SETs. Two consequences:
    • Deleting a line does not reset a preference. A missing key means “no local opinion”; the store keeps its value and the client re-adds the line on the next rewrite. Resetting is cabalmail config reset <key>, which writes the default explicitly and pushes it.
    • Offline file edits can lose a race. Edit config.toml on a disconnected laptop while another device changes the same key, and the next foreground pull wins — the client cannot tell which edit is newer, because nothing records when either happened. Bounded and rare, but real; it is stated in cabalmail.5 rather than papered over. Fixing it properly means adding per-key updated_at to the preferences row, which is a server change and out of scope here (open question 6).

Note also that theme exists twice — flat (light/dark, owned by the React app) and inside app (system/light/dark, owned by the Apple clients). Linux uses the app one, matching Apple. default_from_address and signature inherit the Lambda’s validators (100 and 2000 characters, no control characters bar \n/\t in the signature); enforce those client-side so the user gets a position-anchored error instead of a rejected push.

What belongs where

Layer Contents Rationale
config.toml [preferences] The nine universal keys, plus display name Bidirectional: file ↔ UI ↔ server, every platform
config.toml [preferences.linux] start_minimized, notification_actions, close_to_tray, single-instance behaviour Bidirectional, but only between this user’s Linux machines
config.toml [local] Control domain and account; cache size caps and directory overrides; poll intervals; log level; proxy; keyring backend and session_only; WebKit toggles Per-machine. Syncing these would be actively wrong
$XDG_STATE_HOME Window geometry, pane positions, expanded-folder set, last-selected folder, logs State, not configuration — nobody hand-edits a window size
$XDG_DATA_HOME Drafts, outbox User data; must survive a cache wipe
$XDG_CACHE_HOME Envelope cache, message bodies, the fetched deployment descriptor Safe to delete at any time
$XDG_RUNTIME_DIR Single-instance lock / activation socket Per-session, tmpfs
Secret Service Cognito tokens Never a file

Consequences

Desktop compatibility

GNOME is the reference desktop, but the client must be a good citizen on KDE, Xfce, and tiling WMs, under both Wayland and X11. What that actually requires:

Concern Status Notes
X11 and Wayland Both GTK4 ships both GDK backends; nothing in this plan is Wayland-only
KDE / Xfce / tiling WMs Runs everywhere Nothing here assumes a GNOME shell at runtime
Visual fit outside GNOME Accepted cost libadwaita hard-codes the Adwaita stylesheet and deliberately stopped following the system GTK theme in 1.x. On Breeze it looks like a GNOME app. There is no supported way around this short of abandoning libadwaita
Notifications Everywhere org.freedesktop.Notifications — dunst, mako, Plasma, gnome-shell, actions included
File dialogs Everywhere Portal-backed; xdg-desktop-portal-gtk and -kde both exist
Secret Service Most desktops gnome-keyring, KWallet, and KeePassXC all provide it. Bare WMs frequently provide nothing — the actionable failure path is specified in Phase 3
Settings persistence Everywhere Plain files under XDG base dirs. Deliberately not dconf, which needs a D-Bus session bus and silently degrades to an in-memory backend without one
Tray icon Not on stock GNOME GTK4 removed GtkStatusIcon; there is no tray API. Needs StatusNotifierItem via a non-GTK crate (ksni). Works natively on KDE, Xfce, and tiling setups with waybar; stock GNOME needs the AppIndicator extension
Launcher unread badge Partial The Unity LauncherEntry D-Bus interface is honoured by KDE and most docks, but not by stock GNOME Shell
Server-side decorations Not on GNOME/Wayland Mutter deliberately does not implement xdg-decoration, so a decorations preference cannot be honoured there. Dropped from scope; the app is CSD throughout

The last three are why close_to_tray carries a caveat and why there is no decorations key. The rule for any facility in this class: detect the capability at runtime, hide the control when it is absent, and keep the stored preference — so a user who moves between a KDE desktop and a GNOME laptop does not lose the setting on the machine that can honour it.

Distro support matrix

Distro GTK4 libadwaita webkitgtk-6.0 Status
Arch / CachyOS 4.22 1.9 2.52 (extra) Phase 2 target. Development platform
Ubuntu 24.04 LTS 4.14 1.4 libwebkitgtk-6.0-4 Phase 8 target. Defines the API floor
Debian 13 (trixie) ≥ 4.14 ≥ 1.4 packaged Phase 8, same source package as Ubuntu
Fedora (current) current current packaged Phase 8, the .rpm reference build
RHEL 10 / CentOS Stream 10 4.x present expected Phase 8, gated on the CI build proving it
RHEL 9 4.6 — absent Not supported. See below

RHEL 9 ships the GTK3-era webkit2gtk3 / webkit2gtk4.0 packages, not the GTK4 webkitgtk-6.0 introduced in WebKitGTK 2.40 (Repology, WebKitGTK API versions). A GTK4 + WebKit6 application therefore cannot be packaged for RHEL 9 from distro dependencies. “RHEL support” in this plan means RHEL 10 and Fedora. If RHEL 9 becomes a hard requirement, the only realistic answer is a Flatpak carrying its own runtime — a scope change, not a packaging tweak, and one this plan deliberately does not absorb.

Repository layout

A new top-level directory, sibling to apple/, android/, and react/admin:

linux/
  Cargo.toml                       # workspace root
  Cargo.lock                       # committed — packaging needs reproducible deps
  rust-toolchain.toml              # pinned toolchain
  clippy.toml  rustfmt.toml
  README.md

  cabalmail-kit/                   # spiritual sibling of CabalmailKit — NO gtk/webkit deps
    Cargo.toml
    src/
      lib.rs
      config/                      # layered user config (TOML) + deployment descriptor fetch
      auth/                        # Cognito USER_PASSWORD_AUTH, MFA, token refresh
      secret/                      # Secret Service (oo7) + trait for test doubles
      api/                         # ApiClient trait + reqwest impl, one module per group
      models/                      # Envelope, Message, Address, Folder, FolderTree, Flags…
      mime/                        # header decode, MIME parse, attachment plan
      cache/                       # envelope + body caches, UIDVALIDITY keyed, LRU bodies
      compose/                     # Draft, ReplyBuilder, SignatureFormatter, MailtoUrl
      outbox/                      # on-disk send queue
      policy/                      # pure UI-decision types (see Phase 2 note)
      prefs/                       # Preferences + server sync
    tests/
      fixtures/                    # golden JSON per endpoint
      *.rs

  cabalmail-gtk/                   # the application
    Cargo.toml
    build.rs                       # gresource + blueprint compilation
    src/
      main.rs  application.rs  runtime.rs   # spawn_to_ui! lives here
      ui/{auth,mail,compose,addresses,folders,settings}/
    resources/
      *.blp                        # Blueprint UI definitions
      icons/                       # symbolic icons
      editor/                      # editor.html + editor-bridge.js (first-party)
                                   #   marked.umd.js + turndown.js are gitignored,
                                   #   materialized by scripts/sync-vendored.sh
      reader/                      # reader.css, reader-shim.js
    data/
      com.cabalmail.Cabalmail.desktop.in
      com.cabalmail.Cabalmail.metainfo.xml.in
      config.example.toml          # commented reference, installed to /usr/share/doc
      cabalmail.5.md               # man page source; keys generated from the parser table

  xtask/                           # cargo xtask: sync-vendored, package, smoke, fixtures
  scripts/
    sync-vendored.sh               # mirrors apple/scripts/sync-vendored.sh
  packaging/
    arch/PKGBUILD  arch/.SRCINFO
    debian/                        # Phase 8
    rpm/cabalmail.spec             # Phase 8

cabalmail-kit is a library crate with no binary and no GUI dependency. Everything that can be decided without a widget lives there — including the pure policy types described in Phase 2. This is what makes the client testable.

Working agreement for AI-executed phases

Each numbered work item below is sized to one branch and one PR. Executors must observe:

  1. One work item per PR. Do not batch items across phase boundaries.
  2. Every PR is green on cargo xtask ci locally before push. That target runs fmt, clippy, kit tests, and app tests in the same order CI does.
  3. Every PR adds tests in the same commit as the code. The kit’s coverage floor is enforced from Phase 2; a PR that lowers it fails.
  4. Contract changes are fixture changes. Any change to how the client reads a Lambda response updates the golden fixture under cabalmail-kit/tests/fixtures/ in the same PR.
  5. Changelog fragment required — changelog.d/<slug>.<category>.md, per changelog.d/README.md. See the Open Questions on whether a Linux: prefix is adopted.
  6. Ship through stage. Down-merge origin/stage, resolve conflicts, push, open the PR against stage.
  7. Never widen scope silently. If a work item turns out to need something the plan didn’t anticipate, say so in the PR description rather than absorbing it.

Progress

Where the work stands, so it does not have to be reverse-engineered from git. Every work item below carries a Status: line once it lands; this table is the summary. A phase with no entry has not been started.

Phase Status
1. Workspace scaffolding Complete (2026-08-08). All five items shipped; cargo run -p cabalmail-gtk opens a window.
2. Build pipeline, packaging & test harness In progress. Items 1, 2, 4, and 6 shipped, and item 5’s artifact upload with them. Item 3 shipped layer (c) whole, and the enforcement halves of (a) and (d) - the coverage floor and the smoke job. What those two layers test is written with the code they test, so most of (a) and (d) arrives with Phases 3 to 5. Layer (b) moved to Phase 3, where the API client its fixtures decode into lands. Item 5’s AUR publication is the one thing still blocked.
3. Configuration, authentication & API client In progress. Item 1 is partial: the kit side is written, nothing in the app calls it yet, and its first-launch prompt for a control domain moved to item 6, the sign-in window, added 2026-09-22.
4-8 Not started.

Paused (2026-08-24): AUR publication. The AUR is closed to new account registrations, so the Arch package has no route to publication. Item 4’s work is written, tested, and committed on the branch claude/linux-phase-2-arch-packaging, which is not pushed and has no PR open - deliberately, until the AUR question resolves. Item 5’s AUR step was already a manual, human-gated act; it is now blocked outright. Nothing else in Phase 2 depends on either, so items 3 and 6 can proceed.

Erratum (2026-09-02): Item 4 merged - 5973542b is on stage, linux/packaging/arch/PKGBUILD is in the tree, and package-arch runs on every push. The paragraph above is the record of a hold that has since lifted for the packaging itself; what remains blocked is item 5’s AUR upload, which is the only step that needs an AUR account.


Phase 1: Workspace Scaffolding

Goal: a workspace that builds an empty window, with the async bridge and the crate split already correct.

1. Cargo workspace

Status: Shipped 2026-08-07 (74f88e52).

2. cabalmail-kit skeleton

Status: Shipped 2026-08-07 (d598f7a6).

3. Layered configuration

Status: Shipped 2026-08-07 (f7b480bc), with follow-ups in f64ed883 and e414f2d5.

Land the configuration store and schema here, before anything reads a setting — retrofitting precedence and provenance after six phases of direct reads is the predictable disaster.

The propagation machinery itself (inotify watching, debounce, push/pull wiring) lands in Phase 6 alongside the Settings UI and the server sync it coordinates with. Phase 1 builds the store, the schema, and the file I/O it depends on — deliberately, so the store is well-tested before three writers are pointed at it.

4. cabalmail-gtk shell

Status: Shipped 2026-08-08 (9437d213).

5. xtask

Status: Shipped 2026-08-08 (ed7c8d7f). package arch landed with Phase 2 item 4 and smoke with Phase 2 item 3; fixtures is still declared-only.

Subcommands, each a thin wrapper so both humans and CI have one spelling:

cargo xtask sync-vendored     # marked + turndown from react/admin/node_modules
cargo xtask ci                # fmt --check, clippy -D warnings, kit tests, app tests
cargo xtask package arch      # build PKGBUILD in a container, run namcap
cargo xtask smoke             # install the built package, launch headless, assert
cargo xtask fixtures          # regenerate golden fixtures from a live stage deployment

Phase 1 verification


Phase 2: Build Pipeline, Packaging & Test Harness

Goal: everything after this phase is developed under green CI and produces an installable Arch package on every push. Nothing user-facing ships in this phase.

1. Workflow layout

Status: Shipped 2026-08-15 (8999dd4a, c31e6031), with follow-ups in d8a25d26 and 89ff60f3, and smoke and coverage added with item 3 on 2026-09-12. All nine jobs exist - the five planned here plus workspace-checks, which runs the xtask drift tests, supply-chain from item 6, and the two the layout above named.

New .github/workflows/linux.yml, modelled on apple.yml: triggered by workflow_dispatch and by pushes to main / stage under linux/** and .github/workflows/linux.yml. Like apple.yml, it deploys nothing to AWS.

jobs:
  lint:            # ubuntu-latest — rustfmt --check, clippy -D warnings
  kit-test:        # ubuntu-latest — cargo test -p cabalmail-kit (no GTK, no display)
  app-build:       # container: ubuntu:24.04 — API floor build, gtk/adw/webkit dev pkgs
  app-test:        # container: ubuntu:24.04 — xvfb-run cargo test -p cabalmail-gtk
  package-arch:    # container: archlinux:base-devel — makepkg + namcap
  smoke:           # needs: package-arch — install, launch under xvfb, assert startup
  coverage:        # cargo-llvm-cov on the kit; fails under the floor

app-build runs against Ubuntu 24.04, not Arch. Building the API floor on every push is the entire point — an Arch-only CI would let GTK 4.16+ API land silently and surface it as a Phase 8 packaging failure months later.

2. Toolchain pinning

Status: Shipped. The pinned toolchain action, the Ubuntu dependency list, and the Arch one all exist; cargo-deny arrived with item 6 and cargo-llvm-cov with item 3’s coverage job on 2026-09-12, both pinned and installed from prebuilt binaries. lint.yml installs both, since its one job runs the whole gate; linux.yml installs each in the job that runs its step. xtask/tests/workflow_contract.rs fails if either pin stops agreeing across the two workflows, xtask/src/ci.rs, and the README.

3. Test harness

Status: Partial, 2026-09-12. What shipped is the harness - the machinery each layer needs - not the tests each layer will eventually hold, since most of those are written with the code they cover.

This is the substance of the phase. Four layers:

(a) Kit unit tests — cargo test -p cabalmail-kit. Pure logic: MIME parsing, header decoding, reply construction, signature formatting, folder-tree assembly, cache eviction, preference reconciliation. No network, no display, no filesystem beyond tempfile. This is where the coverage floor is enforced and where the bulk of the suite lives, mirroring the 49-file CabalmailKitTests suite.

(b) HTTP contract tests — wiremock + golden fixtures. Every endpoint the client calls gets a fixture under cabalmail-kit/tests/fixtures/<endpoint>/<case>.json and a test that serves it through wiremock and asserts the decoded Rust type. Fixtures are captured from a real stage deployment by cargo xtask fixtures and committed. Two properties this buys:

Redact tokens and real addresses on capture; cargo xtask fixtures enforces this and fails on anything matching a JWT or a live mail domain.

Erratum (2026-09-12): Layer (b) cannot be written in this phase. There is no API client, no models, and no reqwest dependency until Phase 3, so there is nothing for a captured fixture to decode into and nothing for wiremock to serve a request from - a corpus written now would assert that some JSON round-trips through serde_json, which is a test of serde_json. It moves to Phase 3, work item 4, alongside the client it pins, which is what cargo xtask fixtures now names when it is asked for. The reasoning above stands unchanged; only the phase it lands in moves. This is the same shape as item 6’s third bullet, and for the same reason: a check that cannot be made to fail is not a check.

(c) Pure policy types. apple/CabalmailTests/ is 24 files of tests over pure types that encode UI decisions without owning widgets. That file list is the specification for this layer. Port the equivalents into cabalmail-kit/src/policy/ as plain functions and test them in layer (a):

Apple type Linux equivalent Decision it encodes
CompactColumnPolicy policy::layout When the split view collapses
ReaderToolbarLayout / ReaderHeaderHeightPolicy policy::reader Which toolbar actions appear, header sizing
MessageRowIdentity policy::row_identity Stable list identity across refresh
ComposeCancelPolicy / ComposeCancelChoice policy::compose_cancel Save / discard / keep-editing on close
DisposeIntent / RowDisposal policy::dispose Archive vs Trash vs Restore per folder
HTMLRewrite policy::html_rewrite Remote-content and link rewriting before render
MessageListPillCount policy::pills All / Unread / Flagged counts
SearchSourceFolderIndex policy::search_source Which folder a cross-folder hit came from

Anything in this table that ends up needing a gtk::Widget has been modelled wrong.

Erratum (2026-09-12): As shipped, policy::reader is not a transliteration of ReaderToolbarLayout. Half of that type is arithmetic about which iOS SDK folds a .bottomBar at what width and how AppKit demotes a crowded toolbar’s trailing items, and libadwaita does neither - an AdwHeaderBar draws exactly what it is packed with. What ported is the idea of a budget and a demotion order, and the header-height cap verbatim; the platform arithmetic did not, and neither did Apple’s actual order - macToolbar’s is arranged for AppKit to do the demoting. The Linux module declares its own order in all(), and takes both the bar and the menu from it, so the order is enforced rather than described.

Two rows of the table name a pair of Apple types, and both halves ported: the dialog’s button set and which of them a dismissal resolves to (ComposeCancelChoice), and the two-leg row-disposal animation and its durations (RowDisposal). The first is the one with a hazard attached — SwiftUI dropped the cancel-role button in a popover, AdwAlertDialog defaults its close response to the first one added, and both roads lead to a stray click discarding a draft.

Two defects were carried by HTMLRewrite and are not reproduced here, since a port is where they can be fixed: substituting each cid: reference in turn corrupts a Content-ID that is a prefix of another (logo, logo2), and matching <head by prefix injects the head defaults into the middle of a fragment that opens with <header>. The Linux version scans once and matches whole tags; both have tests.

Every function in these modules takes the fields it decides on rather than a model type, which is what let the layer land ahead of Phase 3’s models. It is also the property worth keeping: a policy that needed an Envelope would be one no test could exercise without building one.

(d) Widget and smoke tests. Thin by design. cargo test -p cabalmail-gtk under xvfb-run covers construction of each top-level view against a fake ApiClient, and the spawn_to_ui! bridge. The smoke job goes further: installs the built package into a clean container, launches it with --self-test against a wiremock instance, and asserts the process reaches a signed-out main window and exits 0. That flag is a real code path added in this phase — it is the only end-to-end assertion that the packaged artifact works, as opposed to the source tree.

Erratum (2026-09-12): The wiremock half of that sentence went with layer (b). --self-test as shipped starts the application, waits for the window to become visible, prints a marker, and quits; there is no client to point at a stub server yet. The marker is a constant in cabalmail-kit that both the app and cargo xtask smoke read, so the printer and the grep cannot drift. Pointing the self-test at a wiremock instance follows the API client into Phase 3.

4. Arch packaging

Status: Shipped 2026-08-24 (5973542b). Publication to the AUR is still paused - see the note under Progress - but the packaging itself is on stage. cargo xtask package arch stages the PKGBUILD against the working tree, builds it with makepkg, and lints both with namcap; a package-arch job runs it in an archlinux:base-devel container.

packaging/arch/PKGBUILD:

Erratum (2026-08-24): Two makedepends are missing from that list, and both are hard build failures. Arch splits glib-compile-resources out of glib2 into glib2-devel, which cabalmail-gtk/build.rs needs to bundle the GResource; and rendering cabalmail.5.md to roff needs a converter — go-md2man, chosen over pandoc, which is the same conversion behind a gigabyte of Haskell and is packaged everywhere this client targets. As shipped, the arrays are not written in the PKGBUILD alone: they come from packaging/deps/arch.txt (the per-distro list install-linux-deps.sh already reads), and xtask/tests/packaging_contract.rs fails if the two disagree. webkitgtk-6.0 joins depends with the composer in Phase 5.

  • Installs config.example.toml to /usr/share/doc/cabalmail/ and cabalmail.5 to the man path. It does not install anything into /etc/xdg/cabalmail/ — that path is reserved for the administrator, and shipping a file there would make “no system config present” indistinguishable from “administrator chose these defaults”.
  • build() runs cargo build --release --frozen --offline against vendored crates.
  • check() runs cargo test -p cabalmail-kit — kit tests need no display, so they run inside makepkg unmodified.
  • namcap clean on both the PKGBUILD and the built package; CI fails on any namcap error.

The vendored-JS problem. editor.html needs marked.umd.js and turndown.js. The Apple client materializes them from react/admin/node_modules/ at build time, which is right for a developer checkout but wrong for makepkg — prepare() must not reach the network, and requiring nodejs/npm as a makedepend to run npm ci breaks that rule. The resolution:

Erratum (2026-08-24): “prepare() must not reach the network” is too strong as stated, and the shipped PKGBUILD does not obey it: prepare() runs cargo fetch --locked, which is the standard Arch spelling for a Rust package, because crates cannot be expressed as source=() entries without committing a vendor tree. Everything after that is offline — build() and check() pass --frozen, which fails rather than resolving. The conclusion the rule was reached for is unaffected: npm is not a makedepend, and the npm tarballs are pinned, checksummed sources.

5. Release artifacts

Status: Partial, 2026-09-12. package-arch uploads the .pkg.tar.zst and the .SRCINFO that cargo xtask package arch now generates beside it; the smoke job downloads that artifact rather than building its own. The .SRCINFO is generated from packaging/arch/PKGBUILD rather than from the staged copy this run built - the staged copy points its git source at the local checkout, which would publish metadata naming a path on whatever machine ran the build - and a check refuses to write one carrying a file:// source. Publication to the AUR remains blocked - see the note under Progress - and stays a manual step whenever it unblocks.

The package-arch job uploads the .pkg.tar.zst and its .SRCINFO as workflow artifacts on every run. Publishing to the AUR is a manual, human-gated step — not automated in this phase, and not something an AI-executed work item should perform.

6. Guard rails

Status: Shipped 2026-09-02. cargo deny check runs as the supply-chain step in its own job; the kit’s freedom from GUI dependencies is asserted transitively by xtask/tests/kit_purity.rs, under the existing workspace-checks step. The reader WebView grep moved to Phase 4 — see below.

Erratum (2026-09-02): The third bullet cannot be written in this phase. There is no reader, no WebKitWebView, and no webkit6 dependency until Phase 4, so a grep written now matches nothing and passes for reasons unrelated to what it guards — a check that cannot be made to fail is not a guard. It moves to Phase 4, work item 3, alongside the code it protects. The CLAUDE.md prohibition is written in this phase, since a rule can precede its enforcement.

Phase 2 verification

Erratum (2026-09-12): Three of these five are met, one cannot be met from a branch, and one moves to Phase 3.

  • The first cannot be met here at all: linux.yml fires only on a push to main or stage, so package-arch and the new smoke job have never run as jobs. It is met, or not, on the promotion that merges them.
  • The second and third are met, by hand rather than by CI: cargo xtask package arch built a namcap-clean package on an Arch workstation, and cargo xtask smoke installed that package into a clean archlinux:base-devel container and got self-test reached a main window out of the installed binary under Xvfb. The container half is what the smoke job automates.
  • The fourth is met, and was when the floor landed.
  • The fifth goes to Phase 3 with layer (b).

Running it that way is also what found the last defect in it: smoke wrote its throwaway HOME under target/, which fails outright against a read-only checkout — the ordinary way to hand a container a source tree. It uses the system temp directory now.

What was proved by breaking it once, rather than by watching it pass: the self-test reports failure when the window is not presented, the coverage step exits non-zero when the floor is raised above the measured figure, and the two new drift tests fail when the smoke job’s command is removed or the package artifact is renamed at one end.


Phase 3: Configuration, Authentication & API Client

1. Runtime configuration

Status: Partial, 2026-09-23 (d562cdc3, 4e3992a8, and the review fixes after them). The kit side is written; nothing in the app calls it yet, and the first-launch half moved to item 6.

Two distinct things are called “config” in this codebase, and conflating them will cause bugs. Keep the names apart in code and docs:

  Deployment descriptor User config
Source https://{control_domain}/config.json, written by Terraform The user’s text editor
Owner The deployment The user
Path $XDG_CACHE_HOME/cabalmail/deployment.json (cached) $XDG_CONFIG_HOME/cabalmail/config.toml
Editable No — refetched and overwritten Yes, that’s the point
Type Deployment Settings

config/: fetch the descriptor, decode into a Deployment struct mirroring CabalmailKit.Configuration — control_domain, domains[], invokeUrl, cognitoConfig. Derive imap_host with the same rule the React app and Apple client use: a dev. prefix is replaced by imap., otherwise imap. is prepended.

The cached copy is deliberately in $XDG_CACHE_HOME — deleting it must be harmless, and a stale descriptor after a deployment change is exactly the bug a cache-clear should fix.

The control domain comes from Settings.control_domain (so it can be set in config.toml, which is how someone pins a workstation to stage) or is entered on first launch. One binary works against dev / stage / prod either way.

2. Authentication

auth/: hand-rolled USER_PASSWORD_AUTH against https://cognito-idp.<region>.amazonaws.com/, mirroring CognitoAuthService. Required surface:

3. Secret storage

secret/: a SecretStore trait with an oo7 Secret Service implementation and an in-memory double for tests. Tokens stored as one JSON blob under a label keyed to (control_domain, username), so two accounts never collide — the account-scoping property Preferences.activate provides on Apple.

Failure mode to handle explicitly: many minimal WM setups run no Secret Service provider. Detect this at sign-in, and present an actionable message (“No keyring service found — install gnome-keyring or KWallet, or run with --session-only to keep credentials in memory for this session”) rather than a generic error. Silently falling back to a plaintext file is not acceptable.

4. API client

api/: an ApiClient trait plus a reqwest implementation, grouped as the Apple client groups them. All calls carry the Cognito ID token as Authorization. Folder paths use / on the wire and are normalized server-side.

Group Endpoints
Addresses /list, /new, /revoke, /suspend_address, /reinstate_address, /set_favorite, /list_my_domains
Folders /list_folders, /new_folder, /delete_folder, /subscribe_folder, /unsubscribe_folder, /folder_status
Messages /list_messages, /list_envelopes, /search_envelopes, /fetch_message, /list_attachments, /fetch_attachment, /fetch_inline_image
Operations /set_flag, /move_messages, /purge_messages, /empty_trash
Compose /send, /save_draft, /upload_url
State /get_preferences, /set_preferences, /get_nav_state, /set_nav_state
Other /fetch_bimi

/push_register, /push_deregister, and /push_envelope are not implemented — they are APNs-specific (see Phase 7).

Phase 2’s test harness moved its HTTP contract layer here, by the erratum under that work item: every endpoint above gets a golden fixture under cabalmail-kit/tests/fixtures/<endpoint>/<case>.json, served through wiremock and asserted against the decoded Rust type, captured and redacted by cargo xtask fixtures. The client and the fixtures land together because neither is testable without the other. cargo xtask fixtures names this work item today.

5. Models and caching

models/: Envelope (including the threading identity — message_id, in_reply_to, references), Message, Address, Folder, FolderTree, Flags, AuthResults, SearchQuery / SearchResult.

cache/: per-folder envelope cache keyed by UIDVALIDITY under $XDG_CACHE_HOME/cabalmail/envelopes/; raw .eml bodies under bodies/, LRU-evicted by mtime past a configurable cap (default 200 MB, matching Apple).

mime/: RFC 2047 header decoding, multipart walking, attachment descriptor extraction, inline-image cid: resolution. Fetch full bodies and parse client-side — there is no fetchPart over the API.

6. Sign-in window

Added 2026-09-22. Items 2 and 3 are the kit’s Cognito and keyring surface, and Phase 4 starts at the signed-in shell; nothing owned the screen between them, including item 1’s first-launch prompt for a control domain.

The signed-out state of the main window: an AdwNavigationView of pages, every call through spawn_to_ui!, and every error rendered from CabalmailError’s Display, never its debug form.

Phase 3 verification


Phase 4: Mail Reading

1. Shell and folder sidebar

AdwNavigationSplitView three-pane: folder list, message list, reader. Collapses per policy::layout at narrow widths. Folder tree from /list_folders with unread badges from /folder_status; expand/collapse state persisted locally.

2. Message list

GtkListView backed by a gio::ListStore — virtualized, which the plan assumes rather than retrofits. Positional pagination via /list_messages?offset=&limit=, then /list_envelopes for the visible window.

3. Reader

Body render in a WebKitWebView, hardened:

Attachments strip with presigned-URL download through /fetch_attachment, saved via GtkFileDialog.

First-class scope, not a filter: /search_envelopes across one folder or all subscribed folders, with a filter dialog (from / to / subject / body / date range / flags) and paginated results carrying their source folder.

5. Resume cursor

/get_nav_state on launch; if the stored folder/message differs from the default, offer restore via an AdwToast with an action. Push /set_nav_state debounced on navigation.

Phase 4 verification


Phase 5: Mail Composition & On-the-Fly From

1. Compose window

A separate AdwWindow per draft (multi-window compose is a desktop expectation, and matches the Apple compose scene). Dual-mode body via AdwViewSwitcher: Rich Text and Markdown, exactly as the Apple and React composers do.

The rich pane is a contenteditable inside a WebKitWebView — JS enabled here, loading the first-party editor.html + editor-bridge.js with marked and turndown. This is the same asset set the Apple client uses, from the same version pins, which is why the composer behaviour matches without reimplementation. The GTK toolbar drives it over a WebKitUserContentManager script-message bridge.

At send time, port computeMessageBodies()’s four-way table verbatim (both-empty, rich-only, markdown-only, both-filled) so every message ships populated text/plain and text/html parts. This is pure logic and belongs in cabalmail-kit/src/compose/ with tests.

2. From-address picker

First item is always Create new address…, opening the mint dialog inline (username / subdomain / domain / optional comment, with a Random button). Domains come from /list_my_domains. This is the product’s signature interaction — it must be one click from compose.

3. Reply / Reply All / Forward

Port ReplyBuilder as a pure function: idempotent Re: / Fwd: prefixing, In-Reply-To / References threading from the envelope’s threading identity, reply-all deduplication and self-exclusion, and the “default From to the original’s addressee” rule that makes an on-the-fly address reusable across a thread.

Signature insertion via the RFC 3676 "-- " delimiter, ported from SignatureFormatter.

4. Drafts

Local buffer plus server sync, matching docs/draft-sync-and-threading.md:

5. Send and outbox

/send owns the Outbox APPEND, SMTP submission, and the Sent copy — the client performs no APPEND. Classify failures before queueing, exactly as CabalmailClient.send does: transport and network errors queue into the on-disk outbox and raise a warning toast; application-level rejections (auth, malformed recipient, permanent 5xx) surface immediately so the compose window can correct them. Drain on connectivity restore, maxAttempts = 10.

Attachments upload through /upload_url.

6. Desktop compose entry points

Phase 5 verification


Phase 6: Address & Folder Management + Settings

1. Addresses

AdwPreferencesPage-style list from /list: create, revoke, suspend, reinstate, favorite. Search and favorites-first sorting. Revocation is a destructive AdwAlertDialog naming the address.

2. Folders

Tree from /list_folders: create (with parent), delete, subscribe / unsubscribe. Deleting a non-empty folder warns with its message count.

3. Settings

AdwPreferencesWindow, groups mirroring the Apple settings surface, persisted through the kit’s Preferences with the same wire keys so the setting follows the user across platforms:

Group Setting Wire key
Account Username, control domain (read-only), Sign Out —
Reading Mark as read (manual / on open) mark_as_read
Reading Load remote content (off / ask / always) load_remote_content
Reading Default body render mode default_body_render_mode
Composing Default From address default_from_address
Composing Signature signature
Composing Display name name (top-level, shared with web)
Actions Dispose action (Archive / Trash) dispose_action
Appearance Theme (System / Light / Dark) theme
Appearance Folder count display folder_count_display
Diagnostics Debug log viewer + export —

Sync semantics match Apple: server-wins pull on login and on window focus, debounced push on local edit, stored under the row’s app map so the web client’s flat keys are untouched. A revoked default_from_address falls back to None.

Three-way propagation

This is where the config file, the Settings UI, and the server are wired into one another (the model is specified under Configuration model; Phase 1 built the store it needs). Work items:

Controls are never rendered read-only on account of the file — the two are peers, and either can be used at any time. The window links to config.toml and to man 5 cabalmail, so the GUI is a discovery path for the text interface rather than a replacement for it.

crash_reporting_enabled has no Linux consumer (MetricKit is Apple-only) — the key is read and preserved on write so a round trip through the Linux client never clobbers the iOS setting, but no toggle is shown. It is the canonical example of a platform-scoped preference, and the Linux client’s own platform-scoped keys appear here in a Linux group, marked in the UI as syncing only to the user’s other Linux machines. Keys whose desktop lacks the underlying facility (close_to_tray with no StatusNotifierItem host) are hidden rather than shown broken — the stored value still syncs, so moving to a desktop that supports it picks the setting back up.

Phase 6 verification


Phase 7: Desktop Integration & Polish

1. New-mail notification (no push)

APNs is unavailable, so:

Web Push is the documented seam, not 1.1.0 scope. Supporting it means teaching push_register a platform discriminator and giving push_dispatch an RFC 8030 / VAPID transport alongside APNs. The 1.2.x browser-extension plan will want the same thing; whoever gets there first should build it for both. Recorded in Open Questions.

2. Keyboard and menus

Port the Apple command set to GTK actions with AdwPreferencesWindow-consistent accelerators: Reply (Ctrl+R), Reply All (Ctrl+Shift+R), Forward (Ctrl+Shift+J), Mark Read/Unread (Ctrl+T), Flag (Ctrl+Shift+8), Move to Folder (Ctrl+M), New Message (Ctrl+N), Refresh (Ctrl+Shift+R… resolve the collision with Reply All in favour of F5 for refresh), Dispose (Delete). Full keyboard navigation of list and reader; a shortcuts window (Ctrl+?).

Note the Apple lesson: dispose is deliberately window-scoped rather than app-global so it cannot fire from a compose window mid-draft. GTK actions have the same hazard — scope dispose to the mail window.

3. Session and window state

Window geometry, pane positions, and the expanded-folder set as JSON under $XDG_STATE_HOME/cabalmail/ — state, not configuration, and not worth a second config system (see Configuration model). Restore the last folder from the nav-state cursor. Optional start-minimized-to-notifications for users who keep mail running.

4. Offline

Cached envelopes and bodies readable offline; compose available with sends queued to the outbox. An AdwBanner reports connectivity loss, sourced from NetworkManager over D-Bus with a poll fallback.

5. Accessibility and theming

6. Diagnostics

DebugLogStore equivalent in the kit: an in-memory ring buffer with level filtering, viewable in Settings and exportable. Also written to $XDG_STATE_HOME/cabalmail/ and surfaced through --log-level.

Phase 7 verification


Phase 8: Debian & RHEL Packaging

1. Debian / Ubuntu

2. RHEL / Fedora

3. CI matrix extension

package-deb and package-rpm jobs join linux.yml, each with a matching smoke job that installs into a clean container of the same distro and runs --self-test. Artifacts uploaded per push; repository publication stays manual.

Phase 8 verification


Parity matrix

Apple client feature → Linux status. Anything marked deferred is deliberate, not an oversight.

Feature Linux Phase
Sign-in, sign-up, forgot password Yes 3
TOTP / SMS MFA Yes (TOTP enrollment included) 3
Folder list, unread badges, subscribe/unsubscribe Yes 4, 6
Message list: virtualized, sortable, filter pills Yes 4
Bulk multi-select operations Yes 4
Reader: HTML, plain text, remote-content policy, inline images Yes 4
SPF/DKIM/DMARC line, BIMI avatar Yes 4
Attachments: list, download Yes 4
Cross-folder structured search Yes 4
Resume cursor Yes 4
Compose: rich text + Markdown, shared JS pipeline Yes 5
On-the-fly From, mint-from-compose Yes 5
Reply / Reply All / Forward with threading Yes 5
Drafts: local buffer + cross-device sync Yes 5
Outbox with transient/permanent classification Yes 5
Address lifecycle: create, revoke, suspend, reinstate, favorite Yes 6
Folder management Yes 6
Server-synced preferences Yes 6
Theme, accessibility, keyboard, offline Yes 7
Debug log viewer and export Yes 7
mailto: handler Yes 5
Push notifications (APNs) Polling + desktop notifications 7
Contacts integration Deferred — no cross-desktop equivalent of the Apple Contacts store; recipient autocomplete draws on sent-message history instead —
Siri / App Intents Not applicable —
Watch companion, visionOS, MetricKit Not applicable —
Admin surfaces (users, DMARC reports, domain access) Out of scope — web app —
Hand-editable config file, man page, --print-config Beyond parity — no Apple equivalent; a platform expectation, not a port 1, 6

Out of scope for 1.1.0


Prerequisites

On an Arch development host:

sudo pacman -S rustup podman webkitgtk-6.0
rustup default 1.97.1
rustup component add clippy rustfmt llvm-tools

Decisions taken

Recorded here so the rationale survives; each was an open question above.

Date Question Decision
2026-08-07 Rust toolchain and edition Pin 1.97.1 exactly, edition 2024, no MSRV promise. Ubuntu 24.04’s 1.75 floor would cost the whole 1.76-1.97 language window, and a distro-native .deb is not a step toward the Debian archive anyway (which requires packaging every dependency crate separately). See Toolchain
2026-08-07 Platform-scoped preference sync Prefixed scalar keys in APP_ALLOWED (linux_*). Two server lines per key, no structural change, and the existing per-key merge already isolates platforms. Nested maps are not expressible - see the backend constraints
2026-08-07 GSettings Dropped. Window geometry moves to $XDG_STATE_HOME. One config system rather than two, no glib-compile-schemas hooks in any package, and no silent degradation on bare WMs without a D-Bus session bus
2026-08-07 UI definition format Blueprint (.blp). Roughly a third the line count of equivalent .ui XML with far better diffs; packaged in Arch, Debian, and Fedora, so every packaging container can build it

Open questions

  1. Changelog prefix. The Apple: prefix exists because set-testflight-notes.py filters on it. There is no equivalent consumer for Linux yet. Adopt Linux: now in anticipation of generated release notes for the packages, or leave fragments unprefixed until something consumes them? A linux-changelog.yml gate mirroring apple-changelog.yml only makes sense once the answer is “adopt”.
  2. Web Push ownership. The 1.2.x browser extension will need the same push_register platform discriminator and VAPID transport. Should that land as shared groundwork in 1.1.x, or wait for 1.2.x and leave Linux polling for a release?
  3. RHEL 9. Confirmed unsupportable from distro dependencies. Is RHEL 10 / Fedora an acceptable reading of “RHEL to follow”, or does RHEL 9 need to be met — which would mean adopting Flatpak?
  4. AUR publication. Which account owns the AUR package, and does the repo carry the .SRCINFO or generate it at publish time?
  5. Recipient autocomplete source. Apple reads the system Contacts store. Sent-message history is the proposed substitute; is an optional libebook (Evolution Data Server) integration worth the dependency on GNOME systems?
  6. Per-key updated_at on the preferences row. (Raised by the resolution of the config-file question — see Configuration model.) The row has no timestamp or version attribute, so a client cannot tell whether its local edit or the server’s value is newer. The consequence is bounded but real: an offline config.toml edit loses to a concurrent edit from another device on the next foreground pull. Fixing it means adding a per-key updated_at in set_preferences and comparing on pull — a server change affecting the Apple and web clients too. Out of scope for 1.1.0 and documented in cabalmail.5 as a known edge; confirm that’s acceptable, or schedule the server work.

Sources