//! Application state: the pure core of the client. `handle` maps UI inputs //! to state changes and background operations (`PendingOp`), `handle_result` //! folds an operation's outcome back in — and may chain a refresh. The //! Relm4 component in `view.rs` is a thin adapter over these two functions, //! which keeps the state machine testable without a window server. use std::cell::Cell; use std::collections::HashSet; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use fumi_core::address::{trust_label, Address, Scheme}; use fumi_core::crypto::{fingerprint, unb32, KEY_LEN}; use fumi_core::store::Store; use gtk::prelude::*; use crate::i18n; use crate::ops::{self, Draft, Folder, OpenedMsg, Row, Snapshot}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { Onboard, Signup, Mail, 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, } 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 { 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}"); } } } } /// One account folder, as the switcher lists it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountEntry { pub id: String, /// The registered address; None until signup completes. pub label: Option, /// Whether the folder holds a master another account can reuse. pub has_key: bool, } /// 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 step: SignupStep, pub mode: SignupMode, pub user: String, pub host: String, pub invite: String, /// True once this host's key is known to be pinned — either a fetch /// was just confirmed (see `Carrier::pin_offer`) or it already was /// from an earlier session — so the server step's button reads /// "Continue" instead of "Fetch the server's key". pub key_trusted: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignupStep { Server, Account, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignupMode { New, Existing, } /// The compose view: `to` takes a short address or a smol:// URI. #[derive(Debug, Clone)] pub struct Compose { pub to: String, pub subject: String, pub body: String, /// The message this replies to, as 64 hex characters. pub reply_to: Option, /// Frontmatter rows as typed — `Key: value` text, parsed at send. pub headers: Vec, } /// A correspondent's server that has to be pinned before carrier will /// take their key from it, and the key fetched for it so far. The compose /// and the contact detail both prompt through this one slot: they are /// never on screen together, since selecting a contact stashes the /// compose. Signup keeps its own `pin_offer` — there the address arrives /// late from a live entry, and "already pinned" is a third state this /// struct has no room for. #[derive(Debug, Clone)] pub struct PinPrompt { pub addr: Address, /// None until the fetch lands, Some once the key is on screen. pub offered: Option<[u8; KEY_LEN]>, } #[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, /// Factory reset erases every local file, so it asks first too. Reset, /// The message being answered carries the sender's own frontmatter /// fields; the reply asks whether to carry copies of them. Reply { to: String, subject: String, id: String, rows: Vec, }, /// A `smol://` address whose key is not the one on file for that /// contact. `save_contact` is an upsert and both ways in use it with /// `verified = true`, so a replacement would silently displace a /// known-good key and skip fumi's own rotation-chain check. It is /// confirmed here instead. KeyChange { address: String, offered: [u8; KEY_LEN], known_fingerprint: String, offered_fingerprint: String, resume: KeyChangeResume, }, } /// What confirming a key change goes on to do. #[derive(Debug, Clone)] pub enum KeyChangeResume { Send, Import { uri: String }, } /// Which question a dialog is asking. The alert's response demux keys off /// this instead of one boolean per variant, so a new dialog adds arms /// rather than widening a tuple. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DialogKind { Delete, Rotate, Reset, Reply, KeyChange, } impl Dialog { pub fn kind(&self) -> DialogKind { match self { Dialog::Delete { .. } => DialogKind::Delete, Dialog::Rotate => DialogKind::Rotate, Dialog::Reset => DialogKind::Reset, Dialog::Reply { .. } => DialogKind::Reply, Dialog::KeyChange { .. } => DialogKind::KeyChange, } } } /// UI inputs. Background results arrive as [`OpResult`] instead, so the /// busy guard can never swallow the message that clears the busy flag. #[derive(Debug, Clone)] pub enum Message { // Onboarding OnboardCreate, OnboardRestore, OnboardBack, OnboardSeedChanged(String), OnboardRestoreFromSeed, OnboardSeedSaved, /// Copies the master of the named account into this one, so both /// addresses share a key. OnboardReuse(String), // Accounts AccountSwitch(String), AccountAdd, /// Abandons an account that's still being set up and returns to /// `Carrier::previous`. AccountAddCancelled, // Signup SignupUserChanged(String), SignupHostChanged(String), SignupInviteChanged(String), /// The server step's one button: fetches the host's key, or — once /// one is on screen (fetched this session or already pinned) — pins /// it and advances. SignupKeyAction, SignupStepBack, SignupStartOver, SignupModeChanged(SignupMode), SignupRegister, SignupRecall, // Mail GotoMail(Folder), Goto(Page), MessageSelected(Folder, String), FetchPressed, FetchCancelled, /// A live re-list while a fetch runs: the store commits each envelope /// as it verifies, so the list can show mail arriving mid-fetch. FetchTick, ComposePressed, ComposeToChanged(String), ComposeSubjectChanged(String), ComposeBodyChanged(String), ComposeHeaderAdded, ComposeHeaderChanged(usize, String), ComposeHeaderRemoved(usize), ComposeClosed, SendPressed, /// The pin card's one button, on whichever surface shows it: fetches /// the server's key, then — once it is on screen — pins it, and in a /// compose sends in the same press. PinServerAction, ReplyPressed, ReplyCancelled, ReplyConfirmed(bool), DeletePressed, DeleteConfirmed(bool), DeleteCancelled, AcceptPressed, BlockPressed, // Contacts /// A sidebar contact was clicked: the article pane shows its details. ContactSelected(String), /// The own identity was clicked: the same detail view, this time with /// our own key lines in it. OwnSelected, ContactImportChanged(String), ContactImportPressed, ContactWrite(String), ContactAccept(String, String), ContactBlock(String), ContactUnblock(String), // Settings SecretRevealToggled, CopySecret, KeepMailChanged(bool), StrictPinningChanged(bool), TimeoutChanged(String), RotatePressed, RotateConfirmed, RotateCancelled, ExportBackupTo(PathBuf), ImportBackupFrom(String), ResetPressed, ResetConfirmed, ResetCancelled, KeyChangeConfirmed, KeyChangeCancelled, } /// What a background operation reports back. #[derive(Debug, Clone)] pub enum OpResult { SnapshotLoaded(Result), /// A one-line note, or the failure prose fumi produced. Generic(Result), /// The key a server presented with no pin in place (sec 4 tofu). PinOffered(Result, String>), Pinned(Result<(), String>), } /// One background operation to run on the blocking pool, carrying every /// parameter it needs; the adapter supplies store, master and timeout. #[derive(Debug, Clone)] pub enum PendingOp { Snapshot { read: HashSet, }, Register { addr: Address, invite: Option, }, Restore { addr: Address, }, LearnKey { addr: Address, }, Pin { addr: Address, key: [u8; KEY_LEN], }, Fetch { cancel: Arc, }, Send { to: String, subject: String, body: String, reply_to: Option, headers: Vec<(String, String)>, }, Delete { id: String, folder: Folder, everywhere: bool, }, Accept { address: String, identity: [u8; KEY_LEN], }, Block { address: String, }, ImportContact { uri: String, }, Rotate, Export { path: PathBuf, }, ImportBackup { text: String, }, } pub struct Carrier { /// The config root holding `accounts/` and `active.txt`. pub root: PathBuf, /// The active account's id and folder. pub id: String, pub dir: PathBuf, pub accounts: Vec, /// The account to return to when one still being set up is cancelled. pub previous: Option, pub store: Arc, pub master: Option<[u8; KEY_LEN]>, pub marks: ReadMarks, pub page: Page, pub status: Option, pub status_error: bool, pub busy: Option, pub cancel: Arc, /// Off by default: a fetch acknowledges what it takes, so mail is /// removed from the server once it's stored here. pub keep_mail: bool, /// Refuse a send that would take a recipient's key from a server with /// no pin: `client::resolve` verifies nothing, so such a key is /// whatever answered the connection. pub strict_pinning: bool, pub timeout: u64, pub timeout_text: String, pub onboard: Onboard, pub signup: Signup, pub folder: Folder, pub lists: Vec>, pub open: Option<(Folder, OpenedMsg)>, /// The contact whose details the article pane shows, selected from /// the sidebar's bottom panel; a message selection clears it. pub selected_contact: Option, /// True when the article pane shows our own identity's details. pub selected_own: bool, pub compose: Option, /// Composes set aside by a sidebar trip instead of Cancel, kept in /// the drafts sidecar; the folder only shows once it holds one. pub drafts: Vec, pub snapshot: Option, /// Signup's offer only; correspondents' servers prompt through /// `pin_prompt`. pub pin_offer: Option<(Address, [u8; KEY_LEN])>, pub pin_prompt: Option, /// The Identity tab's recovery secret is masked until this is toggled. pub secret_revealed: bool, pub contact_import: String, pub dialog: Option, /// The view syncs entry text from the model once after a prefill /// (reply, write-to-contact), then leaves typing to the widgets. pub compose_fresh: Cell, /// Same pattern for the onboarding/signup entries: a programmatic /// reset (factory reset, discarding a cancelled identity) clears the /// model, and this flag makes the view push that blank state into /// the entries once, instead of leaving stale typed text on screen. pub form_fresh: Cell, } impl Carrier { /// Opens the account `active.txt` names, else the first one, else a /// fresh empty one. pub fn boot(root: PathBuf) -> (Carrier, Option) { let id = match ops::load_active(&root) .or_else(|| ops::list_accounts(&root).into_iter().next()) { Some(id) => id, None => match ops::new_account(&root) { Ok(id) => id, Err(e) => { eprintln!("cannot create an account in {}: {e}", root.display()); std::process::exit(1); } }, }; Carrier::boot_account(root, id) } pub fn boot_account(root: PathBuf, id: String) -> (Carrier, Option) { let dir = ops::account_dir(&root, &id); if let Err(e) = ops::save_active(&root, &id) { eprintln!("{e}"); } let accounts = load_accounts(&root); // An unregistered account next to registered ones is one being // added, even after a restart, so it always offers a way back. let previous = accounts .iter() .find(|entry| entry.id != id && entry.label.is_some()) .map(|entry| entry.id.clone()); 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 drafts = ops::load_drafts(&dir.join("drafts.txt")); let settings = ops::load_settings(&dir.join("settings.txt")); let previous = previous.filter(|_| !registered); let mut app = Carrier { marks: ReadMarks::load(dir.join("read.txt")), root, id, dir, accounts, previous, store, master, page, status: None, status_error: false, busy: None, cancel: Arc::new(AtomicBool::new(false)), keep_mail: settings.keep_mail, strict_pinning: settings.strict_pinning, timeout: settings.timeout, timeout_text: settings.timeout.to_string(), onboard: Onboard { mode: OnboardMode::Start, seed: String::new(), seed_shown: String::new(), }, signup: Signup { step: SignupStep::Server, mode: SignupMode::New, user: String::new(), host: String::new(), invite: String::new(), key_trusted: false, }, folder: Folder::Inbox, lists: vec![Vec::new(), Vec::new(), Vec::new()], open: None, selected_contact: None, selected_own: false, compose: None, drafts, snapshot: None, pin_offer: None, pin_prompt: None, secret_revealed: false, contact_import: String::new(), dialog: None, compose_fresh: Cell::new(false), form_fresh: Cell::new(true), }; // Through the busy guard, so a switch can't land while this // snapshot is still loading the old account's lists. let op = if registered { app.refresh() } else { None }; (app, op) } /// Accounts other than this one whose master a new account can reuse. pub fn reusable_accounts(&self) -> Vec<&AccountEntry> { self.accounts .iter() .filter(|entry| entry.id != self.id && entry.has_key) .collect() } /// Whether another account already holds this address. Two stores /// fetching one mailbox would split its mail between them. fn address_taken(&self, addr: &Address) -> bool { let short = addr.short(); self.accounts .iter() .any(|entry| entry.id != self.id && entry.label.as_deref() == Some(short.as_str())) } fn master(&self) -> [u8; KEY_LEN] { self.master.unwrap_or([0u8; KEY_LEN]) } fn key_path(&self) -> PathBuf { self.dir.join("carrier.key") } fn drafts_path(&self) -> PathBuf { self.dir.join("drafts.txt") } /// Rewritten on every change: three values in one short file, the /// same whole-file rewrite the drafts sidecar does. fn save_settings(&self) { ops::save_settings( &self.dir.join("settings.txt"), &ops::Settings { keep_mail: self.keep_mail, timeout: self.timeout, strict_pinning: self.strict_pinning, }, ); } /// Every sidebar trip closes the compose this way: a compose with /// anything in it becomes a draft, an empty one is thrown away, and /// Cancel stays the only way to discard work outright. fn stash_draft(&mut self) { let Some(compose) = self.compose.take() else { return; }; let rows = compose.headers.iter().any(|row| !row.trim().is_empty()); if compose.to.trim().is_empty() && compose.subject.trim().is_empty() && compose.body.trim().is_empty() && !rows { return; } self.drafts.push(Draft { id: ops::random_id(), to: compose.to, subject: compose.subject, body: compose.body, reply_to: compose.reply_to, headers: compose.headers, at: fumi_core::store::now(), }); ops::save_drafts(&self.drafts_path(), &self.drafts); self.note(i18n::t("draft-saved-note")); } /// Tapping a draft in the folder puts it back in the compose it came /// from, out of the sidecar, so saving it again cannot fork it. fn open_draft(&mut self, id: &str) { let Some(index) = self.drafts.iter().position(|d| d.id == id) else { return; }; let draft = self.drafts.remove(index); ops::save_drafts(&self.drafts_path(), &self.drafts); self.compose = Some(Compose { to: draft.to, subject: draft.subject, body: draft.body, reply_to: draft.reply_to, headers: draft.headers, }); self.compose_fresh.set(true); } /// Deletes the active account's folder, then boots `next`, or /// whichever account `boot` picks. The connection is swapped out /// before the files go because Windows refuses to remove a file a /// live connection holds, and the busy guard has already seen to it /// that no operation holds a clone of the store. fn remove_active(&mut self, next: Option) -> Result, String> { self.store = Arc::new(Store::open_in_memory().map_err(|e| e.to_string())?); ops::remove_account(&self.root, &self.id)?; let root = self.root.clone(); let (app, op) = match next { Some(id) => Carrier::boot_account(root, id), None => Carrier::boot(root), }; *self = app; Ok(op) } /// Signing out removes this account only; the app moves on to another /// one, or back to onboarding when it was the last. fn factory_reset(&mut self) -> Option { match self.remove_active(None) { Ok(op) => { match self.store.account().ok().flatten() { Some(addr) => self.note(i18n::t1( "logged-out-switched-note", "address", addr.short(), )), None => self.note(i18n::t("logged-out-note")), } op } Err(e) => { self.fail(e); None } } } /// Replaces the model with another account's, keeping nothing of this /// one but what's already on disk. fn switch_to(&mut self, id: String) -> Option { let (app, op) = Carrier::boot_account(self.root.clone(), id); *self = app; op } /// Cancelling out of a not-yet-registered identity removes the key /// file too, so Back always leaves onboarding resumable from a clean /// start — never behind a stale key a later Create could silently /// overwrite and orphan the server registration against. The seed /// already shown (or pasted) still regenerates the same master, so /// nothing is actually lost. fn discard_identity(&mut self) { if self.store.account().ok().flatten().is_none() { if let Err(e) = ops::delete_key(&self.key_path()) { self.fail(e); return; } self.master = None; } self.onboard = Onboard { mode: OnboardMode::Start, seed: String::new(), seed_shown: String::new(), }; self.signup = Signup { step: SignupStep::Server, mode: SignupMode::New, user: String::new(), host: String::new(), invite: String::new(), key_trusted: false, }; self.pin_offer = None; self.page = Page::Onboard; self.form_fresh.set(true); } /// The current folder's rows, drafts included: drafts carry the /// recipient where mail carries the sender, newest first. pub fn rows(&self) -> Vec { if self.folder == Folder::Drafts { let mut rows: Vec = self .drafts .iter() .map(|draft| Row { id: draft.id.clone(), from: draft.to.clone(), subject: draft.subject.clone(), at: draft.at, unread: false, }) .collect(); rows.sort_by_key(|row| -row.at); rows } else { self.lists .get(self.folder as usize) .cloned() .unwrap_or_default() } } 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 the pass-throughs handled before the guard in `handle`. 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; // Stop the fetch ticker: its job ended with the operation. self.cancel.store(true, Ordering::Relaxed); } /// One blocking pass over the store, bringing every page's data home. fn refresh(&mut self) -> Option { if !self.start("refreshing") { return None; } Some(PendingOp::Snapshot { read: self.marks.snapshot(), }) } /// `user@host` as typed on the signup page, defaulting the port. fn signup_address(&self) -> Option
{ 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 recipient's address when sending to it would make carrier take /// their key from a server it has never pinned: no key in the address, /// no contact on file, no pin for the host. A pin is enough to make /// the lookup trustworthy — `connect` rejects a mismatch whatever /// `require_pin` says. RNS has nothing to pin: there the destination /// hash in the address is itself the pin. fn unpinned_first_contact(&self, to: &str) -> Option
{ let addr = Address::parse(to).ok()?; if addr.scheme != Scheme::Tcp || addr.identity.is_some() { return None; } // A store error reads as "not known, not pinned": this gate errs // towards asking. let known = self.store.contact(&addr.short()).ok().flatten().is_some(); let pinned = self .store .server_pin(&addr.host, addr.port) .ok() .flatten() .is_some(); (!known && !pinned).then_some(addr) } /// A self-certifying address whose key differs from the contact on /// file. Both keys are public and about to be shown side by side, so /// a plain comparison is all this needs. fn key_change(&self, text: &str) -> Option<(String, [u8; KEY_LEN], [u8; KEY_LEN])> { let addr = Address::parse(text).ok()?; let offered = addr.identity?; let (known, _) = self.store.contact(&addr.short()).ok().flatten()?; (known != offered).then_some((addr.short(), known, offered)) } /// The key the pin card fetched, if it was fetched for this very /// recipient; taken, so editing the address cannot reuse it. fn take_offered_pin(&mut self, to: &str) -> Option<(Address, [u8; KEY_LEN])> { let wanted = Address::parse(to).ok()?.short(); let prompt = self.pin_prompt.as_ref()?; let key = prompt.offered.filter(|_| prompt.addr.short() == wanted)?; let prompt = self.pin_prompt.take()?; Some((prompt.addr, key)) } /// The host as typed on the signup page's server step, before a /// username exists — `learn_key`/`connect` only read host/port/scheme, /// so a throwaway username satisfies `Address::parse`'s grammar. fn signup_host_address(&self) -> Option
{ let host = self.signup.host.trim(); if host.is_empty() { return None; } Address::parse(&format!("x@{host}")).ok() } /// Opens one message and marks it read inline: opening an envelope and /// re-listing is local and fast, so it needs no background pass. fn select_message(&mut self, folder: Folder, id: String) { self.folder = folder; self.open = None; self.marks.mark(id.clone()); let master = self.master(); let read = self.marks.snapshot(); let id = match ops::open(&self.store, &master, &id, folder) { Ok(msg) => { self.open = Some((folder, msg)); self.status = None; true } Err(e) => { self.fail(e); false } }; if id { if let Ok(lists) = ops::lists(&self.store, &master, &read) { self.lists = lists; } } } pub fn handle(&mut self, message: Message) -> Vec { match message { // Pass-throughs: typing and the tick survive the busy guard. Message::ComposeBodyChanged(body) => { if let Some(compose) = &mut self.compose { compose.body = body; } } Message::ComposeHeaderAdded => { if let Some(compose) = &mut self.compose { compose.headers.push(String::new()); } } Message::ComposeHeaderChanged(index, text) => { if let Some(compose) = &mut self.compose { if let Some(row) = compose.headers.get_mut(index) { *row = text; } } } Message::ComposeHeaderRemoved(index) => { if let Some(compose) = &mut self.compose { if index < compose.headers.len() { compose.headers.remove(index); } } } Message::ContactSelected(address) => { self.stash_draft(); self.selected_own = false; self.selected_contact = Some(address); // The open message stays: the detail shows over it while // its list highlight holds, and picking it again returns. self.page = Page::Mail; } Message::OwnSelected => { self.stash_draft(); self.selected_own = true; self.selected_contact = None; self.page = Page::Mail; } Message::FetchCancelled => { if self.busy.is_some() { self.cancel.store(true, Ordering::Relaxed); } } Message::FetchTick => { if self.busy.as_deref() == Some("fetching") { let master = self.master(); let read = self.marks.snapshot(); if let Ok(lists) = ops::lists(&self.store, &master, &read) { self.lists = lists; } } } Message::OnboardSeedChanged(seed) => { self.onboard.seed = seed; } Message::SignupUserChanged(user) => { self.signup.user = user; } Message::SignupHostChanged(host) => { self.signup.host = host; // A different host invalidates whatever key was on // screen: there is only ever one key pending trust. self.signup.key_trusted = false; self.pin_offer = None; } Message::SignupInviteChanged(invite) => { self.signup.invite = invite; } Message::ContactImportChanged(text) => { self.contact_import = text; } Message::KeepMailChanged(keep) => { self.keep_mail = keep; self.save_settings(); } Message::StrictPinningChanged(strict) => { self.strict_pinning = strict; self.save_settings(); } Message::SecretRevealToggled => { self.secret_revealed = !self.secret_revealed; } Message::TimeoutChanged(text) => { if let Ok(timeout) = text.trim().parse::() { self.timeout = timeout; self.save_settings(); } self.timeout_text = text; } Message::ComposeToChanged(to) => { if let Some(compose) = &mut self.compose { compose.to = to; } } Message::ComposeSubjectChanged(subject) => { if let Some(compose) = &mut self.compose { compose.subject = subject; } } _ if self.busy.is_some() => return Vec::new(), Message::AccountSwitch(id) => { if id == self.id || !self.accounts.iter().any(|entry| entry.id == id) { return Vec::new(); } self.stash_draft(); return self.switch_to(id).into_iter().collect(); } Message::AccountAdd => { self.stash_draft(); match ops::new_account(&self.root) { Ok(id) => { let previous = self.id.clone(); self.switch_to(id); self.previous = Some(previous); } Err(e) => self.fail(e), } } Message::AccountAddCancelled => { let Some(previous) = self.previous.clone() else { return Vec::new(); }; return match self.remove_active(Some(previous)) { Ok(op) => op.into_iter().collect(), Err(e) => { self.fail(e); Vec::new() } }; } Message::OnboardReuse(id) => { if id == self.id { return Vec::new(); } let source = ops::account_dir(&self.root, &id).join("carrier.key"); let copied = ops::load_key(&source) .and_then(|master| ops::save_key(&self.key_path(), &master).map(|()| master)); match copied { Ok(master) => { self.master = Some(master); self.page = Page::Signup; self.note(i18n::t("seed-reused-note")); } Err(e) => self.fail(e), } } Message::OnboardCreate => { let master = ops::new_master(); if let Err(e) = ops::save_key(&self.key_path(), &master) { self.fail(e); } else { self.master = Some(master); self.onboard.seed_shown = ops::seed_text(&master); self.onboard.mode = OnboardMode::Created; self.note(i18n::t("identity-created-note")); } } Message::OnboardRestore => { self.onboard.mode = OnboardMode::Restore; } Message::OnboardBack => { if self.onboard.mode == OnboardMode::Created { self.discard_identity(); } else { self.onboard.mode = OnboardMode::Start; self.onboard.seed.clear(); self.form_fresh.set(true); } } 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); } else { self.master = Some(master); self.page = Page::Signup; self.note(i18n::t("seed-restored-note")); } } Err(e) => self.fail(e), }, Message::OnboardSeedSaved => { self.page = Page::Signup; } Message::SignupStepBack => { self.signup.step = SignupStep::Server; } Message::SignupStartOver => { self.discard_identity(); } Message::SignupModeChanged(mode) => { self.signup.mode = mode; } Message::SignupKeyAction => { if let Some((addr, key)) = self.pin_offer.take() { self.signup.step = SignupStep::Account; return vec![PendingOp::Pin { addr, key }]; } if self.signup.key_trusted { self.signup.step = SignupStep::Account; return Vec::new(); } let Some(addr) = self.signup_host_address() else { self.fail(i18n::t("signup-enter-host-error")); return Vec::new(); }; return vec![PendingOp::LearnKey { addr }]; } Message::SignupRegister => { let Some(addr) = self.signup_address() else { self.fail(i18n::t("signup-enter-user-host-error")); return Vec::new(); }; if self.address_taken(&addr) { self.fail(i18n::t1( "accounts-duplicate-error", "address", addr.short(), )); return Vec::new(); } let invite = if self.signup.invite.is_empty() { None } else { Some(self.signup.invite.clone()) }; if !self.start("registering") { return Vec::new(); } return vec![PendingOp::Register { addr, invite }]; } Message::SignupRecall => { let Some(addr) = self.signup_address() else { self.fail(i18n::t("signup-enter-user-host-error")); return Vec::new(); }; if self.address_taken(&addr) { self.fail(i18n::t1( "accounts-duplicate-error", "address", addr.short(), )); return Vec::new(); } if !self.start("restoring") { return Vec::new(); } return vec![PendingOp::Restore { addr }]; } Message::GotoMail(folder) => { self.stash_draft(); if self.page == Page::Mail && self.folder == folder { return Vec::new(); } self.page = Page::Mail; self.folder = folder; self.open = None; self.selected_own = false; self.selected_contact = None; return self.refresh().into_iter().collect(); } Message::Goto(page) => { self.stash_draft(); self.page = page; self.open = None; self.selected_own = false; self.selected_contact = None; if page == Page::Mail || page == Page::Settings { return self.refresh().into_iter().collect(); } } Message::MessageSelected(folder, id) => { self.selected_own = false; self.selected_contact = None; if folder == Folder::Drafts { self.open_draft(&id); } else { self.select_message(folder, id); } } Message::FetchPressed => { self.stash_draft(); if !self.start("fetching") { return Vec::new(); } return vec![PendingOp::Fetch { cancel: self.cancel.clone(), }]; } Message::ComposePressed => { self.stash_draft(); self.compose = Some(Compose { to: String::new(), subject: String::new(), body: String::new(), reply_to: None, headers: Vec::new(), }); self.compose_fresh.set(true); } Message::ReplyPressed => { let Some((_, msg)) = &self.open else { return Vec::new(); }; // 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(i18n::t("reply-no-address-error")); return Vec::new(); } else { msg.from.clone() }; let subject = if msg.subject.is_empty() || msg.subject.starts_with("Re:") { msg.subject.clone() } else { i18n::t1("reply-subject-prefix", "subject", msg.subject.clone()) }; // The sender's own fields are the one thing a reply has to // decide about: carry copies, or start clean. Only the // fields that would survive send-time validation ask — // a mail whose only extra field is the threading id // replies straight through. let rows = ops::reply_rows(&msg.headers); if rows.is_empty() { self.compose = Some(Compose { to, subject, body: String::new(), reply_to: Some(msg.id.clone()), headers: Vec::new(), }); self.compose_fresh.set(true); } else { self.dialog = Some(Dialog::Reply { to, subject, id: msg.id.clone(), rows, }); } } Message::ReplyCancelled => { self.dialog = None; } Message::ReplyConfirmed(copy) => { let Some(Dialog::Reply { to, subject, id, rows, }) = self.dialog.take() else { return Vec::new(); }; self.compose = Some(Compose { to, subject, body: String::new(), reply_to: Some(id), headers: if copy { rows } else { Vec::new() }, }); self.compose_fresh.set(true); } Message::ComposeClosed => { self.compose = None; } Message::SendPressed => { let Some(compose) = &self.compose else { return Vec::new(); }; if compose.to.trim().is_empty() { self.fail(i18n::t("compose-enter-recipient-error")); return Vec::new(); } let mut headers = Vec::new(); for (index, row) in compose.headers.iter().enumerate() { match ops::header_row(row) { Ok(Some(pair)) => headers.push(pair), Ok(None) => {} Err(reason) => { let mut args = fluent_bundle::FluentArgs::new(); args.set("index", (index + 1) as i64); args.set("reason", reason); self.fail(i18n::t_args("compose-header-row-error", Some(&args))); return Vec::new(); } } } if let Err(reason) = ops::validate_headers(&headers) { self.fail(reason); return Vec::new(); } let to = compose.to.trim().to_string(); // Asked before anything else: a pasted address carrying a // different key is a question about who the recipient is, // which has to settle before how to reach them. if let Some((address, known, offered)) = self.key_change(&to) { self.dialog = Some(Dialog::KeyChange { address, offered, known_fingerprint: fingerprint(&known), offered_fingerprint: fingerprint(&offered), resume: KeyChangeResume::Send, }); return Vec::new(); } let op = PendingOp::Send { to: to.clone(), subject: compose.subject.trim().to_string(), body: compose.body.clone(), reply_to: compose.reply_to.clone(), headers, }; // The card's second press: the key on screen was fetched // for this recipient's server, so pin it here — a local // write — and the send below opens a pinned session. // Writing the pin first is also what stops the re-entry // in PinServerAction from looping back into the gate. if let Some((addr, key)) = self.take_offered_pin(&to) { if let Err(e) = ops::pin(&self.store, &addr, &key) { self.fail(e); return Vec::new(); } } if self.strict_pinning { if let Some(addr) = self.unpinned_first_contact(&to) { let mut args = fluent_bundle::FluentArgs::new(); args.set("address", addr.short()); args.set("host", trust_label(&addr.host, addr.port)); self.fail(i18n::t_args("first-contact-blocked-error", Some(&args))); self.pin_prompt = Some(PinPrompt { addr, offered: None, }); // The compose stays open: its text is the message // the user is still trying to send. return Vec::new(); } } self.pin_prompt = None; self.compose = None; return vec![op]; } Message::PinServerAction => { // The surface is the address: a compose's recipient, or // the contact whose detail is open. Never both at once. let Some(to) = self .compose .as_ref() .map(|compose| compose.to.trim().to_string()) .or_else(|| self.selected_contact.clone()) else { return Vec::new(); }; let Ok(addr) = Address::parse(&to) else { self.fail(i18n::t("address-invalid-error")); return Vec::new(); }; let fetched = self .pin_prompt .as_ref() .is_some_and(|p| p.offered.is_some() && p.addr.short() == addr.short()); if !fetched { self.pin_prompt = Some(PinPrompt { addr: addr.clone(), offered: None, }); return vec![PendingOp::LearnKey { addr }]; } // In a compose the pin is only ever the first half of the // send the user already asked for, and SendPressed writes // it before re-checking the gate — so this does not loop. if self.compose.is_some() { return self.handle(Message::SendPressed); } let Some(prompt) = self.pin_prompt.take() else { return Vec::new(); }; return prompt .offered .map(|key| { vec![PendingOp::Pin { addr: prompt.addr, key, }] }) .unwrap_or_default(); } Message::DeletePressed => { let Some((folder, msg)) = &self.open else { return Vec::new(); }; self.dialog = Some(Dialog::Delete { id: msg.id.clone(), folder: *folder, kept: msg.kept, }); } Message::DeleteCancelled => { self.dialog = None; } Message::DeleteConfirmed(everywhere) => { let Some(Dialog::Delete { id, folder, kept }) = self.dialog.take() else { return Vec::new(); }; return vec![PendingOp::Delete { id, folder, everywhere: everywhere && kept, }]; } Message::AcceptPressed => { let Some((folder, msg)) = &self.open else { return Vec::new(); }; if *folder != Folder::Requests { return Vec::new(); } let Some(uri) = msg.reply_to.clone() else { self.fail(i18n::t("contacts-accept-no-address-error")); return Vec::new(); }; let Ok(addr) = Address::parse(&uri) else { self.fail(i18n::t("contacts-reply-address-invalid-error")); return Vec::new(); }; let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&msg.sender).unwrap_or_default()) else { self.fail(i18n::t("contacts-sender-key-invalid-error")); return Vec::new(); }; return vec![PendingOp::Accept { address: addr.short(), identity, }]; } Message::BlockPressed => { let Some((_, msg)) = &self.open else { return Vec::new(); }; let Some(uri) = msg.reply_to.clone() else { self.fail(i18n::t("contacts-block-no-address-error")); return Vec::new(); }; let Ok(addr) = Address::parse(&uri) else { self.fail(i18n::t("contacts-reply-address-invalid-error")); return Vec::new(); }; return vec![PendingOp::Block { address: addr.short(), }]; } Message::ContactImportPressed => { let uri = self.contact_import.trim().to_string(); if uri.is_empty() { return Vec::new(); } if let Some((address, known, offered)) = self.key_change(&uri) { self.dialog = Some(Dialog::KeyChange { address, offered, known_fingerprint: fingerprint(&known), offered_fingerprint: fingerprint(&offered), resume: KeyChangeResume::Import { uri }, }); return Vec::new(); } return vec![PendingOp::ImportContact { uri }]; } Message::KeyChangeCancelled => { self.dialog = None; } Message::KeyChangeConfirmed => { let Some(Dialog::KeyChange { address, offered, resume, .. }) = self.dialog.take() else { return Vec::new(); }; match resume { // Recording the accepted key is also what keeps the // re-dispatch below from asking the same question. KeyChangeResume::Send => { match ops::save_contact(&self.store, &address, &offered) { Ok(()) => return self.handle(Message::SendPressed), Err(e) => self.fail(e), } } KeyChangeResume::Import { uri } => { return vec![PendingOp::ImportContact { uri }] } } } 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: String::new(), reply_to: None, headers: Vec::new(), }); self.compose_fresh.set(true); } Message::ContactAccept(address, key) => { let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default()) else { return Vec::new(); }; return vec![PendingOp::Accept { address, identity }]; } Message::ContactBlock(address) => { return vec![PendingOp::Block { 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 Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default()) else { return Vec::new(); }; return vec![PendingOp::Accept { address, identity }]; } Message::CopySecret => { let text = ops::seed_text(&self.master()); if let Some(display) = gtk::gdk::Display::default() { display.clipboard().set_text(&text); } } Message::RotatePressed => { self.dialog = Some(Dialog::Rotate); } Message::RotateCancelled => { self.dialog = None; } Message::ResetPressed => { self.dialog = Some(Dialog::Reset); } Message::ResetCancelled => { self.dialog = None; } Message::ResetConfirmed => { self.dialog = None; return self.factory_reset().into_iter().collect(); } Message::RotateConfirmed => { self.dialog = None; return vec![PendingOp::Rotate]; } Message::ExportBackupTo(path) => { return vec![PendingOp::Export { path }]; } Message::ImportBackupFrom(text) => { if text.trim().is_empty() { return Vec::new(); } return vec![PendingOp::ImportBackup { text }]; } } Vec::new() } /// Results always pass — each one clears the operation it belongs to, /// which is why they never travel through the busy-guarded `handle`. pub fn handle_result(&mut self, result: OpResult) -> Option { match result { OpResult::SnapshotLoaded(Ok(snap)) => { self.done(); self.lists = snap.lists.clone(); self.snapshot = Some(snap); None } OpResult::SnapshotLoaded(Err(e)) => { self.done(); self.fail(e); None } OpResult::Generic(Ok(note)) => { self.done(); self.note(note); // The only `Generic` ops reachable from the signup page are // Register and Restore — LearnKey/Pin report through their // own variants — so a success here always means the // address is bound and it's safe to leave the page. if self.page == Page::Signup { self.page = Page::Mail; self.accounts = load_accounts(&self.root); self.previous = None; } self.refresh() } OpResult::Generic(Err(e)) => { self.done(); self.fail(e); None } OpResult::PinOffered(Ok(Some((addr, key)))) => { self.done(); // The prompt is armed synchronously before the fetch is // ever spawned, so an offer that matches it belongs to a // correspondent's server; anything else is signup's. match self .pin_prompt .as_mut() .filter(|prompt| prompt.addr.short() == addr.short()) { Some(prompt) => prompt.offered = Some(key), None => self.pin_offer = Some((addr, key)), } self.note(i18n::t("server-key-offered-note")); None } OpResult::PinOffered(Ok(None)) => { self.done(); // A pin appeared in the meantime: a correspondent prompt // has nothing left to ask, and signup can advance. if self.pin_prompt.take().is_none() { self.signup.key_trusted = true; } self.note(i18n::t("server-key-already-pinned-note")); None } OpResult::PinOffered(Err(e)) => { self.done(); self.fail(e); None } OpResult::Pinned(Ok(())) => { self.done(); self.note(i18n::t("server-key-pinned-note")); // The contact detail shows the pin state, so that pane // needs the fresh row; signup's own pin changes nothing // that is on screen. if self.page == Page::Mail { return self.refresh(); } None } OpResult::Pinned(Err(e)) => { self.done(); self.fail(e); None } } } } fn load_accounts(root: &std::path::Path) -> Vec { ops::list_accounts(root) .into_iter() .map(|id| { let dir = ops::account_dir(root, &id); AccountEntry { label: ops::account_label(&dir), has_key: dir.join("carrier.key").exists(), id, } }) .collect() } #[cfg(test)] mod tests { use super::*; fn app() -> Carrier { let dir = tempfile::tempdir().unwrap().keep(); let (app, _op) = Carrier::boot(dir); app } /// A registered account folder under `root`, with its own master. fn registered(root: &std::path::Path, address: &str) -> String { let id = ops::new_account(root).unwrap(); let dir = ops::account_dir(root, &id); ops::save_key(&dir.join("carrier.key"), &ops::new_master()).unwrap(); let store = Store::open(&dir.join("carrier.db")).unwrap(); store .set_account(&Address::parse(address).unwrap()) .unwrap(); id } /// Two registered accounts, booted into `alice`. fn two_accounts() -> (Carrier, String, String) { let root = tempfile::tempdir().unwrap().keep(); let alice = registered(&root, "alice@example.org"); let bob = registered(&root, "bob@example.org"); ops::save_active(&root, &alice).unwrap(); let (app, _) = Carrier::boot(root); (app, alice, bob) } #[test] fn boot_opens_the_active_account_through_the_busy_guard() { let (app, alice, _) = two_accounts(); assert_eq!(app.id, alice); assert_eq!(app.page, Page::Mail); assert_eq!(app.accounts.len(), 2); assert!(app.previous.is_none()); // The boot snapshot holds the guard, so no switch can overtake it. assert_eq!(app.busy.as_deref(), Some("refreshing")); } #[test] fn switching_keeps_each_accounts_drafts_and_settings_apart() { let (mut app, alice, bob) = two_accounts(); app.busy = None; app.handle(Message::StrictPinningChanged(true)); app.handle(Message::ComposePressed); app.handle(Message::ComposeSubjectChanged("half written".into())); let ops = app.handle(Message::AccountSwitch(bob.clone())); assert!(matches!(ops.as_slice(), [PendingOp::Snapshot { .. }])); assert_eq!(app.id, bob); assert_eq!(ops::load_active(&app.root).as_deref(), Some(bob.as_str())); assert!(app.compose.is_none()); assert!(app.drafts.is_empty()); assert!(!app.strict_pinning); app.busy = None; app.handle(Message::AccountSwitch(alice.clone())); assert_eq!(app.drafts.len(), 1); assert_eq!(app.drafts[0].subject, "half written"); assert!(app.strict_pinning); } #[test] fn switching_waits_for_the_running_operation() { let (mut app, alice, bob) = two_accounts(); app.busy = Some("fetching".into()); assert!(app.handle(Message::AccountSwitch(bob)).is_empty()); assert_eq!(app.id, alice); } #[test] fn adding_an_account_can_reuse_a_secret_or_be_cancelled() { let (mut app, alice, _) = two_accounts(); app.busy = None; app.handle(Message::AccountAdd); assert_eq!(app.page, Page::Onboard); assert_eq!(app.previous.as_deref(), Some(alice.as_str())); assert_eq!(app.accounts.len(), 3); assert_eq!(app.reusable_accounts().len(), 2); let added = app.dir.clone(); app.handle(Message::OnboardReuse(alice.clone())); assert_eq!(app.page, Page::Signup); let alice_master = ops::load_key(&ops::account_dir(&app.root, &alice).join("carrier.key")); assert_eq!(app.master, alice_master.ok()); assert_eq!(ops::load_key(&added.join("carrier.key")).ok(), app.master); app.handle(Message::AccountAddCancelled); assert!(!added.exists()); assert_eq!(app.id, alice); assert_eq!(app.page, Page::Mail); assert_eq!(app.accounts.len(), 2); } /// Quitting halfway through adding an account must not strand the /// user in onboarding with no way back. #[test] fn an_unfinished_account_offers_a_way_back_after_a_restart() { let (mut app, alice, _) = two_accounts(); app.busy = None; app.handle(Message::AccountAdd); let (rebooted, _) = Carrier::boot(app.root.clone()); assert_eq!(rebooted.page, Page::Onboard); assert_eq!(rebooted.previous.as_deref(), Some(alice.as_str())); } #[test] fn an_address_another_account_holds_is_refused() { let (mut app, _, _) = two_accounts(); app.busy = None; app.handle(Message::AccountAdd); app.handle(Message::OnboardCreate); app.handle(Message::OnboardSeedSaved); app.signup.user = "bob".into(); app.signup.host = "example.org".into(); assert!(app.handle(Message::SignupRegister).is_empty()); assert!(app.status_error); assert!(app.handle(Message::SignupRecall).is_empty()); assert!(app.busy.is_none()); } #[test] fn signing_out_of_one_account_moves_to_the_other() { let (mut app, alice, bob) = two_accounts(); app.busy = None; let alice_dir = app.dir.clone(); app.handle(Message::ResetPressed); let ops = app.handle(Message::ResetConfirmed); assert!(matches!(ops.as_slice(), [PendingOp::Snapshot { .. }])); assert!(!alice_dir.exists()); assert_eq!(app.id, bob); assert_eq!(app.page, Page::Mail); assert!(app.accounts.iter().all(|entry| entry.id != alice)); assert!(app.dir.join("carrier.key").exists()); } /// Results clear the busy flag instead of deadlocking: this is the /// regression test for the guard bug the Iced version shipped with. #[test] fn results_clear_the_busy_flag_instead_of_deadlocking() { let mut app = app(); app.busy = Some("registering".into()); let chained = app.handle_result(OpResult::Generic(Ok("registered".into()))); // The note landed and the refresh it triggers is the new busy op. assert_eq!(app.status.as_deref(), Some("registered")); assert_eq!(app.busy.as_deref(), Some("refreshing")); assert!(matches!(chained, Some(PendingOp::Snapshot { .. }))); app.busy = Some("registering".into()); app.handle_result(OpResult::Generic(Err("boom".into()))); assert!(app.busy.is_none()); assert!(app.status_error); app.busy = Some("registering".into()); app.handle_result(OpResult::SnapshotLoaded(Err("boom".into()))); assert!(app.busy.is_none()); } #[test] fn actions_are_swallowed_while_busy() { let mut app = app(); app.master = Some([0u8; KEY_LEN]); app.busy = Some("fetching".into()); let ops = app.handle(Message::FetchPressed); assert!(ops.is_empty()); assert_eq!(app.busy.as_deref(), Some("fetching")); } /// Off by default — a fetch deletes what it takes — and the Settings /// switch is a plain toggle, not busy-guarded like a network action. #[test] fn keep_mail_defaults_off_and_toggles() { let mut app = app(); assert!(!app.keep_mail); app.handle(Message::KeepMailChanged(true)); assert!(app.keep_mail); app.handle(Message::KeepMailChanged(false)); assert!(!app.keep_mail); } fn composing(to: &str) -> Compose { Compose { to: to.into(), subject: "hello".into(), body: "a message".into(), reply_to: None, headers: Vec::new(), } } /// `client::resolve` verifies nothing, so a first contact over a /// server with no pin seals the message to whatever answered. Strict /// mode stops exactly that case, and keeps the compose so the text /// the user is trying to send survives the refusal. #[test] fn strict_pinning_blocks_first_contact_over_an_unpinned_server() { let mut app = app(); app.master = Some([0u8; KEY_LEN]); app.strict_pinning = true; app.compose = Some(composing("bob@example.org")); assert!(app.handle(Message::SendPressed).is_empty()); assert!(app.compose.is_some(), "the draft must survive the refusal"); assert!(app.status_error); let prompt = app.pin_prompt.as_ref().expect("the card must be armed"); assert_eq!(prompt.addr.short(), "bob@example.org"); assert!(prompt.offered.is_none()); // A pin is all the gate wants: `connect` rejects a mismatch // whatever it was asked for, so the lookup is now trustworthy. app.store .pin_server("example.org", 1961, &[7u8; KEY_LEN]) .unwrap(); let ops = app.handle(Message::SendPressed); assert!(matches!(ops.as_slice(), [PendingOp::Send { .. }])); assert!(app.compose.is_none()); } /// The gate only bites where a key would have to be learned: an /// address that carries one, a contact already on file, and RNS — /// where the destination hash is itself the pin — all go straight out. #[test] fn strict_pinning_lets_carried_keys_contacts_and_rns_through() { let mut app = app(); app.master = Some([0u8; KEY_LEN]); app.strict_pinning = true; let key = [3u8; KEY_LEN]; let carried = fumi_core::crypto::b32(&key); app.compose = Some(composing(&format!("smol://bob@example.org/{carried}"))); assert!(matches!( app.handle(Message::SendPressed).as_slice(), [PendingOp::Send { .. }] )); app.store .save_contact("carol@example.org", &key, false) .unwrap(); app.compose = Some(composing("carol@example.org")); assert!(matches!( app.handle(Message::SendPressed).as_slice(), [PendingOp::Send { .. }] )); let hash = "8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a"; app.compose = Some(composing(&format!("smol+rns://dave@{hash}"))); assert!( matches!( app.handle(Message::SendPressed).as_slice(), [PendingOp::Send { .. }] ), "rns has nothing to pin, so the gate must not strand it" ); } #[test] fn strict_pinning_off_sends_over_an_unpinned_server() { let mut app = app(); app.master = Some([0u8; KEY_LEN]); app.handle(Message::StrictPinningChanged(false)); app.compose = Some(composing("bob@example.org")); assert!(matches!( app.handle(Message::SendPressed).as_slice(), [PendingOp::Send { .. }] )); } /// The card's two presses: fetch, then pin and send in one go. The /// offer must land in `pin_prompt` and leave signup's own slot alone, /// and the pin must be written before the send is dispatched — that /// ordering is what keeps the re-entry from looping. #[test] fn the_pin_card_fetches_then_pins_and_sends() { let mut app = app(); app.master = Some([0u8; KEY_LEN]); app.strict_pinning = true; app.compose = Some(composing("bob@example.org")); app.handle(Message::SendPressed); let ops = app.handle(Message::PinServerAction); assert!(matches!(ops.as_slice(), [PendingOp::LearnKey { .. }])); let addr = Address::parse("bob@example.org").unwrap(); let key = [5u8; KEY_LEN]; app.handle_result(OpResult::PinOffered(Ok(Some((addr, key))))); assert!(app.pin_offer.is_none(), "signup's slot must stay empty"); assert_eq!(app.pin_prompt.as_ref().unwrap().offered, Some(key)); let ops = app.handle(Message::PinServerAction); assert!(matches!(ops.as_slice(), [PendingOp::Send { .. }])); assert_eq!( app.store.server_pin("example.org", 1961).unwrap(), Some(key) ); assert!(app.pin_prompt.is_none()); assert!(app.compose.is_none()); } /// A pin that appeared in the meantime retires the prompt without /// touching signup, which shares the result variant. #[test] fn an_already_pinned_server_retires_the_prompt() { let mut app = app(); app.pin_prompt = Some(PinPrompt { addr: Address::parse("bob@example.org").unwrap(), offered: None, }); app.handle_result(OpResult::PinOffered(Ok(None))); assert!(app.pin_prompt.is_none()); assert!(!app.signup.key_trusted); app.handle_result(OpResult::PinOffered(Ok(None))); assert!(app.signup.key_trusted, "an unarmed offer is signup's"); } /// `save_contact` is an upsert, so a pasted address carrying a /// different key would quietly displace a known-good one and skip /// fumi's own rotation-chain check. It has to be confirmed. #[test] fn a_changed_smol_key_asks_before_replacing_a_contact() { let mut app = app(); app.master = Some([0u8; KEY_LEN]); let known = [1u8; KEY_LEN]; let swapped = [2u8; KEY_LEN]; app.store .save_contact("bob@example.org", &known, true) .unwrap(); let uri = format!( "smol://bob@example.org/{}", fumi_core::crypto::b32(&swapped) ); app.compose = Some(composing(&uri)); assert!(app.handle(Message::SendPressed).is_empty()); assert!(matches!( app.dialog, Some(Dialog::KeyChange { resume: KeyChangeResume::Send, .. }) )); // Cancelling leaves both the draft and the stored key alone, and // pressing Send again asks the same question. app.handle(Message::KeyChangeCancelled); assert!(app.dialog.is_none()); assert!(app.compose.is_some()); assert_eq!( app.store.contact("bob@example.org").unwrap().unwrap().0, known ); assert!(app.handle(Message::SendPressed).is_empty()); let ops = app.handle(Message::KeyChangeConfirmed); assert!(matches!(ops.as_slice(), [PendingOp::Send { .. }])); assert_eq!( app.store.contact("bob@example.org").unwrap().unwrap().0, swapped ); assert!(app.compose.is_none()); // The same address carrying the key already on file is not a // change, so it goes straight out. app.compose = Some(composing(&uri)); assert!(matches!( app.handle(Message::SendPressed).as_slice(), [PendingOp::Send { .. }] )); } #[test] fn a_changed_smol_key_asks_before_importing_it() { let mut app = app(); let known = [1u8; KEY_LEN]; let swapped = [2u8; KEY_LEN]; app.store .save_contact("bob@example.org", &known, true) .unwrap(); app.contact_import = format!( "smol://bob@example.org/{}", fumi_core::crypto::b32(&swapped) ); assert!(app.handle(Message::ContactImportPressed).is_empty()); assert!(matches!( app.dialog, Some(Dialog::KeyChange { resume: KeyChangeResume::Import { .. }, .. }) )); assert!(matches!( app.handle(Message::KeyChangeConfirmed).as_slice(), [PendingOp::ImportContact { .. }] )); // An address with no contact on file is not a change either. app.contact_import = format!("smol://new@example.org/{}", fumi_core::crypto::b32(&known)); assert!(matches!( app.handle(Message::ContactImportPressed).as_slice(), [PendingOp::ImportContact { .. }] )); } /// Off by default — this one is opt-in — and a plain toggle, not /// busy-guarded like a network action. #[test] fn strict_pinning_defaults_off_and_toggles() { let mut app = app(); assert!(!app.strict_pinning); app.busy = Some("fetching".into()); app.handle(Message::StrictPinningChanged(true)); assert!(app.strict_pinning); app.handle(Message::StrictPinningChanged(false)); assert!(!app.strict_pinning); } /// Settings are the one piece of state a restart used to forget, /// which would have let a security toggle lapse on every launch. #[test] fn settings_survive_a_reboot() { let dir = tempfile::tempdir().unwrap().keep(); let (mut app, _) = Carrier::boot(dir.clone()); app.handle(Message::StrictPinningChanged(true)); app.handle(Message::KeepMailChanged(true)); app.handle(Message::TimeoutChanged("90".into())); assert!(app.dir.join("settings.txt").exists()); let (rebooted, _) = Carrier::boot(dir); assert!(rebooted.strict_pinning); assert!(rebooted.keep_mail); assert_eq!(rebooted.timeout, 90); assert_eq!(rebooted.timeout_text, "90"); } #[test] fn secret_reveal_toggles() { let mut app = app(); assert!(!app.secret_revealed); app.handle(Message::SecretRevealToggled); assert!(app.secret_revealed); app.handle(Message::SecretRevealToggled); assert!(!app.secret_revealed); } /// Export/Import now go through a native file dialog rather than a /// paste box, so the model only ever sees the chosen path/text — /// an empty import (e.g. an empty file) is a no-op, like before. #[test] fn export_and_import_dispatch_on_the_chosen_path() { let mut app = app(); let path = std::path::PathBuf::from("/tmp/wherever.gsmol"); let ops = app.handle(Message::ExportBackupTo(path.clone())); assert!(matches!(ops.as_slice(), [PendingOp::Export { path: p }] if *p == path)); assert!(app .handle(Message::ImportBackupFrom(" ".into())) .is_empty()); let ops = app.handle(Message::ImportBackupFrom("some backup text".into())); assert!(matches!(ops.as_slice(), [PendingOp::ImportBackup { .. }])); } /// A factory reset asks first, then erases every file in the config /// directory and returns the model to onboarding, as if the app had /// never run. #[test] fn factory_reset_erases_the_files_and_returns_to_onboarding() { let dir = tempfile::tempdir().unwrap().keep(); let (mut app, _) = Carrier::boot(dir); let account = app.dir.clone(); let master = ops::new_master(); ops::save_key(&account.join("carrier.key"), &master).unwrap(); app.master = Some(master); std::fs::write(account.join("read.txt"), "ab").unwrap(); std::fs::write(account.join("drafts.txt"), "draft2 id 1 -\n\n\n\n").unwrap(); app.signup.host = "typed-before-the-reset.example".into(); app.signup.user = "alice".into(); app.handle(Message::StrictPinningChanged(true)); // The button is busy-guarded like every other action. app.busy = Some("fetching".into()); app.handle(Message::ResetPressed); assert!(app.dialog.is_none()); app.busy = None; app.handle(Message::ResetPressed); assert!(matches!(app.dialog, Some(Dialog::Reset))); app.handle(Message::ResetConfirmed); assert!(app.dialog.is_none()); assert_eq!(app.page, Page::Onboard); assert_eq!(app.onboard.mode, OnboardMode::Start); assert!(app.master.is_none()); assert!(app.snapshot.is_none()); assert!(app.drafts.is_empty()); // The fields themselves, not just the model, must come back // clean — form_fresh tells the view to push the blank state // into the entries instead of leaving stale typed text on them. assert!(app.signup.host.is_empty()); assert!(app.signup.user.is_empty()); assert!(app.form_fresh.get()); for name in ["carrier.key", "read.txt", "drafts.txt", "settings.txt"] { assert!(!account.join(name).exists(), "{name} survived the reset"); } // The last account's folder goes too; boot starts a fresh one. assert!(!account.exists()); assert_ne!(app.dir, account); // Preferences go with everything else, so the rebooted model is // back to its defaults rather than the logged-out user's. assert!(!app.strict_pinning); assert!(app.dir.join("carrier.db").exists()); assert!(app.store.account().unwrap().is_none()); } /// Backing out of a freshly created, not-yet-registered identity must /// discard it — otherwise a later Create can silently overwrite it out /// from under an in-flight or already-committed registration. #[test] fn onboard_back_from_created_discards_the_key() { let dir = tempfile::tempdir().unwrap().keep(); let (mut app, _) = Carrier::boot(dir.clone()); app.handle(Message::OnboardCreate); assert!(app.master.is_some()); assert!(app.dir.join("carrier.key").exists()); app.handle(Message::OnboardBack); assert!(app.master.is_none()); assert!(!app.dir.join("carrier.key").exists()); assert_eq!(app.page, Page::Onboard); assert_eq!(app.onboard.mode, OnboardMode::Start); } /// Restore mode writes no key until the seed is actually submitted, so /// backing out of it has nothing to discard — just the typed seed. #[test] fn onboard_back_from_restore_only_clears_the_typed_seed() { let mut app = app(); app.handle(Message::OnboardRestore); app.handle(Message::OnboardSeedChanged("not yet submitted".into())); app.handle(Message::OnboardBack); assert!(app.master.is_none()); assert!(app.onboard.seed.is_empty()); assert_eq!(app.onboard.mode, OnboardMode::Start); } /// Start Over on the signup page's server step is the same discard, /// reachable after onboarding has already handed off to Page::Signup. #[test] fn signup_start_over_discards_the_key() { let dir = tempfile::tempdir().unwrap().keep(); let (mut app, _) = Carrier::boot(dir.clone()); app.handle(Message::OnboardCreate); app.handle(Message::OnboardSeedSaved); assert_eq!(app.page, Page::Signup); app.signup.host = "example.org".into(); app.handle(Message::SignupStartOver); assert!(app.master.is_none()); assert!(!app.dir.join("carrier.key").exists()); assert_eq!(app.page, Page::Onboard); assert!(app.signup.host.is_empty()); } /// Register and Restore are long-running, so Back (and a second click) /// must be locked out for their whole in-flight window, the same way /// FetchPressed already locks out everything else. #[test] fn signup_register_and_recall_engage_the_busy_guard() { let mut app = app(); app.master = Some([0u8; KEY_LEN]); app.page = Page::Signup; app.signup.user = "alice".into(); app.signup.host = "example.org".into(); let ops = app.handle(Message::SignupRegister); assert_eq!(app.busy.as_deref(), Some("registering")); assert!(matches!(ops.as_slice(), [PendingOp::Register { .. }])); assert!(app.handle(Message::SignupStartOver).is_empty()); assert_eq!(app.page, Page::Signup); app.busy = None; let ops = app.handle(Message::SignupRecall); assert_eq!(app.busy.as_deref(), Some("restoring")); assert!(matches!(ops.as_slice(), [PendingOp::Restore { .. }])); } /// A successful Register/Restore must leave the signup page, or a /// lingering Back there could still race a fresh identity into /// existence against an address the store already considers bound. #[test] fn generic_success_on_the_signup_page_moves_on_to_mail() { let mut app = app(); app.page = Page::Signup; app.busy = Some("registering".into()); app.handle_result(OpResult::Generic(Ok("registered".into()))); assert_eq!(app.page, Page::Mail); } /// The server step's one button fetches a key when none is on /// screen yet, then pins it and advances once one is. #[test] fn signup_key_action_fetches_then_pins_and_advances() { let mut app = app(); app.signup.host = "example.org".into(); let ops = app.handle(Message::SignupKeyAction); assert!(matches!(ops.as_slice(), [PendingOp::LearnKey { .. }])); assert_eq!(app.signup.step, SignupStep::Server); let addr = Address::parse("x@example.org").unwrap(); app.pin_offer = Some((addr, [0u8; KEY_LEN])); let ops = app.handle(Message::SignupKeyAction); assert!(matches!(ops.as_slice(), [PendingOp::Pin { .. }])); assert!(app.pin_offer.is_none()); assert_eq!(app.signup.step, SignupStep::Account); } /// An already-trusted host (pinned in an earlier session) needs no /// key to show — Continue just advances with no background op. #[test] fn signup_key_action_advances_without_a_pin_when_already_trusted() { let mut app = app(); app.signup.host = "example.org".into(); app.signup.key_trusted = true; let ops = app.handle(Message::SignupKeyAction); assert!(ops.is_empty()); assert_eq!(app.signup.step, SignupStep::Account); } /// Editing the host after a key was fetched (or already trusted) /// invalidates it: there is only ever one key pending trust. #[test] fn editing_the_host_resets_a_fetched_or_trusted_key() { let mut app = app(); let addr = Address::parse("x@example.org").unwrap(); app.pin_offer = Some((addr, [0u8; KEY_LEN])); app.signup.key_trusted = true; app.handle(Message::SignupHostChanged("example.net".into())); assert!(app.pin_offer.is_none()); assert!(!app.signup.key_trusted); } fn opened() -> OpenedMsg { OpenedMsg { id: "ab".repeat(32), from: String::new(), subject: String::new(), time: 0, body: String::new(), sender: String::new(), reply_to: None, headers: Vec::new(), kept: false, } } #[test] fn contact_selection_and_message_selection_supersede_each_other() { let mut app = app(); // An open message survives a contact selection: the detail pane // shows the contact while the mail keeps its list highlight. app.open = Some((Folder::Inbox, opened())); app.handle(Message::ContactSelected("bob@localhost".into())); assert_eq!(app.selected_contact.as_deref(), Some("bob@localhost")); assert_eq!(app.page, Page::Mail); assert!(app.open.is_some()); // busy does not block selecting a contact app.busy = Some("fetching".into()); app.handle(Message::ContactSelected("bob@localhost".into())); assert_eq!(app.busy.as_deref(), Some("fetching")); } #[test] fn own_and_contact_selection_supersede_each_other() { let mut app = app(); app.open = Some((Folder::Inbox, opened())); app.handle(Message::OwnSelected); assert!(app.selected_own); assert_eq!(app.page, Page::Mail); assert!(app.open.is_some()); app.handle(Message::ContactSelected("bob@localhost".into())); assert!(!app.selected_own); assert_eq!(app.selected_contact.as_deref(), Some("bob@localhost")); app.handle(Message::OwnSelected); assert!(app.selected_own); assert!(app.selected_contact.is_none()); // A folder trip closes the detail, own included. app.handle(Message::GotoMail(Folder::Sent)); assert!(!app.selected_own); } #[test] fn text_edits_pass_the_busy_guard() { let mut app = app(); app.compose = Some(Compose { to: String::new(), subject: String::new(), body: String::new(), reply_to: None, headers: Vec::new(), }); app.busy = Some("fetching".into()); app.handle(Message::ComposeBodyChanged("typing while busy".into())); assert_eq!( app.compose.as_ref().map(|c| c.body.clone()), Some("typing while busy".to_string()) ); assert_eq!(app.busy.as_deref(), Some("fetching")); } /// A compose closed by any sidebar trip becomes a draft in the /// sidecar; Cancel stays the only way to throw work away. #[test] fn sidebar_trips_stash_the_compose_as_a_draft() { let mut app = app(); app.handle(Message::ComposePressed); app.handle(Message::ComposeToChanged("alice@example.org".into())); app.handle(Message::ComposeSubjectChanged("half written".into())); app.handle(Message::ComposeBodyChanged("still typing".into())); // The same folder the compose was opened over still closes it. app.folder = Folder::Inbox; app.page = Page::Mail; app.handle(Message::GotoMail(Folder::Inbox)); assert!(app.compose.is_none()); assert_eq!(app.drafts.len(), 1); assert!(app.dir.join("drafts.txt").exists()); let draft = app.drafts[0].clone(); assert_eq!(draft.to, "alice@example.org"); assert_eq!(draft.subject, "half written"); app.folder = Folder::Drafts; assert_eq!(app.rows()[0].id, draft.id); // Opening the draft moves it back into the compose, out of the // sidecar, so saving it again cannot fork it. app.handle(Message::MessageSelected(Folder::Drafts, draft.id.clone())); assert!(app.compose.is_some()); assert!(app.drafts.is_empty()); assert_eq!(app.compose.as_ref().unwrap().subject, "half written"); // Write while editing stashes the draft being edited first, and the // fresh empty compose a trip then discards adds nothing. app.handle(Message::ComposePressed); assert_eq!(app.drafts.len(), 1); app.handle(Message::GotoMail(Folder::Sent)); assert!(app.compose.is_none()); assert_eq!(app.drafts.len(), 1); // An empty compose discarded by a trip leaves no draft behind. app.drafts.clear(); app.handle(Message::ComposePressed); app.handle(Message::GotoMail(Folder::Sent)); assert!(app.drafts.is_empty()); } #[test] fn header_rows_pass_the_busy_guard_and_edit_the_compose() { let mut app = app(); app.handle(Message::ComposePressed); app.busy = Some("fetching".into()); app.handle(Message::ComposeHeaderAdded); app.handle(Message::ComposeHeaderAdded); app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into())); app.handle(Message::ComposeHeaderRemoved(1)); assert_eq!( app.compose.as_ref().expect("compose open").headers, ["Urgency: high"] ); // An index that no longer exists is ignored, not a panic. app.handle(Message::ComposeHeaderChanged(5, "no".into())); assert_eq!(app.compose.as_ref().unwrap().headers, ["Urgency: high"]); } #[test] fn an_invalid_header_blocks_the_send() { let mut app = app(); app.handle(Message::ComposePressed); app.handle(Message::ComposeToChanged("bob@localhost".into())); // A reserved key shadows the client's own field, so it is rejected. app.handle(Message::ComposeHeaderAdded); app.handle(Message::ComposeHeaderChanged(0, "Subject: shadow".into())); assert!(app.handle(Message::SendPressed).is_empty()); assert!(app.compose.is_some()); assert!(app.status_error); // Case-insensitive duplicates are rejected too. app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into())); app.handle(Message::ComposeHeaderAdded); app.handle(Message::ComposeHeaderChanged(1, "urgency: low".into())); assert!(app.handle(Message::SendPressed).is_empty()); assert!(app.status_error); // Removing the duplicate lets the send go through with the pair; // the earlier error note stays until the op result replaces it. app.handle(Message::ComposeHeaderRemoved(1)); let ops = app.handle(Message::SendPressed); assert_eq!(ops.len(), 1); assert!(app.compose.is_none()); assert!(matches!( &ops[0], PendingOp::Send { headers, .. } if headers == &[("Urgency".to_string(), "high".to_string())] )); } #[test] fn replying_to_custom_fields_asks_what_to_carry() { let mut app = app(); app.open = Some(( Folder::Inbox, OpenedMsg { subject: "hello".into(), headers: vec![ ("priority".to_string(), "high".to_string()), ("in-reply-to".to_string(), "cd".repeat(32)), ], ..opened() }, )); // The ask: a dialog, not a compose yet, and cancelling aborts. app.handle(Message::ReplyPressed); assert!(app.compose.is_none()); assert!(matches!(app.dialog, Some(Dialog::Reply { .. }))); app.handle(Message::ReplyCancelled); assert!(app.dialog.is_none()); assert!(app.compose.is_none()); // Either answer opens the same reply; the fields follow the choice. app.handle(Message::ReplyPressed); app.handle(Message::ReplyConfirmed(false)); let compose = app.compose.as_ref().expect("reply open"); assert_eq!(compose.subject, "Re: hello"); assert!(compose.headers.is_empty()); assert_eq!(compose.reply_to.as_deref(), Some("ab".repeat(32).as_str())); app.compose = None; app.handle(Message::ReplyPressed); app.handle(Message::ReplyConfirmed(true)); let compose = app.compose.as_ref().expect("reply open"); // The sender's field arrives; the threading id does not — the // reply carries its own. assert_eq!(compose.headers, ["priority: high"]); } #[test] fn a_thread_only_mail_replies_straight_through() { let mut app = app(); app.open = Some(( Folder::Inbox, OpenedMsg { subject: "hello".into(), headers: vec![("in-reply-to".to_string(), "cd".repeat(32))], ..opened() }, )); app.handle(Message::ReplyPressed); assert!(app.dialog.is_none()); assert!(app.compose.is_some()); assert!(app.compose.as_ref().unwrap().headers.is_empty()); } #[test] fn headers_survive_a_stash_and_reopen() { let mut app = app(); app.handle(Message::ComposePressed); app.handle(Message::ComposeToChanged("bob@localhost".into())); app.handle(Message::ComposeHeaderAdded); app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into())); app.handle(Message::ComposeHeaderAdded); app.handle(Message::ComposeHeaderChanged(1, "not valid yet".into())); // A contact trip stashes without starting a refresh, so the // reopen below is not swallowed by the busy guard. app.handle(Message::ContactSelected("bob@localhost".into())); assert_eq!(app.drafts.len(), 1); let id = app.drafts[0].id.clone(); app.handle(Message::MessageSelected(Folder::Drafts, id)); let compose = app.compose.as_ref().expect("draft reopened"); // Rows return exactly as typed, valid or not: validation is a // send-time concern, not a stash-time one. assert_eq!(compose.headers, ["Urgency: high", "not valid yet"]); } #[test] fn fetch_tick_lists_mail_as_it_lands() { use fumi_core::message::{build_frontmatter, message_id, seal}; let dir = tempfile::tempdir().unwrap().keep(); let (mut app, _op) = Carrier::boot(dir); let master = ops::new_master(); app.master = Some(master); let me = ops::account(&app.store, &master).unwrap(); let addr = Address::parse("me@localhost").unwrap(); app.store.set_account(&addr).unwrap(); let body = build_frontmatter(&[("Subject", "live")], "tick"); let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), 1000, true).unwrap(); let id = message_id(&envelope); app.store .store_inbox(&id, &envelope, 1000, false, false) .unwrap(); app.busy = Some("fetching".into()); app.handle(Message::FetchTick); assert_eq!(app.busy.as_deref(), Some("fetching")); assert_eq!(app.lists[0].len(), 1); assert_eq!(app.lists[0][0].subject, "live"); // A tick outside a fetch is a no-op. app.busy = None; app.handle(Message::FetchTick); } }