carrier/src/app.rs

2559 lines
95 KiB
Rust

//! 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<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}");
}
}
}
}
/// 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<String>,
/// 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<String>,
/// Frontmatter rows as typed — `Key: value` text, parsed at send.
pub headers: Vec<String>,
}
/// 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<String>,
},
/// 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<Snapshot, String>),
/// A one-line note, or the failure prose fumi produced.
Generic(Result<String, String>),
/// The key a server presented with no pin in place (sec 4 tofu).
PinOffered(Result<Option<(Address, [u8; KEY_LEN])>, 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<String>,
},
Register {
addr: Address,
invite: Option<String>,
},
Restore {
addr: Address,
},
LearnKey {
addr: Address,
},
Pin {
addr: Address,
key: [u8; KEY_LEN],
},
Fetch {
cancel: Arc<AtomicBool>,
},
Send {
to: String,
subject: String,
body: String,
reply_to: Option<String>,
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<AccountEntry>,
/// The account to return to when one still being set up is cancelled.
pub previous: Option<String>,
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>,
/// 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<Vec<Row>>,
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<String>,
/// True when the article pane shows our own identity's details.
pub selected_own: bool,
pub compose: Option<Compose>,
/// 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<Draft>,
pub snapshot: Option<Snapshot>,
/// Signup's offer only; correspondents' servers prompt through
/// `pin_prompt`.
pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
pub pin_prompt: Option<PinPrompt>,
/// The Identity tab's recovery secret is masked until this is toggled.
pub secret_revealed: bool,
pub contact_import: String,
pub dialog: Option<Dialog>,
/// 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<bool>,
/// 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<bool>,
}
impl Carrier {
/// Opens the account `active.txt` names, else the first one, else a
/// fresh empty one.
pub fn boot(root: PathBuf) -> (Carrier, Option<PendingOp>) {
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<PendingOp>) {
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<String>) -> Result<Option<PendingOp>, 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<PendingOp> {
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<PendingOp> {
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<Row> {
if self.folder == Folder::Drafts {
let mut rows: Vec<Row> = 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<PendingOp> {
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<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 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<Address> {
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<Address> {
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<PendingOp> {
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::<u64>() {
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<PendingOp> {
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<AccountEntry> {
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);
}
}