Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
name: Release

# One tag, one release, every platform: the signed and notarized Mac app with
# its Sparkle appcast, the Linux programs with their AUR package, and the
# Windows programs (unsigned, and marked experimental until someone has used
# them on a real machine).
# its Sparkle appcast, the Mac terminal programs (signed and notarized too),
# the Linux programs with their AUR package, and the Windows programs
# (unsigned, and marked experimental until someone has used them on a real
# machine).
#
# The release is assembled as a draft and only published once every job has
# delivered. The Mac app's updater reads releases/latest/download/appcast.xml,
Expand Down Expand Up @@ -169,6 +170,13 @@ jobs:
- name: Sign and notarize .app
run: ./scripts/codesign-macos.sh

# Same certificate, same team as the app: that is what lets the terminal
# programs and the app share keychain items without a dialog.
- name: Build, sign and notarize the terminal programs
env:
VERSION: ${{ needs.version.outputs.version }}
run: ./scripts/build-cli-macos.sh

- name: Build DMG
run: ./scripts/build-dmg.sh

Expand Down Expand Up @@ -223,6 +231,8 @@ jobs:
dist/TorroMail-*.dmg
dist/SHA256SUMS.txt
dist/appcast.xml
dist/torromail-*-universal-macos.tar.gz
dist/torromail-*-universal-macos.tar.gz.sha256
if-no-files-found: error

- name: Cleanup keychain
Expand Down Expand Up @@ -250,9 +260,14 @@ jobs:
run: |
set -euo pipefail
ls -l assets
# The three an installed Mac app will come looking for.
# The appcast an installed Mac app comes looking for, and a download
# for every system.
test -f assets/appcast.xml
ls assets/TorroMail-*.dmg assets/torromail-*-x86_64-linux.tar.gz assets/torromail-*-x86_64-windows.zip > /dev/null
ls assets/TorroMail-*.dmg \
assets/torromail-*-universal-macos.tar.gz \
assets/torromail-*-x86_64-linux.tar.gz \
assets/torromail-*-aarch64-linux.tar.gz \
assets/torromail-*-x86_64-windows.zip > /dev/null

flags=(--draft --title "$TAG" --generate-notes)
if [[ "$PRERELEASE" == "true" ]]; then flags+=(--prerelease); fi
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ localization.
cache defaults, pending actions, and search result sets.
- `crates/torromail-mcp`: explicit MCP tool catalog and stdio line server
facade.
- `crates/torromail-tui`: the terminal control surface (`torromail`) for
macOS, Linux and Windows — the same boundary as the app applies.
- `crates/torromail-keychain`: the macOS keychain with the app's team-scoped
access lists. The only crate allowed `unsafe`, and only in its `sys` module;
the rest of the workspace forbids it.
- `apps/TorroMailApp`: SwiftUI macOS configuration/control app.
- `docs`: architecture notes, product decisions, and implementation plans.

Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"crates/torromail-core",
"crates/torromail-discovery",
"crates/torromail-imap-tls",
"crates/torromail-keychain",
"crates/torromail-mcp",
"crates/torromail-oauth",
"crates/torromail-tui",
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ log — and neither shows your mail. The data below is a demo setup.
- `crates/torromail-mcp`: explicit MCP tool surface and stdio line server facade.
- `crates/torromail-control`: the configuration model every surface shares — accounts and their state file, the policy document writer, MCP client setup, provider discovery, log readers, platform paths.
- `crates/torromail-discovery`: the network half of account discovery — a small DNS client (MX, TXT, SRV), HTTPS autoconfig fetches and a TCP probe — behind the `Network` trait `torromail-control` decides with.
- `crates/torromail-tui`: terminal control surface (`torromail`), the Linux counterpart to the macOS app: add accounts, set permissions, connect assistants, read the log. This is not a mail client UI either.
- `crates/torromail-tui`: terminal control surface (`torromail`) for macOS, Linux and Windows — the counterpart to the macOS app, and on Linux and Windows the only one: add accounts, set permissions, connect assistants, read the log. This is not a mail client UI either.
- `crates/torromail-keychain`: the macOS keychain with the app's team-scoped access lists, so the app, the server and the terminal surface share secrets without a dialog. The one crate allowed `unsafe`, confined to its `sys` module.
- `contracts`: behaviour pinned as shared cases. The Rust tests and the Swift contract suite run the same files, so the two implementations cannot drift apart silently.
- `apps/TorroMailApp`: macOS SwiftUI configuration/control app. This is not a mail client UI.
- `docs`: architecture notes and product decisions.
Expand All @@ -97,19 +98,25 @@ swift build --package-path apps/TorroMailApp --scratch-path apps/TorroMailApp/.b

On Linux, `scripts/install-linux.sh` builds a release and installs `torromail` and
`torromail-mcp` into `~/.local/bin` (no root; `--uninstall` removes them again).
On a Mac, `scripts/install-macos.sh` does the same and signs the copies with your
development identity — without that signature they could not share keychain items with
the app.

On Windows (experimental) the same two programs keep their data in
`%LOCALAPPDATA%\TorroMail` and passwords in the Credential Manager; the background check is
not available there yet.

Prebuilt Linux programs are part of every release: tarballs for x86_64 and aarch64 and the
Prebuilt terminal programs are part of every release: a signed and notarized universal
tarball for macOS, a zip for Windows, and for Linux tarballs for x86_64 and aarch64 and the
`torromail-bin` AUR package ([packaging/aur](packaging/aur/torromail-bin/PKGBUILD)), which
the release builds and installs in a clean Arch container before anything is published.

To try the terminal surface from a checkout: `cargo run -p torromail-tui`. It reads the shared data directory —
`~/Library/Application Support/TorroMail` on macOS, `$XDG_STATE_HOME/torromail`
(default `~/.local/state/torromail`) elsewhere, and keeps secrets in the desktop's Secret
Service (`secret-tool` from libsecret must be installed). Keys: `1`–`7` sections, arrows to
(default `~/.local/state/torromail`) on Linux, and keeps secrets in the macOS keychain or
the desktop's Secret Service (`secret-tool` from libsecret must be installed). On a Mac an
unsigned `cargo run` build can store its own secrets but cannot read the app's; use
`scripts/install-macos.sh` for one that can. Keys: `1`–`7` sections, arrows to
select, `n` new account, `enter` edit, `c` connect an assistant, `q` to quit — the bottom line
always lists what applies.

Expand All @@ -121,8 +128,8 @@ For a runnable dev bundle, use `scripts/make-app-bundle.sh`.

Pushing a `v*` tag triggers [.github/workflows/release.yml](.github/workflows/release.yml),
which builds a universal `TorroMail.app`, signs and notarizes it, packages a
`.dmg`, signs a Sparkle appcast, builds the Linux programs and their AUR
package, and publishes everything as one GitHub Release — assembled as a draft
`.dmg`, signs a Sparkle appcast, builds the terminal programs for macOS
(signed and notarized), Linux (with their AUR package) and Windows, and publishes everything as one GitHub Release — assembled as a draft
first, so nothing half-finished is ever the latest release. Installed Mac
copies can check that feed automatically or on demand. See
[docs/RELEASING.md](docs/RELEASING.md) for versioning rules, the required
Expand Down
8 changes: 6 additions & 2 deletions crates/torromail-control/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ roxmltree = "0.21"
serde_json = "1"
sha2 = "0.10"

# The Windows Credential Manager. Only there: macOS has the keychain and every
# other desktop the Secret Service, both reached without a library of ours.
# The macOS keychain, with the team-scoped access list the app uses.
[target.'cfg(target_os = "macos")'.dependencies]
torromail-keychain = { path = "../torromail-keychain" }

# The Windows Credential Manager. Only there: every other desktop has the
# Secret Service, reached without a library of ours.
[target.'cfg(windows)'.dependencies]
keyring = { version = "3", default-features = false, features = ["windows-native"] }
52 changes: 45 additions & 7 deletions crates/torromail-control/src/secrets.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Where passwords, token sets and client keys are kept. Never in a file of
//! ours: on macOS the keychain holds them, on Windows the Credential Manager,
//! elsewhere the desktop's Secret Service (gnome-keyring, KWallet). There is
//! no plaintext fallback — a
//! machine without a secret service cannot hold an account, and saying so is
//! ours: on macOS the keychain holds them (shared by the app, the server and
//! the terminal surface), on Windows the Credential Manager, elsewhere the
//! desktop's Secret Service (gnome-keyring, KWallet). There is no plaintext
//! fallback — a machine without a secret service cannot hold an account, and saying so is
//! better than pretending.
//!
//! The Secret Service is reached through `secret-tool`, the command libsecret
Expand Down Expand Up @@ -176,9 +176,44 @@ impl SecretStore for CredentialManagerStore {
}
}

/// The store this platform keeps secrets in: the Credential Manager on
/// Windows, the Secret Service everywhere else this is called. (On macOS the
/// app and the server talk to the keychain themselves.)
/// The macOS keychain, with the access list the app gives its items: any
/// binary signed by TorroMail's team reads them without a dialog, so what the
/// terminal surface stores the app and the server read, and the other way
/// round. See `torromail-keychain` for how.
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Default)]
pub struct KeychainStore;

#[cfg(target_os = "macos")]
impl KeychainStore {
/// A keychain that would have needed a dialog is usually a locked one — a
/// state of the machine. Anything else it says is a refusal.
fn error(error: torromail_keychain::Error) -> SecretError {
if error.needs_interaction() {
SecretError::Unavailable(error.to_string())
} else {
SecretError::Failed(error.to_string())
}
}
}

#[cfg(target_os = "macos")]
impl SecretStore for KeychainStore {
fn get(&self, service: &str, account: &str) -> Result<Option<String>, SecretError> {
torromail_keychain::read(service, account).map_err(Self::error)
}

fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretError> {
torromail_keychain::save(service, account, secret).map_err(Self::error)
}

fn delete(&self, service: &str, account: &str) -> Result<(), SecretError> {
torromail_keychain::delete(service, account).map_err(Self::error)
}
}

/// The store this platform keeps secrets in: the keychain on macOS, the
/// Credential Manager on Windows, the Secret Service everywhere else.
///
/// `TORROMAIL_SECRET_TOOL` names another `secret-tool` — for tests, and for
/// installations that keep it off the `PATH` an assistant starts the server
Expand All @@ -193,6 +228,9 @@ pub fn platform_store() -> Box<dyn SecretStore> {
{
match std::env::var_os("TORROMAIL_SECRET_TOOL") {
Some(program) => Box::new(SecretToolStore::with_program(program)),
#[cfg(target_os = "macos")]
None => Box::new(KeychainStore),
#[cfg(not(target_os = "macos"))]
None => Box::new(SecretToolStore::default()),
}
}
Expand Down
26 changes: 26 additions & 0 deletions crates/torromail-keychain/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "torromail-keychain"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "The macOS keychain the way the TorroMail app uses it: items any binary of the signing team reads without a dialog."

# Not `workspace = true`, and on purpose: the workspace forbids `unsafe`, and a
# forbid cannot be lifted further down. This crate is the one place that has to
# call Security.framework functions no safe binding covers, so it denies unsafe
# instead and allows it in exactly one module, `sys`. Everything else matches
# the workspace.
[lints.rust]
unsafe_code = "deny"

[lints.clippy]
unwrap_used = "deny"
dbg_macro = "deny"
todo = "deny"

[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
security-framework = "3"
security-framework-sys = "2"
28 changes: 28 additions & 0 deletions crates/torromail-keychain/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! The macOS keychain, used the way the TorroMail app uses it.
//!
//! A fresh item gets an access list whose application list is empty ("any
//! application") and whose partition list names the signing Team ID. The
//! partition is the gate macOS actually enforces, so every binary signed by
//! that team — the app, the server it bundles, the terminal surface — reads
//! and writes the item without a dialog, and a rebuild keeps working because
//! the grant is keyed to the team, not to one binary's hash. This mirrors
//! `KeychainStore.teamScopedAccess()` in the app; an item either side creates
//! is one the other can read.
//!
//! An unsigned build has no team to scope to and stores its items with the
//! keychain's default list, which names only itself. Those work for that build
//! alone — good enough for a local `cargo run`, useless for sharing.
//!
//! Nothing here asks the user. [`silence_prompts`] turns the keychain's
//! dialogs off for the whole process; a read that would need one fails
//! instead, and the callers treat that as "this secret has to be entered
//! again", which is what it means.

#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod sys;

#[cfg(target_os = "macos")]
pub use macos::*;
Loading
Loading