jmap: adopt state changes made in other clients
Adoption keys off a change in the mailboxes since the last sight
(messages.server_state), not a mismatch with Docket, so mail Docket
couldn't move, like Wait with no Docket/Wait label, doesn't snap back.

Assisted-by: Claude Opus 5.5 via Claude Code
change kwywzszwyxlttsssnmqlyzpnlmollouy
commit 16739128d41af12cc9004cdad97c78d17158b77d
author Alpha Chen <alpha@kejadlen.dev>
date
parent kzyqpsuo
diff --git a/migrations/0008_adoption.sql b/migrations/0008_adoption.sql
new file mode 100644
index 0000000..d2a3c99
--- /dev/null
+++ b/migrations/0008_adoption.sql
@@ -0,0 +1,29 @@
+-- Adopting state changes made in other clients (task qwt). Mailbox
+-- moves made elsewhere are user actions, so the poll reads them as a
+-- new state, but only when the mailboxes themselves changed: a message
+-- the server couldn't move (a lane without its label) must not snap
+-- back on its next unrelated update. server_state is the state the
+-- mailboxes said when the poll last saw the message, the baseline that
+-- change is measured from. NULL until the next sight records one.
+ALTER TABLE messages ADD COLUMN server_state TEXT
+    CHECK (server_state IN ('inbox', 'do', 'wait', 'watch', 'done'));
+
+-- An adopted change has no Docket user behind it: a NULL user is
+-- "via another client". history is rebuilt (not ALTERed) because
+-- SQLite can't drop NOT NULL; rowid carries the order across.
+CREATE TABLE history_new (
+    id TEXT PRIMARY KEY,
+    message TEXT NOT NULL REFERENCES messages (id),
+    -- NULL when the change came from another client.
+    user TEXT REFERENCES users (login),
+    at TEXT NOT NULL,
+    event TEXT NOT NULL
+) STRICT;
+
+INSERT INTO history_new (rowid, id, message, user, at, event)
+SELECT rowid, id, message, user, at, event FROM history ORDER BY rowid;
+
+DROP TABLE history;
+ALTER TABLE history_new RENAME TO history;
+
+CREATE INDEX history_message ON history (message);
diff --git a/src/jmap.rs b/src/jmap.rs
index e8cb354..6c5dd61 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -436,6 +436,8 @@ struct Layout {
     pub sent: Option<String>,
     /// The Archive mailbox's id: where Done lands.
     pub archive: Option<String>,
+    /// The Trash mailbox's id: Done whatever labels it keeps.
+    pub trash: Option<String>,
     /// Label mailbox id → the state it carries.
     labels: BTreeMap<String, State>,
     /// Mailbox ids in the Docket namespace: the labels and the parent.
@@ -462,8 +464,9 @@ impl Layout {
                 Some("inbox") => layout.inbox = Some(mailbox.id.clone()),
                 Some("sent") => layout.sent = Some(mailbox.id.clone()),
                 Some("archive") => layout.archive = Some(mailbox.id.clone()),
-                // The other system boxes (Drafts, Junk, Trash)
-                // are neither folders nor labels.
+                Some("trash") => layout.trash = Some(mailbox.id.clone()),
+                // The other system boxes (Drafts, Junk, ...) are
+                // neither folders nor labels.
                 Some(_) => {}
                 None => {
                     let in_docket = mailbox.name == "Docket"
@@ -490,13 +493,33 @@ impl Layout {
         layout
     }
 
-    /// The state the message carries: its `Docket/` label, else Inbox.
+    /// The state the message's mailboxes say (DESIGN.md, State): Done
+    /// in Trash whatever labels it keeps, else its `Docket/` label, else
+    /// Inbox when it sits there, else Done.
     pub fn state_of(&self, email: &Email) -> State {
+        let member = |id: &str| email.mailbox_ids.get(id) == Some(&true);
+        if self.trash.as_deref().is_some_and(member) {
+            return State::Done;
+        }
+        let label = email
+            .mailbox_ids
+            .iter()
+            .filter(|(_, member)| **member)
+            .find_map(|(id, _)| self.labels.get(id).copied());
+        match label {
+            Some(state) => state,
+            None if self.is_inbox(email) => State::Inbox,
+            None => State::Done,
+        }
+    }
+
+    /// True when the message carries a `Docket/` label: state the poll
+    /// follows even if Docket never saw it in the Inbox.
+    pub fn is_labeled(&self, email: &Email) -> bool {
         email
             .mailbox_ids
-            .keys()
-            .find_map(|id| self.labels.get(id).copied())
-            .unwrap_or(State::Inbox)
+            .iter()
+            .any(|(id, member)| *member && self.labels.contains_key(id))
     }
 
     /// The folder the message is filed in: its role-less mailbox outside
@@ -1209,8 +1232,8 @@ impl Client {
     /// received messages land in Inbox — or the state their `Docket/`
     /// label already carries — unassigned; sent ones carry a login when
     /// the From address names one of us, else the shared identity.
-    /// Filed and archived mail stays out until adoption (task qwt)
-    /// watches for it.
+    /// Filed and archived mail stays out; once mail is tracked, the
+    /// poll follows it out of the Inbox (task qwt).
     #[allow(clippy::too_many_arguments)]
     async fn import_mail(
         &self,
@@ -1429,11 +1452,18 @@ impl Client {
                 let (emails, _) = self.emails(&session.api_url, &token, id, &ids).await?;
                 let users = store.users()?;
                 // Received first: a new Inbox message founds the thread
-                // its same-batch sent reply then lands in.
+                // its same-batch sent reply then lands in. Labeled mail
+                // is Docket's too, and mail already tracked stays
+                // followed out of the Inbox: leaving is a state change
+                // (task qwt).
                 for email in &emails {
-                    if sync.layout.is_inbox(email)
-                        && let Some(mail) = incoming(email, &sync.layout, &users)
-                    {
+                    let Some(mail) = incoming(email, &sync.layout, &users) else {
+                        continue;
+                    };
+                    let followed = !sync.layout.is_sent(email)
+                        && (sync.layout.is_labeled(email)
+                            || store.has_message(&credential.name, &mail.message_id)?);
+                    if sync.layout.is_inbox(email) || followed {
                         store.import(|tx| tx.incoming(&credential.name, &mail))?;
                         counts.imported = counts.imported.saturating_add(1);
                     }
@@ -2377,6 +2407,12 @@ mod tests {
         // Not a member: filed out of Inbox, or a sent message.
         let gone = email(&[("M-receipts", true)]);
         assert!(!layout.is_inbox(&gone));
+        // Neither Inbox nor label: Done. A lapsed membership is none.
+        assert_eq!(layout.state_of(&gone), State::Done);
+        assert_eq!(
+            layout.state_of(&email(&[("M-in", true), ("M-do", false)])),
+            State::Inbox
+        );
         assert!(!layout.is_inbox(&email(&[("M-in", false)])));
 
         let mine = email(&[("M-sent", true)]);
diff --git a/src/model.rs b/src/model.rs
index 817340b..56e6147 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -169,12 +169,13 @@ pub struct Comment {
     pub text: String,
 }
 
-/// A change someone made to a message's values, as its toast put it.
+/// A change someone made to a message's values, as its toast put it,
+/// or one adopted from another client.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Event {
     pub message: MessageId,
-    /// A login.
-    pub user: String,
+    /// A login, or None for a change made in another client.
+    pub user: Option<String>,
     pub at: DateTime,
     pub text: String,
 }
diff --git a/src/store.rs b/src/store.rs
index 9e32d7d..4b9f758 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -28,6 +28,7 @@ const MIGRATIONS: &[&str] = &[
     include_str!("../migrations/0005_pending_archives.sql"),
     include_str!("../migrations/0006_jj_style_ids.sql"),
     include_str!("../migrations/0007_pending_moves.sql"),
+    include_str!("../migrations/0008_adoption.sql"),
 ];
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -174,6 +175,7 @@ impl Store {
         let mut inner = self.lock();
         let import = Import {
             tx: inner.conn.transaction()?,
+            now: self.now(),
         };
         f(&import)?;
         import.tx.commit()?;
@@ -232,6 +234,18 @@ impl Store {
 
     /// True when the account's thread is already imported: the poll
     /// path admits a sent email only into a thread it knows.
+    /// Whether the account already tracks the message, by Message-ID:
+    /// mail that leaves the Inbox stays the poll's business once known.
+    pub fn has_message(&self, account: &str, message_id: &str) -> Result<bool, Error> {
+        let inner = self.lock();
+        let known = inner.conn.query_row(
+            "SELECT EXISTS (SELECT 1 FROM messages WHERE account = ?1 AND message_id = ?2)",
+            [account, message_id],
+            |r| r.get(0),
+        )?;
+        Ok(known)
+    }
+
     pub fn has_thread(&self, account: &str, jmap_thread_id: &str) -> Result<bool, Error> {
         let found = self.lock().conn.query_row(
             "SELECT EXISTS(SELECT 1 FROM threads
@@ -437,7 +451,7 @@ impl Store {
         };
 
         write_values(&tx, id, &next)?;
-        insert_event(&tx, id, user, self.now(), &text)?;
+        insert_event(&tx, id, Some(user), self.now(), &text)?;
         tx.commit()?;
         inner
             .undo
@@ -475,7 +489,7 @@ impl Store {
             queue_file(&tx, &id, prev.folder.as_deref())?;
         }
         write_values(&tx, &id, &prev)?;
-        insert_event(&tx, &id, user, self.now(), "Undone")?;
+        insert_event(&tx, &id, Some(user), self.now(), "Undone")?;
         tx.commit()?;
         inner.undo.remove(user);
         inner.flash.insert(
@@ -551,7 +565,7 @@ impl Store {
         tx.execute("DELETE FROM pending_files WHERE message = ?1", [id])?;
         tx.execute("DELETE FROM pending_moves WHERE message = ?1", [id])?;
         write_values(&tx, id, &next)?;
-        insert_event(&tx, id, user, self.now(), "Deleted")?;
+        insert_event(&tx, id, Some(user), self.now(), "Deleted")?;
         tx.commit()?;
         inner.flash.insert(
             user.to_owned(),
@@ -732,6 +746,8 @@ pub struct PendingMove {
 /// Writes inside one transaction, from [`Store::import`].
 pub struct Import<'a> {
     tx: Transaction<'a>,
+    /// The clock adopted changes are stamped with.
+    now: DateTime,
 }
 
 impl Import<'_> {
@@ -846,7 +862,15 @@ impl Import<'_> {
     /// values (state, assignees, reads) are written only on first
     /// import, while the server-owned cache (folder, timestamps, body)
     /// refreshes.
+    ///
+    /// The one exception is state changed in another client (task qwt):
+    /// when the mailboxes say a new state since the last sight, Docket
+    /// adopts it with a history event rather than reverting it. A queued
+    /// move or deletion is Docket's newer word and holds; read-only
+    /// accounts keep state in the database alone, so nothing there
+    /// adopts.
     pub fn incoming(&self, account: &str, mail: &Incoming) -> Result<(), Error> {
+        let prior = self.prior(account, &mail.message_id)?;
         self.tx.execute(
             "INSERT INTO threads (id, account, subject, jmap_thread_id)
              VALUES (?1, ?2, ?3, ?4)
@@ -887,10 +911,11 @@ impl Import<'_> {
         let bcc = json(&mail.bcc)?;
         let _upserted = self.tx.execute(
             "INSERT INTO messages (id, account, message_id, jmap_id, thread, at, cc, bcc, body,
-                kind, from_name, from_addr, state, folder, sent_by, sent_to)
-             SELECT ?1, ?2, ?3, ?4, t.id, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16
+                kind, from_name, from_addr, state, folder, sent_by, sent_to, server_state)
+             SELECT ?1, ?2, ?3, ?4, t.id, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?13
              FROM threads t WHERE t.account = ?2 AND t.jmap_thread_id = ?5
              ON CONFLICT (account, message_id) DO UPDATE SET
+                 server_state = excluded.server_state,
                  jmap_id = excluded.jmap_id,
                  thread = excluded.thread,
                  at = excluded.at,
@@ -921,8 +946,72 @@ impl Import<'_> {
                 sent_to,
             ],
         )?;
+        if let (Some(prior), Some(seen)) = (prior, state)
+            && prior.adopts(*seen)
+        {
+            self.tx.execute(
+                "UPDATE messages SET state = ?2 WHERE id = ?1",
+                params![prior.id, seen],
+            )?;
+            insert_event(
+                &self.tx,
+                &prior.id,
+                None,
+                self.now,
+                &format!("Moved to {seen} via another client"),
+            )?;
+        }
         Ok(())
     }
+
+    /// What a received message held before this sight, for adoption.
+    fn prior(&self, account: &str, message_id: &str) -> Result<Option<Prior>, Error> {
+        let prior = self
+            .tx
+            .query_row(
+                "SELECT m.id, m.state, m.server_state,
+                     EXISTS (SELECT 1 FROM pending_moves p WHERE p.message = m.id)
+                     OR EXISTS (SELECT 1 FROM pending_deletes d WHERE d.message = m.id),
+                     a.read_only
+                 FROM messages m JOIN accounts a ON a.slug = m.account
+                 WHERE m.account = ?1 AND m.message_id = ?2 AND m.kind = 'received'",
+                [account, message_id],
+                |r| {
+                    Ok(Prior {
+                        id: r.get(0)?,
+                        state: r.get(1)?,
+                        server_state: r.get(2)?,
+                        queued: r.get(3)?,
+                        read_only: r.get(4)?,
+                    })
+                },
+            )
+            .optional()?;
+        Ok(prior)
+    }
+}
+
+/// A received message as it stood before a sight of it.
+struct Prior {
+    id: MessageId,
+    state: State,
+    /// What the mailboxes said last time; None before any sight.
+    server_state: Option<State>,
+    /// A move or deletion Docket has yet to push.
+    queued: bool,
+    read_only: bool,
+}
+
+impl Prior {
+    /// Whether the mailboxes' `seen` state is another client's change
+    /// to adopt: they moved since the last sight, to somewhere Docket
+    /// doesn't already say, with nothing of Docket's own in flight.
+    fn adopts(&self, seen: State) -> bool {
+        self.server_state.is_some_and(|last| last != seen)
+            && self.state != seen
+            && !self.queued
+            && !self.read_only
+    }
 }
 
 impl ToSql for State {
@@ -1102,10 +1191,11 @@ fn queue_move(conn: &Connection, id: &str, state: State) -> Result<bool, Error>
     Ok(queued > 0)
 }
 
+/// `user` is None for a change adopted from another client.
 fn insert_event(
     conn: &Connection,
     message: &str,
-    user: &str,
+    user: Option<&str>,
     at: DateTime,
     text: &str,
 ) -> Result<(), Error> {
@@ -1216,8 +1306,8 @@ mod tests {
         assert_eq!(
             history,
             [
-                (SAM.into(), store.now(), "Moved to Do".into()),
-                (SAM.into(), store.now(), "Undone".into()),
+                (Some(SAM.into()), store.now(), "Moved to Do".into()),
+                (Some(SAM.into()), store.now(), "Undone".into()),
             ]
         );
     }
@@ -1393,7 +1483,7 @@ mod tests {
             .into_iter()
             .map(|e| (e.user, e.text))
             .collect();
-        assert_eq!(history, [(SAM.into(), "Moved to Done".into())]);
+        assert_eq!(history, [(Some(SAM.into()), "Moved to Done".into())]);
 
         // Undo reverts the state and queues the move back.
         store.undo(SAM).unwrap();
@@ -1535,8 +1625,8 @@ mod tests {
         assert_eq!(
             history,
             [
-                (SAM.into(), "Filed to House".into()),
-                (SAM.into(), "Deleted".into())
+                (Some(SAM.into()), "Filed to House".into()),
+                (Some(SAM.into()), "Deleted".into())
             ]
         );
 
@@ -1837,7 +1927,12 @@ mod tests {
         assert!(!store.has_thread("household", "T8").unwrap());
         // Threads are scoped to the account.
         assert!(!store.has_thread("eli", "T9").unwrap());
+        assert!(store.has_message("household", "m9@chislan.family").unwrap());
+        assert!(!store.has_message("eli", "m9@chislan.family").unwrap());
 
+        exec(&store, "ALTER TABLE messages RENAME TO gone_messages;");
+        assert!(store.has_message("household", "m9@chislan.family").is_err());
+        exec(&store, "ALTER TABLE gone_messages RENAME TO messages;");
         exec(&store, "ALTER TABLE threads RENAME TO gone_threads;");
         assert!(store.has_thread("household", "T9").is_err());
     }
@@ -1888,6 +1983,45 @@ mod tests {
         assert_eq!(after.values().unwrap().folder, None);
     }
 
+    #[test]
+    fn incoming_adopts_a_state_changed_elsewhere() {
+        let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+        let sight =
+            |state| store.import(|tx| tx.incoming("household", &incoming(state, None, "x")));
+        store
+            .import(|tx| {
+                tx.account(&Account {
+                    slug: "household".into(),
+                    name: "Household".into(),
+                    address: "household@example.com".into(),
+                    read_only: false,
+                })
+            })
+            .unwrap();
+        sight(State::Inbox).unwrap();
+        let id = store.messages(Filter::Search("x")).unwrap()[0].id.clone();
+
+        sight(State::Watch).unwrap();
+        assert_eq!(values(&store, &id).state, State::Watch);
+        assert_eq!(
+            store.history(&id).unwrap(),
+            [Event {
+                message: id.clone(),
+                user: None,
+                at: fixtures::now(),
+                text: "Moved to Watch via another client".into(),
+            }]
+        );
+
+        // A failed event rolls the adoption back with it.
+        exec(
+            &store,
+            "CREATE TRIGGER no_history BEFORE INSERT ON history BEGIN SELECT RAISE(ABORT, 'no'); END;",
+        );
+        assert!(matches!(sight(State::Do), Err(Error::Db(_))));
+        assert_eq!(values(&store, &id).state, State::Watch);
+    }
+
     #[test]
     fn incoming_reports_a_table_that_refuses_writes() {
         let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 309239e..4cc6cb8 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -113,6 +113,7 @@ fn mailboxes() -> Vec<Value> {
         ("MB-in", "Inbox", Some("inbox"), &rights),
         ("MB-sent", "Sent", Some("sent"), &rights),
         ("MB-archive", "Archive", Some("archive"), &rights),
+        ("MB-trash", "Trash", Some("trash"), &rights),
         ("MB-drafts", "Drafts", Some("drafts"), &rights),
         ("MB-sched", "Scheduled", Some("scheduled"), &restricted),
         ("MB-do", "Docket/Do", None, &rights),
@@ -929,11 +930,43 @@ async fn new_inbox_mail_arrives_within_one_poll() {
     assert_eq!(arrived.values().unwrap().folder, None);
 }
 
+/// Rewrites one household email's memberships, as another client would,
+/// and polls once.
+async fn moved_elsewhere(
+    world: &World,
+    client: &Client,
+    household: &Credential,
+    sync: &mut docket::jmap::Sync,
+    store: &Store,
+    id: &str,
+    mailboxes: Value,
+) {
+    world
+        .lock()
+        .unwrap()
+        .update_email(HOUSEHOLD, id, |mail| mail["mailboxIds"] = mailboxes);
+    client.poll_once(household, sync, store).await.unwrap();
+}
+
+/// The message's state and its history, as (user, text) pairs.
+fn state_and_history(store: &Store, message_id: &str) -> (State, Vec<(Option<String>, String)>) {
+    let message = the(store, message_id).unwrap();
+    let history = store
+        .history(&message.id)
+        .unwrap()
+        .into_iter()
+        .map(|e| (e.user, e.text))
+        .collect();
+    (message.values().unwrap().state, history)
+}
+
+fn elsewhere(text: &str) -> (Option<String>, String) {
+    (None, text.to_owned())
+}
+
 #[tokio::test]
-async fn edits_refresh_the_row_while_state_adoption_waits() {
+async fn a_folder_picked_up_elsewhere_refreshes_the_row() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
-
-    // A folder picked up elsewhere refreshes the cached row.
     world
         .lock()
         .unwrap()
@@ -950,22 +983,311 @@ async fn edits_refresh_the_row_while_state_adoption_waits() {
         roofer1.values().unwrap().folder.as_deref(),
         Some("Receipts")
     );
+    // Still in the Inbox: no state changed, so none is adopted.
+    assert_eq!(
+        state_and_history(&store, "E-roofer1@chislan.family"),
+        (State::Inbox, vec![])
+    );
+}
+
+#[tokio::test]
+async fn a_label_added_elsewhere_sets_its_state() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    // Mail.app's drag: out of the Inbox, onto the label.
+    moved_elsewhere(
+        &world,
+        &client,
+        &household,
+        &mut sync,
+        &store,
+        "E-roofer1",
+        json!({"MB-watch": true}),
+    )
+    .await;
+    assert_eq!(
+        state_and_history(&store, "E-roofer1@chislan.family"),
+        (
+            State::Watch,
+            vec![elsewhere("Moved to Watch via another client")]
+        )
+    );
+
+    // Option-drag keeps it in the Inbox too; the label still wins.
+    moved_elsewhere(
+        &world,
+        &client,
+        &household,
+        &mut sync,
+        &store,
+        "E-roofer1",
+        json!({"MB-in": true, "MB-do": true}),
+    )
+    .await;
+    assert_eq!(
+        state_and_history(&store, "E-roofer1@chislan.family").0,
+        State::Do
+    );
+}
 
-    // A `Docket/` label added elsewhere is adoption's to apply, not the
-    // poll's: Docket-owned state keeps what it holds.
+#[tokio::test]
+async fn a_label_removed_elsewhere_with_no_replacement_sets_done() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    // School sits in the Inbox, Watch, and School; Docket moves it to
+    // Watch's label alone first, as a Watch message normally sits.
+    let school = school_message(&store);
+    store
+        .edit("sam@example.com", &school.id, Change::State(State::Watch))
+        .unwrap();
+    client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+    assert_eq!(
+        memberships_of(&world, "E-school"),
+        json!({"MB-watch": true, "MB-school": true})
+    );
+
+    moved_elsewhere(
+        &world,
+        &client,
+        &household,
+        &mut sync,
+        &store,
+        "E-school",
+        json!({"MB-school": true}),
+    )
+    .await;
+    let message = store.message(&school.id).unwrap().unwrap();
+    assert_eq!(message.values().unwrap().state, State::Done);
+    let history: Vec<_> = store
+        .history(&school.id)
+        .unwrap()
+        .into_iter()
+        .map(|e| (e.user, e.text))
+        .collect();
+    assert_eq!(
+        history,
+        [
+            (Some("sam@example.com".into()), "Moved to Watch".into()),
+            elsewhere("Moved to Done via another client"),
+        ]
+    );
+}
+
+#[tokio::test]
+async fn trash_is_done_whatever_labels_it_keeps() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    moved_elsewhere(
+        &world,
+        &client,
+        &household,
+        &mut sync,
+        &store,
+        "E-roofer2",
+        json!({"MB-trash": true, "MB-do": true}),
+    )
+    .await;
+    assert_eq!(
+        state_and_history(&store, "E-roofer2@chislan.family"),
+        (
+            State::Done,
+            vec![elsewhere("Moved to Done via another client")]
+        )
+    );
+}
+
+#[tokio::test]
+async fn inbox_mail_filed_out_elsewhere_is_done() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    moved_elsewhere(
+        &world,
+        &client,
+        &household,
+        &mut sync,
+        &store,
+        "E-roofer1",
+        json!({"MB-receipts": true}),
+    )
+    .await;
+    let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
+    assert_eq!(
+        roofer1.values().unwrap().folder.as_deref(),
+        Some("Receipts")
+    );
+    assert_eq!(
+        state_and_history(&store, "E-roofer1@chislan.family"),
+        (
+            State::Done,
+            vec![elsewhere("Moved to Done via another client")]
+        )
+    );
+
+    // Dragged back into the Inbox, it's Inbox again.
+    moved_elsewhere(
+        &world,
+        &client,
+        &household,
+        &mut sync,
+        &store,
+        "E-roofer1",
+        json!({"MB-in": true, "MB-receipts": true}),
+    )
+    .await;
+    assert_eq!(
+        state_and_history(&store, "E-roofer1@chislan.family").0,
+        State::Inbox
+    );
+}
+
+#[tokio::test]
+async fn labeled_mail_docket_never_saw_arrives_in_its_state() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    // Filed straight to a label by a rule or another client, never in
+    // the Inbox.
+    world.lock().unwrap().add_email(
+        HOUSEHOLD,
+        email_json(
+            "E-labeled",
+            "T-labeled",
+            ("City Water", "billing@citywater.gov"),
+            "household",
+            &["MB-do", "MB-receipts"],
+            "Overdue notice",
+            Some("Pay by Friday."),
+            "Pay by Friday",
+        ),
+    );
+    // A senderless oddity under a label has nothing to show.
+    let mut noise = email_json(
+        "E-labeled-noise",
+        "T-labeled-noise",
+        ("", ""),
+        "household",
+        &["MB-do"],
+        "(no subject)",
+        None,
+        "",
+    );
+    noise["from"] = Value::Null;
+    world.lock().unwrap().add_email(HOUSEHOLD, noise);
+    // And unlabeled filed mail stays out.
+    world.lock().unwrap().add_email(
+        HOUSEHOLD,
+        email_json(
+            "E-filed",
+            "T-filed",
+            ("City Water", "billing@citywater.gov"),
+            "household",
+            &["MB-receipts"],
+            "Receipt",
+            Some("Thanks."),
+            "Thanks",
+        ),
+    );
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert_eq!(counts.imported, 1, "{counts:?}");
+    assert_eq!(
+        state_and_history(&store, "E-labeled@chislan.family"),
+        (State::Do, vec![])
+    );
+    assert!(the(&store, "E-filed@chislan.family").is_none());
+}
+
+#[tokio::test]
+async fn a_queued_docket_change_outranks_one_made_elsewhere() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    // The Archive refuses mail, so Done stays queued.
     world
         .lock()
         .unwrap()
-        .update_email(HOUSEHOLD, "E-roofer1", |mail| {
-            mail["mailboxIds"]["MB-do"] = json!(true);
+        .update_mailbox(HOUSEHOLD, "MB-archive", |m| {
+            m["myRights"]["mayAddItems"] = json!(false);
         });
-    let counts = client
+    let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
+    store
+        .edit("sam@example.com", &roofer1.id, Change::State(State::Done))
+        .unwrap();
+
+    moved_elsewhere(
+        &world,
+        &client,
+        &household,
+        &mut sync,
+        &store,
+        "E-roofer1",
+        json!({"MB-do": true}),
+    )
+    .await;
+
+    assert_eq!(store.pending_moves("household").unwrap().len(), 1);
+    assert_eq!(
+        state_and_history(&store, "E-roofer1@chislan.family"),
+        (
+            State::Done,
+            vec![(Some("sam@example.com".into()), "Moved to Done".into())]
+        )
+    );
+}
+
+#[tokio::test]
+async fn mail_docket_couldnt_move_keeps_its_state() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    // No Docket/Wait on the server: the mail stays in the Inbox while
+    // Docket says Wait.
+    let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
+    store
+        .edit("sam@example.com", &roofer1.id, Change::State(State::Wait))
+        .unwrap();
+    client
         .poll_once(&household, &mut sync, &store)
         .await
         .unwrap();
+
+    // An unrelated change (a folder) brings it back through the poll;
+    // the mailboxes still say what they said, so nothing is adopted.
+    moved_elsewhere(
+        &world,
+        &client,
+        &household,
+        &mut sync,
+        &store,
+        "E-roofer1",
+        json!({"MB-in": true, "MB-receipts": true}),
+    )
+    .await;
+    assert_eq!(
+        state_and_history(&store, "E-roofer1@chislan.family").0,
+        State::Wait
+    );
+}
+
+#[tokio::test]
+async fn read_only_accounts_adopt_nothing() {
+    init_tracing();
+    let (addr, world) = serve().await;
+    let dir = tempfile::tempdir().unwrap();
+    let eli = credential(&dir, "eli", ELI_TOKEN);
+    let store = Store::open_in_memory(Clock::System).unwrap();
+    let (client, mut sync) = synced(&addr, &store, &eli).await;
+
+    world
+        .lock()
+        .unwrap()
+        .update_email(ELI, "E-practice", |mail| {
+            mail["mailboxIds"] = json!({"MB-do": true});
+        });
+    let counts = client.poll_once(&eli, &mut sync, &store).await.unwrap();
+
     assert_eq!(counts.imported, 1, "{counts:?}");
-    let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
-    assert_eq!(roofer1.values().unwrap().state, State::Inbox);
+    assert_eq!(
+        state_and_history(&store, "E-practice@chislan.family"),
+        (State::Inbox, vec![])
+    );
 }
 
 #[tokio::test]