Host your own email and enhance your privacy
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.
cabalmail-kit skeleton — shippedcabalmail-gtk shell — shippedxtask — shipped (fixtures declared only)From
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:
PKGBUILD suitable for the AUR..deb) and RHEL-family (.rpm) follow in Phase 8, built by the same CI matrix.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.
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.
ApiBackedImapClient, issue #371). The hand-rolled IMAP detour is not repeated.AdwNavigationSplitView, AdwToolbarView, AdwPreferencesPage, AdwToastOverlay, AdwAlertDialog, GtkListView. The goal is an app that sits comfortably next to Geary and Evolution, not a transliteration of the iOS UI.cabalmail-kit compiles and tests without GTK, WebKit, or a display server. This is what lets the bulk of the test suite run on a bare ubuntu-latest runner in seconds, and it is the single most important structural decision in this plan.| 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 |
| 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 |
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.
rust-toolchain.toml pins 1.97.1 with clippy, rustfmt, and llvm-tools components. Exact, not stable — with clippy -D warnings in CI, tracking stable means a new Rust release can redden CI overnight on lints nobody wrote code against. Bumping is a deliberate PR.Cargo.toml sets edition = "2024" and rust-version = "1.97". The rust-version field states what we build with; it is not a compatibility promise to anyone.makedepends=(rust)), since Arch’s rustc is always ahead of the pin — that keeps the PKGBUILD idiomatic and network-free, which AUR expects. Note rust-toolchain.toml is a rustup feature and is simply ignored there; the build gets whatever Arch ships, which is fine because Rust is backward compatible within an edition.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.
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:
tokio::runtime::Runtime (multi-thread), created at startup and stored in the application struct.cabalmail-kit call is runtime.spawn(...)ed. Kit futures are Send and never touch GTK types — enforced structurally, since the kit crate has no GTK dependency to touch.async_channel::Sender, consumed by a glib::spawn_future_local task that owns the widget handles..await that crosses the runtime boundary. glib::clone!(#[weak] ...) is the standard capture form.cabalmail-gtk gets a single spawn_to_ui! helper wrapping this dance, written and tested in Phase 1 before any feature code exists.
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.
CABALMAIL_*, e.g. CABALMAIL_LOG_LEVEL)$XDG_CONFIG_HOME/cabalmail/config.toml (default ~/.config/cabalmail/config.toml)$XDG_CONFIG_DIRS in order (default /etc/xdg/cabalmail/config.toml) — gives distro and site administrators a defaults drop point/get_preferences)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.
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:
rename(2), so a reader never sees a partial file and an open editor’s :w can’t interleave with a client write.toml_edit, not toml. A client rewrite touches only the values it changed.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.
lambda/api/set_preferences/function.py constrains this design in three ways that must be respected rather than discovered later:
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.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.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:
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.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.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.
updated_at or version attribute, and the Lambda only SETs. Two consequences:
cabalmail config reset <key>, which writes the default explicitly and pushes it.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.
| 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 |
glib-compile-schemas post-install hooks from all three packaging targets — a real simplification in Phases 2 and 8. The cost is departing from GNOME convention; the plan accepts that, because a GTK app with two config systems is worse than one with an unconventional single system.cabalmail --print-config dumps the effective values with per-key provenance — flag, env, user file, system file, server, or default — and, for synced keys, when they last pushed. A configuration with three writers that can’t explain itself is a support burden; this pays for itself the first time someone files “my setting won’t stick”.cabalmail.5 man page documenting every key, which section it belongs in, and whether it syncs. Installed by all three packages and generated from the same table that drives the parser, so it cannot drift.config.toml on first successful sign-in, populated from the initial server pull, with the section comments shown above. This follows from the file being a view of the store rather than an override layer — a user should not have to create a file to discover what is editable. config.example.toml still ships to /usr/share/doc/cabalmail/ as commented reference documentation.inotify via the notify crate is the mechanism that makes editor changes propagate at all. Debounce coalesces the write burst editors produce (write-temp, rename, truncate), and a parse failure holds the last-good store, surfaces the error in-app, and pushes nothing — a half-saved file must never reach the server.--print-config.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 | 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.
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.
Each numbered work item below is sized to one branch and one PR. Executors must observe:
cargo xtask ci locally before push. That target runs fmt, clippy, kit tests, and app tests in the same order CI does.cabalmail-kit/tests/fixtures/ in the same PR.changelog.d/<slug>.<category>.md, per changelog.d/README.md. See the Open Questions on whether a Linux: prefix is adopted.origin/stage, resolve conflicts, push, open the PR against stage.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 -
5973542bis onstage,linux/packaging/arch/PKGBUILDis in the tree, andpackage-archruns 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.
Goal: a workspace that builds an empty window, with the async bridge and the crate split already correct.
Status: Shipped 2026-08-07 (74f88e52).
linux/Cargo.toml declaring members cabalmail-kit, cabalmail-gtk, xtask.rust-toolchain.toml pinning channel = "1.97.1" exactly (not stable) with components = ["clippy", "rustfmt", "llvm-tools"]; edition = "2024" and rust-version = "1.97" in Cargo.toml. linux/README.md states that contributors need rustup, not the distro rust package — on Arch the two conflict, and only rustup honours the pin.Cargo.lock committed — distro packaging builds offline against vendored crates, which requires a lock.rustfmt.toml and clippy.toml; #![deny(warnings)] is not used (it breaks downstream builds on new compiler versions) — CI passes -D warnings instead.cabalmail-kit skeletonStatus: Shipped 2026-08-07 (d598f7a6).
Cargo.toml — add a CI check (Phase 2, work item 6) that fails if one ever appears.CabalmailError enum covering the error taxonomy the Apple client settled on: network, auth, http(status, body), decode, protocol, cancelled. The distinction between transport failure and application rejection is load-bearing for the outbox (Phase 5) — model it now.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.
config/ in the kit: a Settings struct where every field records both its value and its provenance (flag / env / user file / system file / server / default). Provenance is not a debugging afterthought — --print-config, Phase 6’s Settings UI, and the propagation rules all read it.[preferences], [preferences.linux], [local]) are the file’s rendering of that property, and the parser checks the two agree. A key in the wrong section is an error naming where it belongs.APP_ALLOWED list.toml_edit; write atomically (temp + rename(2)) preserving comments and key order.$XDG_CONFIG_HOME via directories; walk $XDG_CONFIG_DIRS in order for system defaults.cabalmail --print-config emitting the effective table with a provenance column, and cabalmail config set / config reset <key>.config.example.toml and the cabalmail.5 key list from the same parser table, with a test asserting no key exists in the parser that is missing from the man page.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.
cabalmail-gtk shellStatus: Shipped 2026-08-08 (9437d213).
AdwApplication with app ID com.cabalmail.Cabalmail, a single AdwApplicationWindow, and a placeholder AdwStatusPage.build.rs, compiling .blp sources via blueprint-compiler. Its absence is a hard build failure naming the package to install, not a silent fallback — a fallback path here would mean two UI formats in the tree and a build that succeeds differently depending on what happens to be installed.runtime.rs: the tokio runtime handle plus the spawn_to_ui! macro described above, with unit tests proving a spawned future’s result reaches a glib::MainContext callback..desktop and AppStream metainfo.xml. No gschema — window geometry is state ($XDG_STATE_HOME), configuration is TOML, and synced preferences are server-side. See the Configuration model.xtaskStatus: 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
cargo build --workspace succeeds on Arch.cargo test -p cabalmail-kit runs with no display server and no network.cargo run -p cabalmail-gtk opens a window titled “Cabalmail”.cargo tree -p cabalmail-kit | grep -E 'gtk|webkit' returns nothing.cabalmail --print-config on a clean system reports every key as default. Adding ~/.config/cabalmail/config.toml with one key reports that key as user-file and the rest unchanged; setting the matching CABALMAIL_* variable flips it to env. Running with XDG_CONFIG_HOME pointed at a temp directory relocates the lookup — no hard-coded ~/.config anywhere.cabalmail config set) preserves surrounding comments and key order, and is atomic — a concurrent reader sees either the old or the new file, never a truncated one.[preferences] is accepted by the Lambda’s APP_ALLOWED set; a test asserts the client’s synced-key list and the Lambda’s list are identical, so a future divergence fails CI rather than producing 400s at runtime.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.
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.
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.
rust-toolchain.toml (1.97.1), installed by dtolnay/rust-toolchain pinned to a commit SHA (the repo’s convention — every third-party action in apple.yml is SHA-pinned; match it). A toolchain bump is a deliberate PR touching one line, which is the point of pinning exactly rather than tracking stable under clippy -D warnings.blueprint-compiler in every container that builds the app crate. Verifying it is present and new enough in the Ubuntu 24.04, Debian, and Fedora images is a Phase 2 acceptance criterion, not a Phase 8 discovery.linux/packaging/deps/<distro>.txt so the PKGBUILD, the Debian control file, and the CI containers all read one source.cargo-llvm-cov, cargo-deny, namcap installed by pinned version.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.
cargo-llvm-cov over cabalmail-kit in a coverage job, failing under 90% of lines against a suite measuring 96%. A whole-crate floor catches a broad regression, not one untested module slipping in under it; that stays a reviewer’s job. The MIME, header, reply, signature, folder-tree, cache, and preference tests this layer lists are Phases 3 to 5; the modules that would hold them are still doc-comment stubs.cabalmail-kit/src/policy/, one per row of the table below, each with its own tests — including the second Apple type in the two rows that name a pair: ComposeCancelChoice as compose_cancel::Choice, and RowDisposal as dispose::DisposalPhase.--self-test is a real code path in cabalmail-gtk, and cargo xtask smoke installs the package package-arch uploaded into a clean Arch container and launches it under Xvfb. The widget tests against a fake ApiClient need an ApiClient to fake, so they follow it in Phase 3.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:
lambda/api/.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
reqwestdependency until Phase 3, so there is nothing for a captured fixture to decode into and nothing forwiremockto serve a request from - a corpus written now would assert that some JSON round-trips throughserde_json, which is a test ofserde_json. It moves to Phase 3, work item 4, alongside the client it pins, which is whatcargo xtask fixturesnow 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::readeris not a transliteration ofReaderToolbarLayout. Half of that type is arithmetic about which iOS SDK folds a.bottomBarat what width and how AppKit demotes a crowded toolbar’s trailing items, and libadwaita does neither - anAdwHeaderBardraws 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 inall(), 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,AdwAlertDialogdefaults its close response to the first one added, and both roads lead to a stray click discarding a draft.Two defects were carried by
HTMLRewriteand are not reproduced here, since a port is where they can be fixed: substituting eachcid:reference in turn corrupts a Content-ID that is a prefix of another (logo,logo2), and matching<headby 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
Envelopewould 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
wiremockhalf of that sentence went with layer (b).--self-testas 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 incabalmail-kitthat both the app andcargo xtask smokeread, so the printer and the grep cannot drift. Pointing the self-test at awiremockinstance follows the API client into Phase 3.
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:
pkgver derived from the latest semver git tag (matching scripts/promote.sh, which is the version source of truth); pkgrel reset on version bump.depends=(gtk4 libadwaita webkitgtk-6.0 glib2), makedepends=(rust git blueprint-compiler). Arch’s rust provides cargo, so listing both is redundant. This build ignores rust-toolchain.toml (a rustup feature) and uses whatever Arch ships — currently 1.97.1, always at or ahead of the pin.Erratum (2026-08-24): Two makedepends are missing from that list, and both are hard build failures. Arch splits
glib-compile-resourcesout ofglib2intoglib2-devel, whichcabalmail-gtk/build.rsneeds to bundle the GResource; and renderingcabalmail.5.mdto roff needs a converter —go-md2man, chosen overpandoc, 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 frompackaging/deps/arch.txt(the per-distro listinstall-linux-deps.shalready reads), andxtask/tests/packaging_contract.rsfails if the two disagree.webkitgtk-6.0joinsdependswith the composer in Phase 5.
- Installs
config.example.tomlto/usr/share/doc/cabalmail/andcabalmail.5to 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()runscargo build --release --frozen --offlineagainst vendored crates.check()runscargo test -p cabalmail-kit— kit tests need no display, so they run insidemakepkgunmodified.namcapclean on both thePKGBUILDand 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()runscargo fetch --locked, which is the standard Arch spelling for a Rust package, because crates cannot be expressed assource=()entries without committing a vendor tree. Everything after that is offline —build()andcheck()pass--frozen, which fails rather than resolving. The conclusion the rule was reached for is unaffected:npmis not a makedepend, and the npm tarballs are pinned, checksummed sources.
source=() entries pinned to the exact versions in react/admin/package.json (marked@^18.0.2, turndown@^7.2.4), with sha256sums. makepkg fetches and checksums them like any other source; the build is offline-after-fetch and reproducible.react/admin/package.json and fails if the PKGBUILD pins diverge. This preserves the “React’s manifest is the single source of truth” property the Apple client relies on, including Dependabot coverage, without committing upstream bytes.scripts/sync-vendored.sh (via cargo xtask sync-vendored), identical to the Apple flow.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.
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.
cargo deny check for licence and advisory policy.cabalmail-kit has no GTK/WebKit dependency (cargo tree grep).enable_javascript(false) on the reader configuration — the analog of the CLAUDE.md rule forbidding allow-scripts on the React reader iframe. Add the corresponding prohibition to CLAUDE.md in this phase.Erratum (2026-09-02): The third bullet cannot be written in this phase. There is no reader, no
WebKitWebView, and nowebkit6dependency 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. TheCLAUDE.mdprohibition is written in this phase, since a rule can precede its enforcement.
stage under linux/** runs every job in linux.yml green.cargo xtask package arch produces an installable package on a clean Arch container.cargo xtask smoke passes against that package.app-build and not lint — proving the floor is enforced where intended.kit-test.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.ymlfires only on a push tomainorstage, sopackage-archand the newsmokejob 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 archbuilt a namcap-clean package on an Arch workstation, andcargo xtask smokeinstalled that package into a cleanarchlinux:base-develcontainer and gotself-test reached a main windowout of the installed binary under Xvfb. The container half is what thesmokejob 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:
smokewrote its throwawayHOMEundertarget/, 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
smokejob’s command is removed or the package artifact is renamed at one end.
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.
cabalmail_kit::config::deployment fetches and decodes the descriptor, derives imap_host, and caches it at $XDG_CACHE_HOME/cabalmail/deployment.json. resolve reads the control domain from Settings.control_domain names a different host from the one it was fetched from is refused and never cached. Case, a trailing dot, a port, and punycode all compare as one host. The cache is matched on the same rule, so it answers only for the host it came from.invokeUrl is reported instead.cabalmail_kit::http::client is the one client configuration, for this item and item 4. It allows HTTPS only, on the first hop and on every redirect, follows at most five redirects, and sets a connect timeout and a 30-second overall timeout. A refused scheme or redirect is a Protocol error rather than a retryable one. A certificate the trust store rejects stays retryable, since a captive portal is its usual cause.reqwest runs on rustls with the aws-lc-rs provider and the system trust store via rustls-platform-verifier, and nothing links the system OpenSSL. deny.toml now allows ISC and BSD-3-Clause for the TLS stack. aws-lc-sys builds with the C compiler alone, but not under Arch’s default -flto=auto: its objects become GCC LTO bytecode Rust’s linker cannot resolve, so the PKGBUILD sets options=('!lto').cabalmail-kit/tests/fixtures/deployment/descriptor.json is held to terraform/infra/modules/app/templates/config.js.tftpl, and its domains entries to terraform/infra/modules/domains/outputs.tf, by xtask/tests/deployment_descriptor_contract.rs.control_domain with cabalmail config set control_domain <host>.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.
auth/: hand-rolled USER_PASSWORD_AUTH against https://cognito-idp.<region>.amazonaws.com/, mirroring CognitoAuthService. Required surface:
sign_in returning SignedIn or MfaRequired(Totp | Sms)submit_mfa_code, sign_up, confirm_sign_up, resend_confirmation_codeforgot_password, confirm_forgot_password, sign_outtotp_enabled, begin_totp_enrollment (returns the shared secret for an otpauth:// QR), confirm_totp_enrollmentsecret/: 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.
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.
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.
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.
Settings.control_domain and editable, as on the Apple sign-in screen. The descriptor is resolved (item 1) before the credentials page is enabled, so a typo’d domain fails on its own field rather than as a sign-in failure. On the first successful sign-in the normalized domain and the username are written to [local] through the config store, which is what materializes config.toml (see What belongs where). A domain pinned by a flag or CABALMAIL_CONTROL_DOMAIN is shown read-only, with its provenance.sign_in returns MfaRequired, labelled for TOTP or SMS.--session-only is honoured.otpauth:// URI as a QR code and as text, confirming with a code. Reached from the primary menu until Phase 6’s Account group links to the same dialog.--self-test asserts it reaches this window when signed out, which is the signed-out main window Phase 2’s smoke criterion names.control_domain set asks for one; after sign-in, cabalmail --print-config reports it and the username as user file.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.
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.
SortCriterion equivalent).policy::pills.GtkMultiSelection for bulk move / flag / read / dispose.AdwSwipeTracker on touch, context menu everywhere.Body render in a WebKitWebView, hardened:
enable_javascript(false) on the reader view. Non-negotiable. This work item owns the CI grep that enforces it — deferred here from Phase 2, work item 6, which had nothing to grep. Write the check with the view: break it once and watch it fail.WebKitWebContext request interception, honouring the load_remote_content preference (off / ask / always). “Ask” shows an AdwBanner with a per-message allow.cabalmail-cid: URI scheme handler backed by /fetch_inline_image.decide-policy; links never load in-view. Hovering shows the target; activating opens the system browser after confirmation for mismatched display text (the LinkMenu / HTMLRewrite behaviour).GtkTextView with linkification, not through WebKit.AuthResults line (SPF / DKIM / DMARC) and BIMI avatar via /fetch_bimi, cached.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.
/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.
cid: images, malformed MIME, 8-bit headers, HTML with scripts — without executing script and without leaking a remote request while blocking is on.FromA 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.
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.
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.
Local buffer plus server sync, matching docs/draft-sync-and-threading.md:
$XDG_DATA_HOME/cabalmail/drafts/, one JSON per draft — the crash-recovery story./save_draft on close-without-send (always) and on a 60 s debounce while composing; skipped while the body is empty, a send is in flight, or another save is running.(uidvalidity, uid), passing prior coordinates as replaces_*.Drafts offers Edit Draft, reseeding compose from the fetched copy.discard_draft_* so the server expunges the Drafts copy on success./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.
mailto: handler registered in the .desktop file, parsed by the ported MailtoUrl.text/plain and text/html to the React composer for a shared corpus of Markdown inputs. Assert this in tests — it is the highest-value regression guard in the phase.AdwPreferencesPage-style list from /list: create, revoke, suspend, reinstate, favorite. Search and favorites-first sorting. Revocation is a destructive AdwAlertDialog naming the address.
Tree from /list_folders: create (with parent), delete, subscribe / unsubscribe. Deleting a non-empty folder warns with its message count.
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.
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:
config.toml with notify, debounced to coalesce the multi-event burst editors emit on save. Reparse, diff against the store, apply changed keys. A parse failure keeps the last-good store, surfaces an in-app error banner with the file position, and pushes nothing.linux_ prefix, machine-local keys not at all. Scope is read from the schema, so a new key gets the right routing by declaring its scope rather than by being added to a push list someone can forget.$EDITOR moves its control without a reopen.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.
theme / accent / density and the Apple crash_reporting_enabled intact.dispose_action in $EDITOR moves the Settings control and reaches the server; changing it in Settings rewrites the file; changing it on the iOS client updates both on next foreground.[local] key never appears in a /set_preferences request body. A [preferences.linux] key appears only under its linux_ prefix, and a universal key only unprefixed — asserted per scope, since the routing is the part that silently rots.crash_reporting_enabled (Apple), accent, and density (web) untouched on the row — the existing per-key merge already guarantees this, so the test is a regression guard against someone later “simplifying” the push into a whole-map write.[preferences.linux]; an iOS device signing in does not see those keys at all.cabalmail config reset <key> writes the default and propagates it; deleting the line instead leaves the value intact and the line reappears on the next rewrite, as documented.APNs is unavailable, so:
/folder_status on an adaptive interval — frequent while the window is focused, backing off when unfocused or on battery (read UPower over D-Bus; degrade gracefully when absent).gio::Notification via GApplication::send_notification, which routes to org.freedesktop.Notifications and through the portal under sandboxing. Actions: Open, Mark as read, Archive.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.
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.
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.
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.
AccessibleRole and labels on every custom widget; screen-reader pass with Orca.prefers-color-scheme shim into the WebView, and never restyle sender HTML beyond that.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.
packaging/debian/ with control, rules (dh with dh-cargo), changelog generated from the repo’s release tags.debian:trixie and ubuntu:24.04 containers; lintian clean at the error level.packaging/rpm/cabalmail.spec built in fedora:latest and quay.io/centos/centos:stream10.rpmlint clean at the error level.linux/README.md so it is not rediscovered.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.
$XDG_* user directories..desktop file registers a working mailto: handler on each distro.man 5 cabalmail renders on each distro, and every key it documents is accepted by the parser (the Phase 1 drift test, re-run against the installed page)./etc/xdg/cabalmail/config.toml is picked up as system defaults and is correctly overridden by a user file — verified on each distro, since XDG_CONFIG_DIRS defaults vary.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 |
docs/tentative/apple-smime.md.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
rustup, not rust. The two conflict on Arch (rustup declares Provides: rust cargo rustfmt and Conflicts With the same), and only rustup honours rust-toolchain.toml — the distro package would silently build with whatever Arch ships regardless of the pin. pacman -S rustup offers the replacement in one step; nothing depends on rust. Note there is no rustc package on Arch; the binary comes from rust.gtk4, libadwaita, blueprint-compiler, nodejs, npm — headers ship in the main packages on Arch, no -dev split.webkitgtk-6.0 and podman are the two typically not already present.node / npm are needed only for scripts/sync-vendored.sh in a developer checkout, not by makepkg.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 |
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”.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?.SRCINFO or generate it at publish time?libebook (Evolution Data Server) integration worth the dependency on GNOME systems?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.