feat: build carrier, a minimalist Iced smolmail client on fumi-core

This commit is contained in:
randogoth 2026-10-01 23:12:21 +03:00
commit e9e40bca36
12 changed files with 7698 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/target
result
result-*
*.key
*.db
*.db-wal
*.db-shm

4706
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "carrier"
version = "0.1.0"
edition = "2021"
description = "A minimalist smolmail client built on fumi-core, with an Iced UI"
license = "Apache-2.0"
[dependencies]
fumi-core = { path = "../fumi/core" }
iced = { version = "0.13", features = ["tokio", "qr_code"] }
tokio = { version = "1", features = ["rt"] }
rand_core = { version = "0.6", features = ["getrandom"] }
[dev-dependencies]
tempfile = "3"

40
README.md Normal file
View file

@ -0,0 +1,40 @@
# carrier
A minimalist desktop client for [Smol Mail](../smolmail): one compact window — the list above, the article below — built on [fumi-core](../fumi), the reference CLI's own library, with an [Iced](https://iced.rs) UI. One 32-byte seed is the only secret; mail stays sealed at rest and opens on demand.
## What it does
- Create or restore an identity from a seed, shown once at creation time.
- Pin a server's key — paste the operator's, or connect and pin what it presents — then register a username or recall an existing address.
- Fetch (with cancel), read, reply and send sealed mail; the inbox, the requests tier and sent copies each keep their own folder.
- Accept or block a requester when their mail carried a self-certifying reply address; accept tokens attach to accepted contacts automatically.
- Contacts with verified/tofu marks and smol:// import; your own address as a URI, a QR code and a clipboard line.
- Key rotation with a confirm-first step, server pin management, a network timeout, and gsmol backup export/import that never contains the seed.
## Layout
```
src/main.rs config directory, iced launch
src/app.rs state and update; blocking calls run on the worker pool
src/view.rs the compact split view: tab bar, list, article, status line
src/ops.rs fumi-core calls, key/read-mark files, and the offline tests
src/theme.rs the palette: navy surfaces, mint accent, monospace protocol values
```
## State
Everything lives under `$XDG_CONFIG_HOME/carrier` (default `~/.config/carrier`): `carrier.key` is the master seed, written 0600; `carrier.db` is fumi-core's sealed store; `read.txt` is carrier's own read marks, one hex id per line.
## Run and verify
```
devbox run run # build release and launch
devbox run check # fmt --check, build, tests
devbox run test # tests only
```
The tests run offline against an in-memory store: seed and key parsing, a sealed envelope listed and opened end to end, contact import, a full backup round-trip, pin labels and the date formatter. `scripts/devbox-libs.sh` puts the GUI stack's runtime libraries (wayland, xkbcommon, EGL, vulkan) on the library path inside the devbox shell — they are dlopened, so nothing that links the binary can provide them.
## Status
Work in progress: the UI is unexercised against a live server — the store, crypto and address paths are covered by tests, the network flows (register, fetch, send, rotate) are wired to fumi-core's operations but only smoke-tested as far as launching the window.

36
devbox.json Normal file
View file

@ -0,0 +1,36 @@
{
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json",
"packages": [
"rustc@latest",
"cargo@latest",
"pkg-config@latest",
"rustfmt@latest",
"nixpkgs#wayland",
"nixpkgs#libxkbcommon",
"nixpkgs#mesa",
"nixpkgs#libGL",
"nixpkgs#vulkan-loader"
],
"shell": {
"init_hook": [
". \"$PWD/scripts/devbox-libs.sh\""
],
"scripts": {
"build": [
"cargo build --release"
],
"run": [
"cargo build --release && ./target/release/carrier"
],
"dev": [
"cargo build && ./target/debug/carrier"
],
"test": [
"cargo test"
],
"check": [
"cargo fmt --check && cargo build && cargo test"
]
}
}
}

255
devbox.lock Normal file
View file

@ -0,0 +1,255 @@
{
"lockfile_version": "1",
"packages": {
"cargo@latest": {
"last_modified": "2026-08-27T07:16:00Z",
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo",
"source": "devbox-search",
"version": "1.97.1",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1",
"default": true
}
],
"store_path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1",
"default": true
}
],
"store_path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1",
"default": true
}
],
"store_path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1"
}
}
},
"github:NixOS/nixpkgs/nixpkgs-unstable": {
"last_modified": "2026-09-29T03:29:29Z",
"resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D"
},
"nixpkgs#libGL": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libGL"
},
"nixpkgs#libxkbcommon": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libxkbcommon"
},
"nixpkgs#mesa": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#mesa"
},
"nixpkgs#vulkan-loader": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#vulkan-loader"
},
"nixpkgs#wayland": {
"last_modified": "1970-01-01T00:00:00Z",
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#wayland"
},
"pkg-config@latest": {
"last_modified": "2025-11-23T21:50:36Z",
"resolved": "github:NixOS/nixpkgs/ee09932cedcef15aaf476f9343d1dea2cb77e261#pkg-config",
"source": "devbox-search",
"version": "0.29.2",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2",
"default": true
},
{
"name": "man",
"path": "/nix/store/9px0sji43x3r2w4zxl3j3idwsql7lwxx-pkg-config-wrapper-0.29.2-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/hqk44ra6qxw7iixardl6c3hdgb9kq6ns-pkg-config-wrapper-0.29.2-doc"
}
],
"store_path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2",
"default": true
},
{
"name": "man",
"path": "/nix/store/10060k24qggqyzlwdsfmni9y32zxcg0j-pkg-config-wrapper-0.29.2-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/0y4v51ndpyvkj09hwlfqkz0c3h17zfmc-pkg-config-wrapper-0.29.2-doc"
}
],
"store_path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2",
"default": true
},
{
"name": "man",
"path": "/nix/store/1nyspra675q22gfhf7hn2nmfpi6rgim5-pkg-config-wrapper-0.29.2-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/7lq1axxwrafwljs06n88bzyz9w523rkc-pkg-config-wrapper-0.29.2-doc"
}
],
"store_path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2",
"default": true
},
{
"name": "man",
"path": "/nix/store/j9xfpnrygg3v37svc5pfin9q5bm49r94-pkg-config-wrapper-0.29.2-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/x3bypxdxaq20kykybhkf21x4jczsiy8y-pkg-config-wrapper-0.29.2-doc"
}
],
"store_path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2"
}
}
},
"rustc@latest": {
"last_modified": "2026-08-27T07:16:00Z",
"plugin_version": "0.0.1",
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustc",
"source": "devbox-search",
"version": "1.97.1",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1",
"default": true
},
{
"name": "man",
"path": "/nix/store/grw7ifdixkdr4ya8bw5x0psjyfr5azva-rustc-wrapper-1.97.1-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/8qkha38sic70l51g4l7dmjrxnzqjrr6w-rustc-wrapper-1.97.1-doc"
}
],
"store_path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1",
"default": true
},
{
"name": "man",
"path": "/nix/store/hn340n5pi9p30j4bcxgzqgq8lmclg1by-rustc-wrapper-1.97.1-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/avbwjkw896rb3nl28kh8qv6kxyq5b9v2-rustc-wrapper-1.97.1-doc"
}
],
"store_path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1",
"default": true
},
{
"name": "man",
"path": "/nix/store/an2hvf5i7mq52mqdad9rad8mxkpksmq2-rustc-wrapper-1.97.1-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/8svfp79q0gx10gcrsyxf1pw04q8jf0lp-rustc-wrapper-1.97.1-doc"
}
],
"store_path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1"
}
}
},
"rustfmt@latest": {
"last_modified": "2026-08-27T07:16:00Z",
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustfmt",
"source": "devbox-search",
"version": "1.97.1",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/l7xlkqk1bz4w75rwf5dh7pn2br04n59l-rustfmt-1.97.1",
"default": true
}
],
"store_path": "/nix/store/l7xlkqk1bz4w75rwf5dh7pn2br04n59l-rustfmt-1.97.1"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/rl3j1za9dpdri1vr52vl69y1qczjb5zv-rustfmt-1.97.1",
"default": true
}
],
"store_path": "/nix/store/rl3j1za9dpdri1vr52vl69y1qczjb5zv-rustfmt-1.97.1"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/47k26k98v32x56rxiqawdk5qk0r0b6w1-rustfmt-1.97.1",
"default": true
}
],
"store_path": "/nix/store/47k26k98v32x56rxiqawdk5qk0r0b6w1-rustfmt-1.97.1"
}
}
}
}
}

52
scripts/devbox-libs.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env sh
# Devbox installs library packages as their -dev outputs, which carry no
# shared objects; the outs they reference do. Put the GUI stack's real lib
# dirs on LD_LIBRARY_PATH — wayland, xkbcommon, EGL and vulkan are dlopened
# at runtime, so neither rpath nor a DT_NEEDED scan can cover them. Only
# matching package names are picked up: a blanket list would drag glibc and
# friends onto the path for every process in the shell.
set -e
if [ -z "$DEVBOX_PACKAGES_DIR" ]; then
return 0 2>/dev/null || exit 0
fi
MANIFEST="$DEVBOX_PACKAGES_DIR/manifest.json"
[ -f "$MANIFEST" ] || return 0 2>/dev/null || exit 0
libs=$(python3 - "$MANIFEST" <<'EOF'
import json, os, subprocess, sys
wanted = ("wayland", "libxkbcommon", "libglvnd", "vulkan-loader", "mesa")
manifest = json.load(open(sys.argv[1]))
seen, dirs = set(), []
paths = [
path
for name, el in manifest["elements"].items()
if any(w in name for w in wanted)
for path in el.get("storePaths", [])
]
for path in paths:
candidates = [path]
try:
candidates += subprocess.run(
["nix-store", "-q", "--references", path],
capture_output=True, text=True,
).stdout.split()
except OSError:
pass
for cand in candidates:
lib = os.path.join(cand, "lib")
base = os.path.basename(cand)
if lib in seen or not os.path.isdir(lib):
continue
if not any(w in base for w in wanted):
continue
if any(name.endswith(".so") or ".so." in name for name in os.listdir(lib)):
seen.add(lib)
dirs.append(lib)
print(":".join(dirs))
EOF
)
export LD_LIBRARY_PATH="${libs}:${DEVBOX_PACKAGES_DIR}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"

934
src/app.rs Normal file
View file

@ -0,0 +1,934 @@
//! Application state and update. Every fumi-core call that can block runs in
//! `spawn_blocking` and reports back through a `Message`; purely local reads
//! (opening one sealed message, marking it read) happen inline in `update`.
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use fumi_core::address::Address;
use fumi_core::crypto::{unb32, KEY_LEN};
use fumi_core::store::Store;
use iced::widget::qr_code;
use iced::widget::text_editor::{self, Action};
use iced::{clipboard, Task};
use crate::ops::{self, Folder, OpenedMsg, Row, Snapshot};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Onboard,
Signup,
Mail,
Contacts,
Settings,
}
/// Read marks are carrier's own state, so they live beside the store as one
/// hex id per line — an append-only file that a torn tail can only cost a
/// re-read mark, never a lost one.
pub struct ReadMarks {
path: PathBuf,
ids: HashSet<String>,
}
impl ReadMarks {
pub fn load(path: PathBuf) -> ReadMarks {
let ids = std::fs::read_to_string(&path)
.map(|text| text.lines().map(str::to_string).collect())
.unwrap_or_default();
ReadMarks { path, ids }
}
pub fn snapshot(&self) -> HashSet<String> {
self.ids.clone()
}
pub fn mark(&mut self, id: String) {
if self.ids.insert(id.clone()) {
use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
let _ = writeln!(file, "{id}");
}
}
}
}
/// Onboarding: create a master or restore one from a pasted seed.
pub struct Onboard {
pub mode: OnboardMode,
pub seed: String,
pub seed_shown: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnboardMode {
Start,
Created,
Restore,
}
/// Signup: bind the identity to a username, or recall an existing address.
pub struct Signup {
pub user: String,
pub host: String,
pub invite: String,
/// The server's base32 static key, pasted from what the operator
/// published; pinned before the registration connects.
pub key: String,
/// True when onboarding just wrote the key file, so Back is offered.
pub fresh: bool,
}
/// The compose view: `to` takes a short address or a smol:// URI.
pub struct Compose {
pub to: String,
pub subject: String,
pub body: text_editor::Content,
/// The message this replies to, as 64 hex characters.
pub reply_to: Option<String>,
}
#[derive(Debug, Clone)]
pub enum Dialog {
/// Delete locally, and on the server too when `everywhere` is chosen.
Delete {
id: String,
folder: Folder,
kept: bool,
},
/// Rotation only moves the index forward, so the action asks first.
Rotate,
}
#[derive(Debug, Clone)]
pub enum Message {
// Onboarding
OnboardCreate,
OnboardRestore,
OnboardBack,
OnboardSeedEdited(String),
OnboardRestoreFromSeed,
OnboardSeedSaved,
// Signup
SignupUserEdited(String),
SignupHostEdited(String),
SignupInviteEdited(String),
SignupKeyEdited(String),
SignupBack,
SignupLearnKey,
SignupRegister,
SignupRecall,
// Pin offer (signup and settings share it)
PinOffered(Result<Option<(Address, [u8; KEY_LEN])>, String>),
PinAccept,
PinDiscard,
Pinned(Result<(), String>),
// Mail
MessageSelected(Folder, String),
FetchPressed,
FetchCancelled,
ComposePressed,
ComposeToEdited(String),
ComposeSubjectEdited(String),
ComposeBody(Action),
ComposeClosed,
SendPressed,
ReplyPressed,
DeletePressed,
DeleteConfirmed(bool),
DeleteCancelled,
AcceptPressed,
BlockPressed,
// Contacts
ContactImportEdited(String),
ContactImportPressed,
ContactWrite(String),
ContactAccept(String, String),
ContactBlock(String),
ContactUnblock(String),
CopyOwnAddress,
// Settings
SettingsLearnKey,
TimeoutEdited(String),
RotatePressed,
RotateConfirmed,
RotateCancelled,
UnpinPressed(String),
ExportPressed,
Exported(Result<(String, String), String>),
CopyExport,
ImportBody(Action),
ImportPressed,
// Navigation and results
GotoMail(Folder),
Goto(Page),
SnapshotLoaded(Result<Snapshot, String>),
Generic(Result<String, String>),
}
pub struct Carrier {
pub dir: PathBuf,
pub store: Arc<Store>,
pub master: Option<[u8; KEY_LEN]>,
pub marks: ReadMarks,
pub page: Page,
pub status: Option<String>,
pub status_error: bool,
pub busy: Option<String>,
pub cancel: Arc<AtomicBool>,
pub timeout: u64,
pub timeout_text: String,
pub onboard: Onboard,
pub signup: Signup,
pub folder: Folder,
pub lists: Vec<Vec<Row>>,
pub open: Option<(Folder, OpenedMsg)>,
pub compose: Option<Compose>,
pub snapshot: Option<Snapshot>,
pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
pub backup_text: Option<String>,
pub import_body: text_editor::Content,
pub contact_import: String,
pub dialog: Option<Dialog>,
/// The own-address QR, rebuilt whenever a snapshot lands.
pub share_qr: Option<qr_code::Data>,
}
impl Carrier {
pub fn boot(dir: PathBuf) -> (Carrier, Task<Message>) {
let store = match Store::open(&dir.join("carrier.db")) {
Ok(store) => Arc::new(store),
Err(e) => {
eprintln!("cannot open {}: {e}", dir.join("carrier.db").display());
std::process::exit(1);
}
};
let master = ops::load_key(&dir.join("carrier.key")).ok();
let registered =
master.is_some() && store.account().map(|addr| addr.is_some()).unwrap_or(false);
let page = match (master.is_some(), registered) {
(false, _) => Page::Onboard,
(true, false) => Page::Signup,
(true, true) => Page::Mail,
};
let mut app = Carrier {
marks: ReadMarks::load(dir.join("read.txt")),
dir,
store,
master,
page,
status: None,
status_error: false,
busy: None,
cancel: Arc::new(AtomicBool::new(false)),
timeout: 30,
timeout_text: "30".to_string(),
onboard: Onboard {
mode: OnboardMode::Start,
seed: String::new(),
seed_shown: String::new(),
},
signup: Signup {
user: String::new(),
host: String::new(),
invite: String::new(),
key: String::new(),
fresh: false,
},
folder: Folder::Inbox,
lists: vec![Vec::new(), Vec::new(), Vec::new()],
open: None,
compose: None,
snapshot: None,
pin_offer: None,
backup_text: None,
import_body: text_editor::Content::new(),
contact_import: String::new(),
dialog: None,
share_qr: None,
};
let task = if registered {
app.refresh()
} else {
Task::none()
};
(app, task)
}
fn master(&self) -> [u8; KEY_LEN] {
self.master.unwrap_or([0u8; KEY_LEN])
}
fn key_path(&self) -> PathBuf {
self.dir.join("carrier.key")
}
fn note(&mut self, text: String) {
self.status = Some(text);
self.status_error = false;
}
fn fail(&mut self, text: String) {
self.status = Some(text);
self.status_error = true;
}
/// One operation at a time: a second action while one runs is ignored,
/// except `FetchCancelled`, which raises the token the fetch checks.
fn start(&mut self, label: &str) -> bool {
if self.busy.is_some() {
return false;
}
self.busy = Some(label.to_string());
self.cancel.store(false, Ordering::Relaxed);
true
}
fn done(&mut self) {
self.busy = None;
}
/// One blocking pass over the store, bringing every page's data home.
fn refresh(&mut self) -> Task<Message> {
if !self.start("refreshing") {
return Task::none();
}
let store = self.store.clone();
let master = self.master();
let read = self.marks.snapshot();
Task::perform(
async move {
tokio::task::spawn_blocking(move || ops::snapshot(&store, &master, &read))
.await
.unwrap_or_else(|e| Err(format!("refreshing failed: {e}")))
},
Message::SnapshotLoaded,
)
}
fn refresh_after(&mut self, note: String) -> Task<Message> {
self.note(note);
self.refresh()
}
/// Runs one blocking operation on the worker pool, mapping its result to
/// `map` — the label doubles as the busy indicator and the panic message.
fn run_with<T, F, M>(&mut self, label: &str, map: M, work: F) -> Task<Message>
where
T: Send + 'static,
F: FnOnce(Arc<Store>, [u8; KEY_LEN]) -> Result<T, String> + Send + 'static,
M: Fn(Result<T, String>) -> Message + Send + 'static,
{
if !self.start(label) {
return Task::none();
}
let label = label.to_string();
let store = self.store.clone();
let master = self.master();
Task::perform(
async move {
tokio::task::spawn_blocking(move || work(store, master))
.await
.unwrap_or_else(|e| Err(format!("{label} failed: {e}")))
},
map,
)
}
fn run<F>(&mut self, label: &str, work: F) -> Task<Message>
where
F: FnOnce(Arc<Store>, [u8; KEY_LEN]) -> Result<String, String> + Send + 'static,
{
self.run_with(label, Message::Generic, work)
}
/// `user@host` as typed on the signup page, defaulting the port.
fn signup_address(&self) -> Option<Address> {
let user = self.signup.user.trim();
let host = self.signup.host.trim();
if user.is_empty() || host.is_empty() {
return None;
}
Address::parse(&format!("{user}@{host}")).ok()
}
/// The account address, for the settings page's learn-key action.
fn account_address(&self) -> Option<Address> {
self.store.account().ok().flatten()
}
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
// Text editor actions must survive the busy guard: typing in the
// compose body while a fetch runs is ordinary behavior.
Message::ComposeBody(action) => {
if let Some(compose) = &mut self.compose {
compose.body.perform(action);
}
Task::none()
}
Message::ImportBody(action) => {
self.import_body.perform(action);
Task::none()
}
Message::FetchCancelled => {
if self.busy.is_some() {
self.cancel.store(true, Ordering::Relaxed);
}
Task::none()
}
_ if self.busy.is_some() => Task::none(),
Message::OnboardCreate => {
let master = ops::new_master();
if let Err(e) = ops::save_key(&self.key_path(), &master) {
self.fail(e);
return Task::none();
}
self.master = Some(master);
self.onboard.seed_shown = ops::seed_text(&master);
self.onboard.mode = OnboardMode::Created;
self.note("Identity created. Write the seed down; it is the only secret.".into());
Task::none()
}
Message::OnboardRestore => {
self.onboard.mode = OnboardMode::Restore;
Task::none()
}
Message::OnboardBack => {
self.onboard.mode = OnboardMode::Start;
Task::none()
}
Message::OnboardSeedEdited(seed) => {
self.onboard.seed = seed;
Task::none()
}
Message::OnboardRestoreFromSeed => match ops::parse_seed(&self.onboard.seed) {
Ok(master) => {
if let Err(e) = ops::save_key(&self.key_path(), &master) {
self.fail(e);
return Task::none();
}
self.master = Some(master);
self.signup.fresh = true;
self.page = Page::Signup;
self.note("Seed restored. Bind a username or recall an address.".into());
Task::none()
}
Err(e) => {
self.fail(e);
Task::none()
}
},
Message::OnboardSeedSaved => {
self.signup.fresh = true;
self.page = Page::Signup;
Task::none()
}
Message::SignupUserEdited(user) => {
self.signup.user = user;
Task::none()
}
Message::SignupHostEdited(host) => {
self.signup.host = host;
Task::none()
}
Message::SignupInviteEdited(invite) => {
self.signup.invite = invite;
Task::none()
}
Message::SignupKeyEdited(key) => {
self.signup.key = key;
Task::none()
}
Message::SignupBack => {
self.page = Page::Onboard;
Task::none()
}
Message::SignupLearnKey => {
let Some(addr) = self.signup_address() else {
self.fail("enter user and host first".into());
return Task::none();
};
self.run_with("connecting", Message::PinOffered, move |store, _| {
ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key)))
})
}
Message::SettingsLearnKey => {
let Some(addr) = self.account_address() else {
return Task::none();
};
self.run_with("connecting", Message::PinOffered, move |store, _| {
ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key)))
})
}
Message::PinDiscard => {
self.pin_offer = None;
Task::none()
}
Message::PinOffered(result) => {
self.done();
match result {
Ok(Some((addr, key))) => {
self.pin_offer = Some((addr, key));
self.note(
"The server presented this key. Pin it only if the fingerprint \
matches what the operator published."
.into(),
);
Task::none()
}
Ok(None) => {
self.note("This server's key is already pinned.".into());
Task::none()
}
Err(e) => {
self.fail(e);
Task::none()
}
}
}
Message::PinAccept => {
let Some((addr, key)) = self.pin_offer.take() else {
return Task::none();
};
self.run_with("pinning", Message::Pinned, move |store, _| {
ops::pin(&store, &addr, &key)
})
}
Message::Pinned(result) => {
self.done();
match result {
Ok(()) => self.note("server key pinned".into()),
Err(e) => self.fail(e),
}
Task::none()
}
Message::SignupRegister => {
let Some(addr) = self.signup_address() else {
self.fail("enter user and host".into());
return Task::none();
};
let invite = if self.signup.invite.is_empty() {
None
} else {
Some(self.signup.invite.clone())
};
// A pasted operator key is pinned first: registration
// connects with the pin required.
let key = self.signup.key.trim().to_string();
let timeout = self.timeout;
self.run("registering", move |store, master| {
if !key.is_empty() {
let key = ops::parse_key(&key)?;
ops::pin(&store, &addr, &key)?;
}
ops::register(&store, &master, &addr, invite.as_deref(), timeout)
.map(|_| "registered".to_string())
})
}
Message::SignupRecall => {
let Some(addr) = self.signup_address() else {
self.fail("enter user and host".into());
return Task::none();
};
let timeout = self.timeout;
self.run("restoring", move |store, master| {
ops::restore(&store, &master, &addr, timeout)
.map(|index| format!("restored at rotation {index}"))
})
}
Message::GotoMail(folder) => {
if self.page == Page::Mail && self.folder == folder {
return Task::none();
}
self.page = Page::Mail;
self.folder = folder;
self.open = None;
Task::none()
}
Message::Goto(page) => {
self.page = page;
self.open = None;
self.compose = None;
if page == Page::Mail || page == Page::Contacts || page == Page::Settings {
return self.refresh();
}
Task::none()
}
Message::MessageSelected(folder, id) => {
self.folder = folder;
self.open = None;
self.marks.mark(id.clone());
// Opening one envelope and re-listing is local and fast, so
// it happens inline; the refresh keeps the unread dots honest.
let master = self.master();
let read = self.marks.snapshot();
match ops::open(&self.store, &master, &id, folder) {
Ok(msg) => {
self.open = Some((folder, msg));
self.status = None;
}
Err(e) => self.fail(e),
}
if let Ok(lists) = ops::lists(&self.store, &master, &read) {
self.lists = lists;
}
Task::none()
}
Message::FetchPressed => {
if !self.start("fetching") {
return Task::none();
}
let store = self.store.clone();
let master = self.master();
let cancel = self.cancel.clone();
let timeout = self.timeout;
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
ops::fetch(&store, &master, cancel, timeout)
})
.await
.unwrap_or_else(|e| Err(format!("fetching failed: {e}")))
},
Message::Generic,
)
}
Message::ComposePressed => {
self.compose = Some(Compose {
to: String::new(),
subject: String::new(),
body: text_editor::Content::new(),
reply_to: None,
});
Task::none()
}
Message::ReplyPressed => {
let Some((_, msg)) = &self.open else {
return Task::none();
};
// A fingerprint-only sender left no address to reply to.
let to = if let Some(uri) = &msg.reply_to {
uri.clone()
} else if msg.from.starts_with('<') {
self.fail("this sender left no address to reply to".into());
return Task::none();
} else {
msg.from.clone()
};
let subject = if msg.subject.is_empty() || msg.subject.starts_with("Re:") {
msg.subject.clone()
} else {
format!("Re: {}", msg.subject)
};
self.compose = Some(Compose {
to,
subject,
body: text_editor::Content::new(),
reply_to: Some(msg.id.clone()),
});
Task::none()
}
Message::ComposeToEdited(to) => {
if let Some(compose) = &mut self.compose {
compose.to = to;
}
Task::none()
}
Message::ComposeSubjectEdited(subject) => {
if let Some(compose) = &mut self.compose {
compose.subject = subject;
}
Task::none()
}
Message::ComposeClosed => {
self.compose = None;
Task::none()
}
Message::SendPressed => {
let Some(compose) = &self.compose else {
return Task::none();
};
if compose.to.trim().is_empty() {
self.fail("enter a recipient".into());
return Task::none();
}
let to = compose.to.trim().to_string();
let subject = compose.subject.trim().to_string();
let body = compose.body.text();
let reply_to = compose.reply_to.clone();
self.compose = None;
let timeout = self.timeout;
self.run("sending", move |store, master| {
ops::send(
&store,
&master,
&to,
if subject.is_empty() {
None
} else {
Some(subject.as_str())
},
&body,
reply_to.as_deref(),
timeout,
)
})
}
Message::DeletePressed => {
let Some((folder, msg)) = &self.open else {
return Task::none();
};
self.dialog = Some(Dialog::Delete {
id: msg.id.clone(),
folder: *folder,
kept: msg.kept,
});
Task::none()
}
Message::DeleteCancelled => {
self.dialog = None;
Task::none()
}
Message::DeleteConfirmed(everywhere) => {
let Some(Dialog::Delete { id, folder, kept }) = self.dialog.take() else {
return Task::none();
};
let scope = everywhere && kept;
let timeout = self.timeout;
self.run("deleting", move |store, master| {
ops::delete(&store, &master, &id, folder, scope, timeout)
.map(|_| "deleted".to_string())
})
}
Message::AcceptPressed => {
let Some((folder, msg)) = &self.open else {
return Task::none();
};
if *folder != Folder::Requests {
return Task::none();
}
let Some(uri) = msg.reply_to.clone() else {
self.fail("this sender left no address to accept".into());
return Task::none();
};
let Ok(addr) = Address::parse(&uri) else {
self.fail("the sender's reply address does not parse".into());
return Task::none();
};
let Ok(identity) =
<[u8; KEY_LEN]>::try_from(unb32(&msg.sender).unwrap_or_default())
else {
self.fail("the sender's key does not parse".into());
return Task::none();
};
let address = addr.short();
let timeout = self.timeout;
self.run("accepting", move |store, master| {
ops::accept_contact(&store, &master, &address, &identity, timeout)
.map(|_| format!("accepted {address}"))
})
}
Message::BlockPressed => {
let Some((_, msg)) = &self.open else {
return Task::none();
};
let Some(uri) = msg.reply_to.clone() else {
self.fail("this sender left no address to block".into());
return Task::none();
};
let Ok(addr) = Address::parse(&uri) else {
self.fail("the sender's reply address does not parse".into());
return Task::none();
};
let address = addr.short();
let timeout = self.timeout;
self.run("blocking", move |store, master| {
ops::block_contact(&store, &master, &address, timeout)
.map(|_| format!("blocked {address}"))
})
}
Message::ContactImportEdited(text) => {
self.contact_import = text;
Task::none()
}
Message::ContactImportPressed => {
let uri = self.contact_import.trim().to_string();
if uri.is_empty() {
return Task::none();
}
self.run("importing", move |store, _| {
ops::import_contact(&store, &uri)
.map(|(address, _)| format!("imported {address} (verified)"))
})
}
Message::ContactWrite(address) => {
let key = self
.snapshot
.as_ref()
.and_then(|snap| {
snap.contacts
.iter()
.find(|c| c.address == address)
.map(|c| c.key.clone())
})
.unwrap_or_default();
self.compose = Some(Compose {
// Self-certifying: the sealed copy goes to the key we hold.
to: format!("smol://{address}/{key}"),
subject: String::new(),
body: text_editor::Content::new(),
reply_to: None,
});
Task::none()
}
Message::ContactAccept(address, key) => {
let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default())
else {
return Task::none();
};
let timeout = self.timeout;
self.run("accepting", move |store, master| {
ops::accept_contact(&store, &master, &address, &identity, timeout)
.map(|_| format!("accepted {address}"))
})
}
Message::ContactBlock(address) => {
let timeout = self.timeout;
self.run("blocking", move |store, master| {
ops::block_contact(&store, &master, &address, timeout)
.map(|_| format!("blocked {address}"))
})
}
Message::ContactUnblock(address) => {
// A block keeps the row, so unblocking re-accepts it.
let key = self
.snapshot
.as_ref()
.and_then(|snap| {
snap.contacts
.iter()
.find(|c| c.address == address)
.map(|c| c.key.clone())
})
.unwrap_or_default();
let timeout = self.timeout;
self.run("unblocking", move |store, master| {
let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default())
else {
return Err("contact key does not parse".to_string());
};
ops::accept_contact(&store, &master, &address, &identity, timeout)
.map(|_| format!("unblocked {address}"))
})
}
Message::CopyOwnAddress => {
let Some(snap) = &self.snapshot else {
return Task::none();
};
clipboard::write(format!("smol://{}/{}", snap.who.address, snap.who.key))
}
Message::CopyExport => match &self.backup_text {
Some(text) => clipboard::write(text.clone()),
None => Task::none(),
},
Message::TimeoutEdited(text) => {
if let Ok(timeout) = text.trim().parse::<u64>() {
self.timeout = timeout;
}
self.timeout_text = text;
Task::none()
}
Message::RotatePressed => {
self.dialog = Some(Dialog::Rotate);
Task::none()
}
Message::RotateCancelled => {
self.dialog = None;
Task::none()
}
Message::RotateConfirmed => {
self.dialog = None;
let timeout = self.timeout;
self.run("rotating", move |store, master| {
ops::rotate(&store, &master, timeout).map(|(index, key)| {
format!("rotated to index {index}; new key {}…", &key[..12])
})
})
}
Message::UnpinPressed(label) => self.run("unpinning", move |store, _| {
ops::unpin(&store, &label).map(|_| format!("unpinned {label}"))
}),
Message::ExportPressed => {
let dir = self.dir.clone();
self.run_with("exporting", Message::Exported, move |store, master| {
ops::export_backup(&store, &master, &dir)
.map(|(path, text)| (format!("backup written to {}", path.display()), text))
})
}
Message::Exported(result) => {
self.done();
match result {
Ok((note, text)) => {
self.backup_text = Some(text);
self.note(note);
Task::none()
}
Err(e) => {
self.fail(e);
Task::none()
}
}
}
Message::ImportPressed => {
let text = self.import_body.text();
if text.trim().is_empty() {
return Task::none();
}
self.run("importing", move |store, master| {
ops::import_backup(&store, &master, &text)
})
}
Message::SnapshotLoaded(result) => {
self.done();
match result {
Ok(snap) => {
self.share_qr = qr_code::Data::new(
format!("smol://{}/{}", snap.who.address, snap.who.key).as_bytes(),
)
.ok();
self.lists = snap.lists.clone();
self.snapshot = Some(snap);
Task::none()
}
Err(e) => {
self.fail(e);
Task::none()
}
}
}
Message::Generic(result) => {
self.done();
match result {
Ok(note) => self.refresh_after(note),
Err(e) => {
self.fail(e);
Task::none()
}
}
}
}
}
}

37
src/main.rs Normal file
View file

@ -0,0 +1,37 @@
//! carrier — a minimalist smolmail client built on fumi-core, with an Iced
//! UI. State lives in the config directory: `carrier.key` (the master,
//! 0600), `carrier.db` (fumi-core's sealed store) and `read.txt` (read
//! marks, carrier's own sidecar).
mod app;
mod ops;
mod theme;
mod view;
use std::path::PathBuf;
use app::Carrier;
fn main() -> iced::Result {
let dir = config_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("cannot create {}: {e}", dir.display());
std::process::exit(1);
}
iced::application("carrier", Carrier::update, view::view)
.theme(|_| theme::theme())
.window_size(iced::Size::new(1080.0, 640.0))
.run_with(move || {
let (state, task) = Carrier::boot(dir);
(state, task)
})
}
fn config_dir() -> PathBuf {
match std::env::var("XDG_CONFIG_HOME") {
Ok(xdg) if !xdg.is_empty() => PathBuf::from(xdg).join("carrier"),
_ => PathBuf::from(std::env::var("HOME").unwrap_or_default())
.join(".config")
.join("carrier"),
}
}

672
src/ops.rs Normal file
View file

@ -0,0 +1,672 @@
//! Blocking fumi-core operations and the local files around them. Everything
//! here runs on a worker thread; the UI state only ever sees the plain
//! values these functions return.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use fumi_core::address::{Address, DEFAULT_PORT};
use fumi_core::client::{FetchOptions, TrustChange};
use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN};
use fumi_core::error::Error;
use fumi_core::store::Store;
use fumi_core::{account, client, export, store};
/// One message in a folder list, already opened for its metadata.
#[derive(Debug, Clone)]
pub struct Row {
pub id: String,
pub from: String,
pub subject: String,
pub at: i64,
pub unread: bool,
}
/// A fully opened message.
#[derive(Debug, Clone)]
pub struct OpenedMsg {
pub id: String,
pub from: String,
pub subject: String,
pub time: i64,
pub body: String,
/// The sender's public key, base32 — the identity `accept` freezes.
pub sender: String,
/// The sender's self-certifying address, when the payload carried one.
pub reply_to: Option<String>,
pub kept: bool,
}
#[derive(Debug, Clone)]
pub struct Contact {
pub address: String,
pub key: String,
pub verified: bool,
/// Some(true) accepted, Some(false) blocked, None never decided.
pub accepted: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct Who {
pub address: String,
pub key: String,
pub fingerprint: String,
pub rotations: u32,
}
/// Everything the main pages display, refreshed in one blocking pass.
#[derive(Debug, Clone)]
pub struct Snapshot {
pub lists: Vec<Vec<Row>>,
pub contacts: Vec<Contact>,
pub pins: Vec<(String, [u8; KEY_LEN])>,
pub who: Who,
}
pub fn snapshot(
store: &Store,
master: &[u8; KEY_LEN],
read: &HashSet<String>,
) -> Result<Snapshot, String> {
Ok(Snapshot {
lists: lists(store, master, read)?,
contacts: contacts(store)?,
pins: pins(store)?,
who: whoami(store, master)?,
})
}
/// The three folders, in sidebar order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Folder {
Inbox,
Requests,
Sent,
}
impl Folder {
pub const ALL: [Folder; 3] = [Folder::Inbox, Folder::Requests, Folder::Sent];
fn table(self) -> &'static str {
match self {
Folder::Inbox => "inbox",
Folder::Requests => "requests",
Folder::Sent => "sent",
}
}
}
fn explain(e: Error) -> String {
e.to_string()
}
pub fn new_master() -> [u8; KEY_LEN] {
use rand_core::RngCore;
let mut master = [0u8; KEY_LEN];
rand_core::OsRng.fill_bytes(&mut master);
master
}
pub fn seed_text(master: &[u8; KEY_LEN]) -> String {
b32(master)
}
/// A seed as base32 or 64 hex characters, the two forms a paste can carry.
pub fn parse_seed(text: &str) -> Result<[u8; KEY_LEN], String> {
let text = text.trim();
let bytes = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
let mut raw = Vec::with_capacity(32);
for pair in text.as_bytes().chunks(2) {
let hi = (pair[0] as char).to_digit(16).ok_or("bad hex")?;
let lo = (pair[1] as char).to_digit(16).ok_or("bad hex")?;
raw.push((hi * 16 + lo) as u8);
}
raw
} else {
unb32(text).map_err(|e| explain(e))?
};
bytes
.try_into()
.map_err(|_| "a seed is 32 bytes".to_string())
}
pub fn load_key(path: &Path) -> Result<[u8; KEY_LEN], String> {
let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
raw.try_into()
.map_err(|_| format!("{}: not a 32-byte master", path.display()))
}
pub fn save_key(path: &Path, master: &[u8; KEY_LEN]) -> Result<(), String> {
#[cfg(unix)]
let err = {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| e.to_string())?;
file.write_all(master).map_err(|e| e.to_string())
};
#[cfg(not(unix))]
let err = std::fs::write(path, master).map_err(|e| e.to_string());
err
}
pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result<account::Account, String> {
let index = store.rotations().map_err(explain)?;
account::Account::new(*master, index).map_err(explain)
}
pub fn parse_address(text: &str) -> Result<Address, String> {
Address::parse(text).map_err(explain)
}
/// A base32 public key, the form the operator publishes and pins are shown in.
pub fn parse_key(text: &str) -> Result<[u8; KEY_LEN], String> {
unb32(text.trim())
.map_err(explain)?
.try_into()
.map_err(|_| "keys are 32 bytes of base32".to_string())
}
pub fn register(
store: &Store,
master: &[u8; KEY_LEN],
addr: &Address,
invite: Option<&str>,
timeout: u64,
) -> Result<(), String> {
let me = account(store, master)?;
client::register(store, addr, &me, invite, timeout).map_err(explain)
}
pub fn restore(
store: &Store,
master: &[u8; KEY_LEN],
addr: &Address,
timeout: u64,
) -> Result<u32, String> {
client::restore(store, master, addr, timeout).map_err(explain)
}
/// Connects without requiring a pin and reports the static key the server
/// presented, so the user can decide to pin it (sec 4 trust on first use).
/// None when a pin already covers this host.
pub fn learn_key(
store: &Store,
addr: &Address,
timeout: u64,
) -> Result<Option<[u8; KEY_LEN]>, String> {
if store
.server_pin(&addr.host, addr.port)
.map_err(explain)?
.is_some()
{
return Ok(None);
}
let mut session = client::connect(store, addr, false, timeout).map_err(explain)?;
let offered = session.unpinned_static;
session.close();
Ok(offered)
}
pub fn pin(store: &Store, addr: &Address, key: &[u8; KEY_LEN]) -> Result<(), String> {
store
.pin_server(&addr.host, addr.port, key)
.map_err(explain)
}
/// Splits a stored pin label (`host` or `host:port`) back into its parts.
fn label_parts(label: &str) -> (String, u16) {
match label.rsplit_once(':') {
Some((host, port)) => (host.to_string(), port.parse().unwrap_or(DEFAULT_PORT)),
None => (label.to_string(), DEFAULT_PORT),
}
}
pub fn unpin(store: &Store, label: &str) -> Result<(), String> {
let (host, port) = label_parts(label);
store.unpin_server(&host, port).map_err(explain)
}
pub fn pins(store: &Store) -> Result<Vec<(String, [u8; KEY_LEN])>, String> {
store.pins().map_err(explain)
}
/// Fetches with the default cursor policy, returning a one-line summary.
pub fn fetch(
store: &Store,
master: &[u8; KEY_LEN],
cancel: Arc<AtomicBool>,
timeout: u64,
) -> Result<String, String> {
let me = account(store, master)?;
let opts = FetchOptions {
keep: false,
reset: false,
timeout,
dial: None,
cancel: Some(&cancel),
};
let fetched = client::fetch_with(store, &me, opts).map_err(explain)?;
let mut note = format!("{} fetched, {} stored", fetched.total, fetched.stored);
if !fetched.rejected.is_empty() {
note.push_str(&format!(", {} rejected", fetched.rejected.len()));
}
if fetched.cancelled {
note.push_str(" (cancelled)");
}
Ok(note)
}
pub fn send(
store: &Store,
master: &[u8; KEY_LEN],
to: &str,
subject: Option<&str>,
body: &str,
reply_to: Option<&str>,
timeout: u64,
) -> Result<String, String> {
let addr = parse_address(to)?;
let me = account(store, master)?;
let draft = client::SendDraft {
address: &addr,
text: body.to_string(),
subject,
reply_to,
headers: &[],
anonymous: false,
no_pad: false,
};
let sent = client::send(store, &me, &draft, timeout).map_err(explain)?;
let change = match sent.change {
TrustChange::New { unverified: true } => {
"; new contact learned from an unpinned server (unverified)"
}
TrustChange::New { unverified: false } => "; new contact learned (trust on first use)",
TrustChange::Rotated => "; contact rotated to a new key (chain verified)",
TrustChange::None => "",
};
let mut note = format!("sent to {} ({} bytes)", addr.short(), sent.bytes);
if sent.token_used {
note.push_str(", accept token attached");
}
note.push_str(change);
if let Some(warning) = sent.warning {
note.push_str(&format!("; {warning}"));
}
Ok(note)
}
/// Removes one message locally, and on the server when `everywhere` is set.
pub fn delete(
store: &Store,
master: &[u8; KEY_LEN],
id: &str,
folder: Folder,
everywhere: bool,
timeout: u64,
) -> Result<(), String> {
let raw = hex32(id)?;
store.delete_local(folder.table(), &raw).map_err(explain)?;
if everywhere {
let me = account(store, master)?;
client::delete(store, &me, &[raw], None, timeout).map_err(explain)?;
}
Ok(())
}
/// Admits a correspondent to the main tier and pushes the changed token set.
pub fn accept_contact(
store: &Store,
master: &[u8; KEY_LEN],
address: &str,
identity: &[u8; KEY_LEN],
timeout: u64,
) -> Result<(), String> {
store.accept(address, identity).map_err(explain)?;
let me = account(store, master)?;
client::push_tokens(store, &me, None, timeout).map_err(explain)?;
Ok(())
}
/// Blocks a correspondent and pushes the changed token set.
pub fn block_contact(
store: &Store,
master: &[u8; KEY_LEN],
address: &str,
timeout: u64,
) -> Result<bool, String> {
let blocked = store.block(address).map_err(explain)?;
let me = account(store, master)?;
client::push_tokens(store, &me, None, timeout).map_err(explain)?;
Ok(blocked)
}
/// Adds a contact from a self-certifying `smol://` address.
pub fn import_contact(store: &Store, uri: &str) -> Result<(String, String), String> {
let addr = parse_address(uri)?;
let key = addr
.identity
.ok_or("import needs a self-certifying smol:// address carrying a key")?;
store
.save_contact(&addr.short(), &key, true)
.map_err(explain)?;
Ok((addr.short(), b32(&key)))
}
pub fn contacts(store: &Store) -> Result<Vec<Contact>, String> {
Ok(store
.contact_rows()
.map_err(explain)?
.into_iter()
.map(|(address, identity, verified, accepted)| Contact {
address,
key: b32(&identity),
verified,
accepted: accepted.map(|state| state == 1),
})
.collect())
}
pub fn rotate(
store: &Store,
master: &[u8; KEY_LEN],
timeout: u64,
) -> Result<(u32, String), String> {
let me = account(store, master)?;
let rotated = client::rotate(store, &me, None, timeout).map_err(explain)?;
Ok((store.rotations().map_err(explain)?, b32(&rotated.new_pk)))
}
/// Writes the gsmol backup beside the store and returns it for the clipboard.
pub fn export_backup(
store: &Store,
master: &[u8; KEY_LEN],
dir: &Path,
) -> Result<(PathBuf, String), String> {
let text = export::export(store, master).map_err(explain)?;
let path = dir.join(format!("carrier-backup-{}.gsmol", store::now()));
std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
Ok((path, text))
}
pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<String, String> {
let summary = export::import(store, master, text).map_err(explain)?;
Ok(summary.to_string())
}
fn hex32(text: &str) -> Result<[u8; KEY_LEN], String> {
let raw = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
(0..32)
.map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
} else {
return Err("message ids are 64 hex characters".to_string());
};
raw.try_into()
.map_err(|_| "message ids are 32 bytes".to_string())
}
/// Lists one folder as rows, marking unread against the local read set.
fn rows(store: &Store, me: &account::Account, folder: Folder, read: &HashSet<String>) -> Vec<Row> {
store
.mail(folder.table())
.unwrap_or_default()
.into_iter()
.filter_map(|stored| {
let id = b32_hex(&stored.id);
let described = client::describe(store, me, &stored).ok()?;
let from = match folder {
Folder::Sent => stored.recipient.clone().unwrap_or_default(),
_ => described.from,
};
let unread = !read.contains(&id);
Some(Row {
id,
from,
subject: described.subject,
at: described.time,
unread,
})
})
.collect()
}
/// All three folders at once, so the sidebar counts never go stale.
pub fn lists(
store: &Store,
master: &[u8; KEY_LEN],
read: &HashSet<String>,
) -> Result<Vec<Vec<Row>>, String> {
let me = account(store, master)?;
Ok(Folder::ALL
.iter()
.map(|folder| rows(store, &me, *folder, read))
.collect())
}
pub fn open(
store: &Store,
master: &[u8; KEY_LEN],
id: &str,
folder: Folder,
) -> Result<OpenedMsg, String> {
let raw = hex32(id)?;
let me = account(store, master)?;
let stored = store
.mail(folder.table())
.map_err(explain)?
.into_iter()
.find(|m| m.id == raw)
.ok_or_else(|| "message not found".to_string())?;
let described = client::describe(store, &me, &stored).map_err(explain)?;
Ok(OpenedMsg {
id: id.to_string(),
from: match folder {
Folder::Sent => stored.recipient.clone().unwrap_or_default(),
_ => described.from,
},
subject: described.subject,
time: described.time,
body: described.text,
sender: b32(&described.sender),
reply_to: described.fields.get("reply-to").cloned(),
kept: stored.kept,
})
}
pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result<Who, String> {
let me = account(store, master)?;
let address = store
.account()
.map_err(explain)?
.map(|addr| addr.short())
.unwrap_or_default();
Ok(Who {
address,
key: b32(&me.me().pk()),
fingerprint: fingerprint(&me.me().pk()),
rotations: me.index(),
})
}
fn b32_hex(raw: &[u8; KEY_LEN]) -> String {
raw.iter().map(|b| format!("{b:02x}")).collect()
}
/// UTC stamp for an epoch-second timestamp; mail is not minute-critical and
/// the writer's zone is unknown, so UTC is the honest display.
pub fn when(at: i64) -> String {
let days = at.div_euclid(86400);
let secs = at.rem_euclid(86400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
secs / 3600,
(secs % 3600) / 60
)
}
/// Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if month <= 2 { year + 1 } else { year }, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
use fumi_core::message::{build_frontmatter, message_id, seal};
use fumi_core::store::now;
fn master() -> [u8; KEY_LEN] {
let mut seed = [0u8; KEY_LEN];
for (i, b) in seed.iter_mut().enumerate() {
*b = i as u8;
}
seed
}
fn setup() -> Store {
Store::open_in_memory().expect("in-memory store")
}
#[test]
fn seed_round_trips_through_base32_and_hex() {
let m = master();
assert_eq!(parse_seed(&seed_text(&m)).unwrap(), m);
let hex: String = m.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(parse_seed(&hex).unwrap(), m);
assert!(parse_seed("not a seed").is_err());
assert!(parse_seed(&hex[..63]).is_err());
}
#[test]
fn key_parsing_round_trips() {
let m = master();
assert_eq!(parse_key(&b32(&m)).unwrap(), m);
assert!(parse_key("short").is_err());
}
/// A sealed self-copy in the store surfaces as a row, opens with its
/// frontmatter split, and flips to read against the marks set.
#[test]
fn list_and_open_round_trip() {
let store = setup();
let m = master();
let me = account(&store, &m).unwrap();
let addr = Address::parse("me@localhost").unwrap();
store.set_account(&addr).unwrap();
let body = build_frontmatter(
&[("Subject", "hello"), ("Reply-To", &addr.uri(&me.me().pk()))],
"the body text",
);
let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap();
let id = message_id(&envelope);
store
.store_inbox(&id, &envelope, now(), false, false)
.unwrap();
let id_hex: String = id.iter().map(|b| format!("{b:02x}")).collect();
let empty = HashSet::new();
let folders = lists(&store, &m, &empty).unwrap();
let inbox = &folders[0];
assert_eq!(inbox.len(), 1);
assert_eq!(inbox[0].subject, "hello");
assert_eq!(inbox[0].from, "me@localhost");
assert!(inbox[0].unread);
let opened = open(&store, &m, &id_hex, Folder::Inbox).unwrap();
assert_eq!(opened.subject, "hello");
assert_eq!(opened.body, "the body text");
assert!(opened.reply_to.is_some());
let mut read = HashSet::new();
read.insert(id_hex);
let folders = lists(&store, &m, &read).unwrap();
assert!(!folders[0][0].unread);
}
#[test]
fn import_contact_saves_verified() {
let store = setup();
let me = account(&store, &master()).unwrap();
let addr = Address::parse("me@localhost").unwrap();
let uri = addr.uri(&me.me().pk());
import_contact(&store, &uri).unwrap();
let rows = contacts(&store).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].address, "me@localhost");
assert!(rows[0].verified);
// A short form carries no key, so importing it is an error.
assert!(import_contact(&store, "me@localhost").is_err());
}
#[test]
fn backup_round_trips() {
let dir = tempfile::tempdir().unwrap();
let store = setup();
let m = master();
let me = account(&store, &m).unwrap();
let addr = Address::parse("me@localhost").unwrap();
store.set_account(&addr).unwrap();
let body = build_frontmatter(&[("Subject", "kept")], "sealed body");
let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap();
let id = message_id(&envelope);
store
.store_inbox(&id, &envelope, now(), false, false)
.unwrap();
let (path, text) = export_backup(&store, &m, dir.path()).unwrap();
assert!(path.exists());
assert!(!text.is_empty());
let fresh = setup();
let summary = import_backup(&fresh, &m, &text).unwrap();
assert!(summary.contains("1 messages"));
let empty = HashSet::new();
let folders = lists(&fresh, &m, &empty).unwrap();
assert_eq!(folders[0].len(), 1);
assert_eq!(folders[0][0].subject, "kept");
}
#[test]
fn unpin_splits_the_label() {
let store = setup();
let addr = Address::parse("me@localhost:1962").unwrap();
let key = master();
store.pin_server(&addr.host, addr.port, &key).unwrap();
assert_eq!(pin(&store, &addr, &key), Ok(()));
let all = pins(&store).unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].0, "localhost:1962");
unpin(&store, "localhost:1962").unwrap();
assert!(pins(&store).unwrap().is_empty());
}
#[test]
fn stamps_are_utc_civil_dates() {
assert_eq!(when(0), "1970-01-01 00:00");
assert_eq!(when(1_759_279_361), "2025-10-01 00:42");
// Negative stays well-defined (pre-1970).
assert_eq!(when(-1), "1969-12-31 23:59");
}
}

126
src/theme.rs Normal file
View file

@ -0,0 +1,126 @@
//! The one palette the whole UI is built from: dark navy surfaces, light
//! text, mint accents — the same scheme kirakira ships as its dark theme.
use iced::border;
use iced::color;
use iced::theme::Palette;
use iced::widget::{button, container};
use iced::{Border, Color, Theme};
pub const BACKGROUND: Color = color!(0x01111d);
pub const PANEL: Color = color!(0x0a1c2e);
pub const CARD: Color = color!(0x10263b);
pub const ACCENT: Color = color!(0x3ddc97);
pub const TEXT: Color = color!(0xdce7f2);
pub const MUTED: Color = color!(0x7a90a5);
pub const DANGER: Color = color!(0xf0564a);
pub fn theme() -> Theme {
Theme::custom(
"carrier".to_string(),
Palette {
background: BACKGROUND,
text: TEXT,
primary: ACCENT,
success: ACCENT,
danger: DANGER,
},
)
}
/// The surface of the navigation sidebar.
pub fn panel(_theme: &Theme) -> container::Style {
container::Style {
background: Some(PANEL.into()),
text_color: Some(TEXT),
..container::Style::default()
}
}
/// A card slightly lighter than its panel.
pub fn card(_theme: &Theme) -> container::Style {
container::Style {
background: Some(CARD.into()),
text_color: Some(TEXT),
border: border::rounded(6),
..container::Style::default()
}
}
/// The status line at the bottom of the window.
pub fn status(error: bool) -> impl Fn(&Theme) -> container::Style {
move |_theme| container::Style {
background: Some(PANEL.into()),
text_color: Some(if error { DANGER } else { MUTED }),
..container::Style::default()
}
}
/// The primary action button: mint on navy.
pub fn accent() -> impl Fn(&Theme, button::Status) -> button::Style {
move |_theme, status| {
let hovered = matches!(status, button::Status::Hovered);
button::Style {
background: Some(if hovered { color!(0x54e8a8) } else { ACCENT }.into()),
text_color: BACKGROUND,
border: border::rounded(4),
..button::Style::default()
}
}
}
/// A quiet secondary button: filled with the panel color, no border.
pub fn quiet() -> impl Fn(&Theme, button::Status) -> button::Style {
move |_theme, status| {
let hovered = matches!(status, button::Status::Hovered);
button::Style {
background: Some(if hovered { CARD } else { PANEL }.into()),
text_color: TEXT,
border: border::rounded(4),
..button::Style::default()
}
}
}
/// A destructive action, red text on a quiet surface.
pub fn danger() -> impl Fn(&Theme, button::Status) -> button::Style {
move |_theme, status| {
let hovered = matches!(status, button::Status::Hovered);
button::Style {
background: Some(if hovered { color!(0x2a1420) } else { PANEL }.into()),
text_color: DANGER,
border: border::rounded(4),
..button::Style::default()
}
}
}
/// A single list row; the selected row carries the accent border.
pub fn row_style(selected: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |_theme, status| {
let hovered = matches!(status, button::Status::Hovered);
button::Style {
background: Some(
if selected {
CARD
} else if hovered {
color!(0x0d2136)
} else {
PANEL
}
.into(),
),
text_color: TEXT,
border: if selected {
Border {
color: ACCENT,
width: 1.0,
radius: 4.0.into(),
}
} else {
border::rounded(4)
},
..button::Style::default()
}
}
}

818
src/view.rs Normal file
View file

@ -0,0 +1,818 @@
//! The view: a compact horizontal split — a tab bar, the full-width list
//! above, the article below, and a status line at the bottom edge. Protocol
//! values (addresses, keys, ids, dates) are monospace; prose stays sans.
use iced::widget::text::Wrapping;
use iced::widget::{
button, column, container, qr_code, row, rule, scrollable, text, text_editor, text_input, Space,
};
use iced::{Alignment, Element, Font, Length, Padding};
use fumi_core::crypto::{b32, fingerprint};
use crate::app::{Carrier, Dialog, Message, OnboardMode, Page};
use crate::ops::{self, Contact, Folder, Row};
use crate::theme::{self, ACCENT, MUTED, TEXT};
/// Column widths of the list, in pixels; monospace keeps them aligned.
const DATE_W: f32 = 104.0;
const FLAG_W: f32 = 24.0;
const FROM_W: f32 = 248.0;
const ADDR_W: f32 = 260.0;
const KEY_W: f32 = 248.0;
const STATE_W: f32 = 88.0;
pub fn view(app: &Carrier) -> Element<'_, Message> {
let content: Element<'_, Message> = if app.compose.is_some() {
compose(app)
} else {
match app.page {
Page::Onboard => onboard(app),
Page::Signup => signup(app),
Page::Mail => mail(app),
Page::Contacts => contacts(app),
Page::Settings => settings(app),
}
};
column![content, dialog_banner(app), status(app)]
.spacing(0)
.height(Length::Fill)
.width(Length::Fill)
.into()
}
// --- shared pieces ---------------------------------------------------------
/// One line of monospace UI text.
fn mono<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
text(content.to_string())
.font(Font::MONOSPACE)
.size(12)
.color(TEXT)
}
fn muted<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
text(content.to_string())
.font(Font::MONOSPACE)
.size(12)
.color(MUTED)
}
fn accent<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
text(content.to_string())
.font(Font::MONOSPACE)
.size(12)
.color(ACCENT)
}
/// A fixed-width cell so every row lines up like a table column.
fn cell<'a>(content: iced::widget::Text<'a>, width: f32) -> Element<'a, Message> {
container(content)
.width(width)
.align_x(Alignment::Start)
.into()
}
fn action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
button(text(label.to_string()).size(12))
.on_press(msg)
.style(theme::accent())
.padding([3, 10])
.into()
}
fn action_disabled<'a>(label: &str) -> Element<'a, Message> {
button(text(label.to_string()).size(12))
.padding([3, 10])
.style(theme::quiet())
.into()
}
fn quiet_action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
button(text(label.to_string()).size(12))
.on_press(msg)
.style(theme::quiet())
.padding([3, 10])
.into()
}
fn danger_action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
button(text(label.to_string()).size(12))
.on_press(msg)
.style(theme::danger())
.padding([3, 10])
.into()
}
/// A tab entry: accent text when active, muted otherwise.
fn tab<'a>(label: impl std::fmt::Display, active: bool, msg: Message) -> Element<'a, Message> {
let label = label.to_string();
button(
text(label)
.font(Font::MONOSPACE)
.size(12)
.color(if active { ACCENT } else { MUTED }),
)
.on_press(msg)
.padding([3, 6])
.into()
}
/// The bar every main page shares: identity, folder tabs, page tabs, actions.
fn top_bar<'a>(app: &'a Carrier) -> Element<'a, Message> {
let address = app
.snapshot
.as_ref()
.map(|snap| snap.who.address.clone())
.unwrap_or_default();
let unread = |folder: usize| {
app.lists
.get(folder)
.map(|rows| rows.iter().filter(|r| r.unread).count())
.unwrap_or(0)
};
let fetching = app.busy.as_deref() == Some("fetching");
let mut tabs = row![].spacing(2);
for (folder, label) in [
(Folder::Inbox, "Inbox"),
(Folder::Requests, "Requests"),
(Folder::Sent, "Sent"),
] {
let count = unread(folder as usize);
let label = if count > 0 {
format!("{label} {count}")
} else {
label.to_string()
};
tabs = tabs.push(tab(
label,
app.folder == folder && app.page == Page::Mail,
Message::GotoMail(folder),
));
}
tabs = tabs
.push(tab(
"Contacts",
app.page == Page::Contacts,
Message::Goto(Page::Contacts),
))
.push(tab(
"Settings",
app.page == Page::Settings,
Message::Goto(Page::Settings),
));
let fetch = if fetching {
action_disabled("Fetching…")
} else if app.busy.is_some() {
action_disabled("Fetch")
} else {
action("Fetch", Message::FetchPressed)
};
let write = if app.busy.is_some() {
action_disabled("Write")
} else {
action("Write", Message::ComposePressed)
};
container(
row![
accent("carrier"),
mono(address),
Space::with_width(Length::Fill),
tabs,
Space::with_width(Length::Fill),
fetch,
write,
]
.spacing(10)
.align_y(Alignment::Center),
)
.style(theme::panel)
.padding([6, 10])
.width(Length::Fill)
.into()
}
/// The one status line: the busy label with its cancel button while an
/// operation runs, otherwise the last result or error.
fn status<'a>(app: &'a Carrier) -> Element<'a, Message> {
let mut line = row![].spacing(10).align_y(Alignment::Center);
match (&app.busy, &app.status) {
(Some(busy), _) => {
line = line.push(muted(format!("{busy}…")));
if busy == "fetching" {
line = line.push(quiet_action("Cancel", Message::FetchCancelled));
}
}
(None, Some(status)) => {
let piece = if app.status_error {
text(status.clone()).font(Font::MONOSPACE).size(12)
} else {
muted(status.clone())
};
line = line.push(piece);
}
(None, None) => {}
}
container(line)
.style(theme::status(app.status_error && app.busy.is_none()))
.padding([4, 10])
.width(Length::Fill)
.into()
}
/// Inline confirmations: a slim banner between the content and the status.
fn dialog_banner<'a>(app: &'a Carrier) -> Element<'a, Message> {
let Some(dialog) = &app.dialog else {
return row![].into();
};
let mut line = row![].spacing(8).align_y(Alignment::Center);
match dialog {
Dialog::Delete { kept, .. } => {
line = line.push(muted("delete this message:"));
line = line.push(danger_action("here", Message::DeleteConfirmed(false)));
if *kept {
line = line.push(danger_action("everywhere", Message::DeleteConfirmed(true)));
}
line = line.push(quiet_action("cancel", Message::DeleteCancelled));
}
Dialog::Rotate => {
line = line.push(muted(
"rotate the identity? the old key stays derivable; the index never moves back",
));
line = line.push(action("rotate", Message::RotateConfirmed));
line = line.push(quiet_action("cancel", Message::RotateCancelled));
}
}
container(line)
.style(theme::status(false))
.padding([4, 10])
.width(Length::Fill)
.into()
}
fn pin_offer_banner<'a>(app: &'a Carrier) -> Element<'a, Message> {
let Some((_, key)) = &app.pin_offer else {
return row![].into();
};
row![
muted("server key offered:"),
mono(fingerprint(key)),
mono(short(&b32(key), 24)),
action("pin", Message::PinAccept),
quiet_action("discard", Message::PinDiscard),
]
.spacing(8)
.align_y(Alignment::Center)
.into()
}
// --- mail ------------------------------------------------------------------
fn mail<'a>(app: &'a Carrier) -> Element<'a, Message> {
column![
top_bar(app),
container(list_pane(app)).height(Length::FillPortion(38)),
rule::Rule::horizontal(1.0),
container(detail_pane(app)).height(Length::FillPortion(62)),
]
.spacing(0)
.height(Length::Fill)
.into()
}
fn list_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
let rows = app
.lists
.get(app.folder as usize)
.cloned()
.unwrap_or_default();
let header = row![
cell(muted("date"), DATE_W),
cell(muted("f"), FLAG_W),
cell(muted("from/to"), FROM_W),
muted("subject"),
]
.spacing(10)
.padding(padding());
let mut list = column![header].spacing(1);
if rows.is_empty() {
let hint = match app.folder {
Folder::Inbox => "inbox is empty — fetch to check the mailbox",
Folder::Requests => "no requests",
Folder::Sent => "nothing sent yet",
};
list = list.push(container(muted(hint)).padding(Padding::new(10.0)));
}
for row in &rows {
let selected = app
.open
.as_ref()
.is_some_and(|(folder, msg)| *folder == app.folder && msg.id == row.id);
list = list.push(list_row(app.folder, row, selected));
}
scrollable(list).height(Length::Fill).into()
}
/// One compact row: date, flags, from/to, subject — a flat button so the
/// whole line is clickable like a mu4e header.
fn list_row<'a>(folder: Folder, row: &Row, selected: bool) -> Element<'a, Message> {
let flag = if row.unread {
accent("●")
} else {
muted("·")
};
let date = ops::when(row.at);
button(
row![
cell(mono(date), DATE_W),
cell(flag, FLAG_W),
cell(mono(truncate(&row.from, 32)), FROM_W),
text(truncate(&row.subject, 64))
.font(Font::MONOSPACE)
.size(12)
.color(if row.unread { TEXT } else { MUTED })
.wrapping(Wrapping::None),
]
.spacing(10)
.align_y(Alignment::Center),
)
.on_press(Message::MessageSelected(folder, row.id.clone()))
.style(theme::row_style(selected))
.padding(padding())
.width(Length::Fill)
.into()
}
fn padding() -> Padding {
Padding::new(3.0).right(10.0).left(10.0)
}
/// A `label: value` header line for the article view.
fn header_line<'a>(label: &str, value: impl std::fmt::Display) -> Element<'a, Message> {
row![muted(format!("{label:<9}")), mono(value),]
.spacing(4)
.into()
}
fn detail_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
let Some((folder, msg)) = &app.open else {
return container(muted("select a message to read it"))
.center(Length::Fill)
.into();
};
let mut headers = column![].spacing(1);
let to_label = if *folder == Folder::Sent {
"to"
} else {
"from"
};
headers = headers.push(header_line(to_label, &msg.from));
headers = headers.push(header_line(
"subject",
if msg.subject.is_empty() {
"(no subject)".to_string()
} else {
msg.subject.clone()
},
));
headers = headers.push(header_line("date", format!("{} UTC", ops::when(msg.time))));
headers = headers.push(header_line("id", short(&msg.id, 16)));
if msg.kept {
headers = headers.push(header_line("server", "a copy still exists there"));
}
let mut actions = row![].spacing(8);
actions = actions.push(quiet_action("reply", Message::ReplyPressed));
if *folder == Folder::Requests {
if msg.reply_to.is_some() {
actions = actions.push(action("accept", Message::AcceptPressed));
actions = actions.push(danger_action("block", Message::BlockPressed));
} else {
actions = actions.push(muted("no reply address: cannot accept or block"));
}
}
actions = actions.push(danger_action("delete", Message::DeletePressed));
column![
container(headers).padding([8, 10]),
rule::Rule::horizontal(1.0),
scrollable(column![container(
text(msg.body.clone())
.font(Font::MONOSPACE)
.size(12)
.color(TEXT)
)
.padding([8, 10])])
.height(Length::Fill),
rule::Rule::horizontal(1.0),
container(actions).padding([6, 10]),
]
.spacing(0)
.height(Length::Fill)
.into()
}
// --- compose ---------------------------------------------------------------
/// Compose takes the lower pane, keeping the list visible above — writing is
/// the article pane's other job, as in the layout it comes from.
fn compose<'a>(app: &'a Carrier) -> Element<'a, Message> {
let Some(compose) = &app.compose else {
unreachable!("compose view without a draft")
};
let label = if compose.reply_to.is_some() {
"reply"
} else {
"write"
};
column![
top_bar(app),
container(
column![
row![
muted("to"),
text_input("alice@example.org or smol://…", &compose.to)
.on_input(Message::ComposeToEdited)
.size(12)
.width(Length::Fill),
]
.spacing(8),
row![
muted("subject"),
text_input("subject", &compose.subject)
.on_input(Message::ComposeSubjectEdited)
.size(12)
.width(Length::Fill),
]
.spacing(8),
text_editor(&compose.body)
.on_action(Message::ComposeBody)
.font(Font::MONOSPACE)
.size(12)
.height(Length::Fill),
row![
action(label, Message::SendPressed),
quiet_action("cancel", Message::ComposeClosed),
]
.spacing(8),
]
.spacing(6)
)
.padding(10)
.height(Length::Fill),
]
.spacing(0)
.height(Length::Fill)
.into()
}
// --- contacts ---------------------------------------------------------------
fn contacts<'a>(app: &'a Carrier) -> Element<'a, Message> {
let Some(snap) = &app.snapshot else {
return column![top_bar(app)].into();
};
let own = format!("smol://{}/{}", snap.who.address, snap.who.key);
let mut share = row![
muted("you:"),
mono(own),
quiet_action("copy", Message::CopyOwnAddress),
]
.spacing(8)
.align_y(Alignment::Center);
if let Some(data) = &app.share_qr {
share = share.push(container(qr_code(data).cell_size(3)).padding(0));
}
let header = row![
cell(muted("address"), ADDR_W),
cell(muted("key"), KEY_W),
cell(muted("state"), STATE_W),
muted(""),
]
.spacing(10)
.padding(padding());
let mut table = column![header].spacing(1);
for contact in &snap.contacts {
table = table.push(contact_row(contact));
}
if snap.contacts.is_empty() {
table = table.push(
container(muted("no contacts yet — import a smol:// address"))
.padding(Padding::new(10.0)),
);
}
column![
top_bar(app),
container(share).style(theme::panel).padding([6, 10]),
container(
row![
text_input("paste a smol:// address to import", &app.contact_import)
.on_input(Message::ContactImportEdited)
.on_submit(Message::ContactImportPressed)
.size(12)
.width(Length::Fill),
action("import", Message::ContactImportPressed),
]
.spacing(8)
)
.padding([6, 10]),
scrollable(table).height(Length::Fill),
]
.spacing(0)
.height(Length::Fill)
.into()
}
fn contact_row<'a>(contact: &Contact) -> Element<'a, Message> {
let state = match contact.accepted {
Some(true) => "accepted",
Some(false) => "blocked",
None => {
if contact.verified {
"verified"
} else {
"tofu"
}
}
};
let mut actions = row![quiet_action(
"write",
Message::ContactWrite(contact.address.clone()),
)]
.spacing(6);
match contact.accepted {
Some(true) => {
actions = actions.push(danger_action(
"block",
Message::ContactBlock(contact.address.clone()),
));
}
Some(false) => {
actions = actions.push(action(
"unblock",
Message::ContactUnblock(contact.address.clone()),
));
}
None => {
actions = actions.push(action(
"accept",
Message::ContactAccept(contact.address.clone(), contact.key.clone()),
));
}
}
container(
row![
cell(accent(&contact.address), ADDR_W),
cell(mono(short(&contact.key, 32)), KEY_W),
cell(muted(state), STATE_W),
actions,
]
.spacing(10)
.align_y(Alignment::Center),
)
.style(theme::card)
.padding(padding())
.width(Length::Fill)
.into()
}
// --- settings ---------------------------------------------------------------
fn settings<'a>(app: &'a Carrier) -> Element<'a, Message> {
let Some(snap) = &app.snapshot else {
return column![top_bar(app)].into();
};
let who = &snap.who;
let identity = column![
header_line("address", &who.address),
header_line("key", &who.key),
header_line("fingerprint", &who.fingerprint),
header_line("rotations", who.rotations),
row![
action("rotate", Message::RotatePressed),
quiet_action("learn key", Message::SettingsLearnKey),
]
.spacing(8),
]
.spacing(1);
let mut pins = column![].spacing(1);
for (label, key) in &snap.pins {
pins = pins.push(
row![
cell(mono(label), ADDR_W),
cell(mono(short(&b32(key), 32)), KEY_W),
danger_action("unpin", Message::UnpinPressed(label.clone())),
]
.spacing(10)
.align_y(Alignment::Center),
);
}
if snap.pins.is_empty() {
pins = pins.push(muted("no server keys pinned"));
}
// Pinned to the app borrow: the column mixes owned rows with the
// `text_editor` that borrows `app.import_body`.
let mut export: iced::widget::Column<'_, Message> = column![].spacing(4);
export = export.push(
row![
action("export", Message::ExportPressed),
quiet_action("copy", Message::CopyExport),
]
.spacing(8),
);
if let Some(text) = &app.backup_text {
export = export.push(scrollable(mono(short(text, 4000))).height(Length::Fixed(80.0)));
}
export = export.push(muted("import a backup (gsmol): paste it below"));
export = export.push(
text_editor(&app.import_body)
.on_action(Message::ImportBody)
.font(Font::MONOSPACE)
.size(12)
.height(Length::Fixed(100.0)),
);
export = export.push(action("import", Message::ImportPressed));
let sections = column![
section("identity", identity.into()),
section("server keys", pins.into()),
section("backup", export.into()),
section(
"network",
row![
muted("timeout"),
text_input("30", &app.timeout_text)
.on_input(Message::TimeoutEdited)
.size(12)
.width(80),
muted("seconds"),
]
.spacing(8)
.align_y(Alignment::Center)
.into(),
),
]
.spacing(10);
column![
top_bar(app),
pin_offer_banner(app),
scrollable(container(sections).padding(10)).height(Length::Fill),
]
.spacing(0)
.height(Length::Fill)
.into()
}
fn section<'a>(title: &str, body: Element<'a, Message>) -> Element<'a, Message> {
container(
column![container(accent(title)).padding([2, 0]), body]
.spacing(6)
.max_width(720),
)
.style(theme::card)
.padding(10)
.width(Length::Fill)
.into()
}
// --- onboarding and signup ---------------------------------------------------
fn centered<'a>(body: Element<'a, Message>) -> Element<'a, Message> {
container(body)
.center(Length::Fill)
.max_width(520)
.padding(20)
.into()
}
fn onboard<'a>(app: &'a Carrier) -> Element<'a, Message> {
let body = match app.onboard.mode {
OnboardMode::Start => column![
text("carrier").font(Font::MONOSPACE).size(24).color(ACCENT),
muted("smolmail: one seed, sealed mail, five operations"),
row![
action("create an identity", Message::OnboardCreate),
quiet_action("restore from a seed", Message::OnboardRestore),
]
.spacing(8)
.padding([12, 0]),
]
.spacing(10)
.align_x(Alignment::Start),
OnboardMode::Created => column![
muted("this seed is the only secret — write it down now; it is shown once"),
container(
text_input("", &app.onboard.seed_shown)
.font(Font::MONOSPACE)
.size(12)
.width(Length::Fill)
)
.style(theme::card)
.padding(6),
row![
action("i saved it", Message::OnboardSeedSaved),
quiet_action("back", Message::OnboardBack),
]
.spacing(8),
]
.spacing(10),
OnboardMode::Restore => column![
muted("paste the 32-byte seed, base32 or hex"),
text_input("seed", &app.onboard.seed)
.on_input(Message::OnboardSeedEdited)
.on_submit(Message::OnboardRestoreFromSeed)
.size(12)
.width(Length::Fill),
row![
action("restore", Message::OnboardRestoreFromSeed),
quiet_action("back", Message::OnboardBack),
]
.spacing(8),
]
.spacing(10),
};
centered(body.into())
}
fn signup<'a>(app: &'a Carrier) -> Element<'a, Message> {
let mut body = column![
accent("set up the address"),
row![
muted("user"),
text_input("alice", &app.signup.user)
.on_input(Message::SignupUserEdited)
.size(12)
.width(Length::Fill),
]
.spacing(8),
row![
muted("host"),
text_input("example.org", &app.signup.host)
.on_input(Message::SignupHostEdited)
.size(12)
.width(Length::Fill),
]
.spacing(8),
row![
muted("invite"),
text_input("optional token", &app.signup.invite)
.on_input(Message::SignupInviteEdited)
.size(12)
.width(Length::Fill),
]
.spacing(8),
row![
muted("key"),
text_input("server key, if the operator published one", &app.signup.key)
.on_input(Message::SignupKeyEdited)
.size(12)
.width(Length::Fill),
]
.spacing(8),
]
.spacing(8);
if app.signup.key.is_empty() {
body = body.push(
row![
muted("or"),
quiet_action("learn the server key", Message::SignupLearnKey),
muted("and pin what it presents"),
]
.spacing(8)
.align_y(Alignment::Center),
);
}
body = body.push(pin_offer_banner(app));
body = body.push(
row![
action("register", Message::SignupRegister),
quiet_action("i already have this address", Message::SignupRecall),
]
.spacing(8)
.padding([8, 0]),
);
if app.signup.fresh {
body = body.push(quiet_action("back", Message::SignupBack));
}
centered(body.into())
}
// --- small helpers -----------------------------------------------------------
fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
text.to_string()
} else {
let cut: String = text.chars().take(max - 1).collect();
format!("{cut}…")
}
}
fn short(text: &str, max: usize) -> String {
if text.len() <= max {
text.to_string()
} else {
format!("{}…{}", &text[..max / 2], &text[text.len() - 3..])
}
}