2559 lines
95 KiB
Rust
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);
|
|
}
|
|
}
|