feat: rework signup into a safer multi-step flow, add keep-mail setting

This commit is contained in:
randogoth 2026-10-08 19:58:50 +03:00
parent 53c9c4b8ce
commit 2892ea3fa7
3 changed files with 649 additions and 132 deletions

View file

@ -75,14 +75,28 @@ pub enum OnboardMode {
/// 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,
/// The server's base32 static key, pasted from what the operator
/// published; pinned before the registration connects.
pub key: String,
/// True when onboarding just wrote the key file, so Back is offered.
pub fresh: bool,
/// 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.
@ -107,6 +121,8 @@ pub enum Dialog {
},
/// 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 {
@ -133,9 +149,13 @@ pub enum Message {
SignupUserChanged(String),
SignupHostChanged(String),
SignupInviteChanged(String),
SignupKeyChanged(String),
SignupBack,
SignupLearnKey,
/// 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,
@ -186,6 +206,7 @@ pub enum Message {
// Settings
SettingsLearnKey,
KeepMailChanged(bool),
TimeoutChanged(String),
RotatePressed,
RotateConfirmed,
@ -194,6 +215,9 @@ pub enum Message {
ExportPressed,
ImportBodyChanged(String),
ImportPressed,
ResetPressed,
ResetConfirmed,
ResetCancelled,
}
/// What a background operation reports back.
@ -219,8 +243,6 @@ pub enum PendingOp {
Register {
addr: Address,
invite: Option<String>,
/// A pasted operator key, pinned before the registration connects.
key: String,
},
Restore {
addr: Address,
@ -279,6 +301,9 @@ pub struct Carrier {
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,
pub timeout: u64,
pub timeout_text: String,
pub onboard: Onboard,
@ -304,6 +329,11 @@ pub struct Carrier {
/// 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 {
@ -334,6 +364,7 @@ impl Carrier {
status_error: false,
busy: None,
cancel: Arc::new(AtomicBool::new(false)),
keep_mail: false,
timeout: 30,
timeout_text: "30".to_string(),
onboard: Onboard {
@ -342,11 +373,12 @@ impl Carrier {
seed_shown: String::new(),
},
signup: Signup {
step: SignupStep::Server,
mode: SignupMode::New,
user: String::new(),
host: String::new(),
invite: String::new(),
key: String::new(),
fresh: false,
key_trusted: false,
},
folder: Folder::Inbox,
lists: vec![Vec::new(), Vec::new(), Vec::new()],
@ -362,6 +394,7 @@ impl Carrier {
contact_import: String::new(),
dialog: None,
compose_fresh: Cell::new(false),
form_fresh: Cell::new(true),
};
let op = registered.then(|| PendingOp::Snapshot {
read: app.marks.snapshot(),
@ -427,6 +460,61 @@ impl Carrier {
self.compose_fresh.set(true);
}
/// Factory reset: close the store, erase the config directory, then
/// boot a fresh model in place. 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 factory_reset(&mut self) {
let store = match Store::open_in_memory() {
Ok(store) => Arc::new(store),
Err(e) => {
self.fail(e.to_string());
return;
}
};
self.store = store;
if let Err(e) = ops::factory_reset(&self.dir) {
self.fail(e);
return;
}
let (app, _) = Carrier::boot(self.dir.clone());
*self = app;
self.note("factory reset done. Create a new identity or restore a seed.".into());
}
/// 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> {
@ -504,6 +592,17 @@ impl Carrier {
Address::parse(&format!("{user}@{host}")).ok()
}
/// 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) {
@ -596,16 +695,20 @@ impl Carrier {
}
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::SignupKeyChanged(key) => {
self.signup.key = key;
}
Message::ContactImportChanged(text) => {
self.contact_import = text;
}
Message::KeepMailChanged(keep) => {
self.keep_mail = keep;
}
Message::TimeoutChanged(text) => {
if let Ok(timeout) = text.trim().parse::<u64>() {
self.timeout = timeout;
@ -640,7 +743,13 @@ impl Carrier {
self.onboard.mode = OnboardMode::Restore;
}
Message::OnboardBack => {
self.onboard.mode = OnboardMode::Start;
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) => {
@ -648,7 +757,6 @@ impl Carrier {
self.fail(e);
} else {
self.master = Some(master);
self.signup.fresh = true;
self.page = Page::Signup;
self.note("Seed restored. Bind a username or recall an address.".into());
}
@ -656,15 +764,28 @@ impl Carrier {
Err(e) => self.fail(e),
},
Message::OnboardSeedSaved => {
self.signup.fresh = true;
self.page = Page::Signup;
}
Message::SignupBack => {
self.page = Page::Onboard;
Message::SignupStepBack => {
self.signup.step = SignupStep::Server;
}
Message::SignupLearnKey => {
let Some(addr) = self.signup_address() else {
self.fail("enter user and host first".into());
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("enter the server host first".into());
return Vec::new();
};
return vec![PendingOp::LearnKey { addr }];
@ -694,14 +815,19 @@ impl Carrier {
} else {
Some(self.signup.invite.clone())
};
let key = self.signup.key.trim().to_string();
return vec![PendingOp::Register { addr, invite, key }];
if !self.start("registering") {
return Vec::new();
}
return vec![PendingOp::Register { addr, invite }];
}
Message::SignupRecall => {
let Some(addr) = self.signup_address() else {
self.fail("enter user and host".into());
return Vec::new();
};
if !self.start("restoring") {
return Vec::new();
}
return vec![PendingOp::Restore { addr }];
}
Message::GotoMail(folder) => {
@ -989,6 +1115,16 @@ impl Carrier {
Message::RotateCancelled => {
self.dialog = None;
}
Message::ResetPressed => {
self.dialog = Some(Dialog::Reset);
}
Message::ResetCancelled => {
self.dialog = None;
}
Message::ResetConfirmed => {
self.dialog = None;
self.factory_reset();
}
Message::RotateConfirmed => {
self.dialog = None;
return vec![PendingOp::Rotate];
@ -1030,6 +1166,13 @@ impl Carrier {
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.refresh()
}
OpResult::Generic(Err(e)) => {
@ -1049,6 +1192,7 @@ impl Carrier {
}
OpResult::PinOffered(Ok(None)) => {
self.done();
self.signup.key_trusted = true;
self.note("This server's key is already pinned.".into());
None
}
@ -1124,6 +1268,194 @@ mod tests {
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);
}
/// 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.clone());
let master = ops::new_master();
ops::save_key(&dir.join("carrier.key"), &master).unwrap();
app.master = Some(master);
std::fs::write(dir.join("read.txt"), "ab").unwrap();
std::fs::write(dir.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();
// 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"] {
assert!(!dir.join(name).exists(), "{name} survived the reset");
}
// The store is reopened by boot, so a fresh empty db is the one
// file a reset legitimately leaves behind.
assert!(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!(dir.join("carrier.key").exists());
app.handle(Message::OnboardBack);
assert!(app.master.is_none());
assert!(!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!(!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),